Favro - Toggl Timer

Start Toggl Timer when viewing a Favro ticket

目前为 2020-03-06 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Favro - Toggl Timer
  3. // @namespace https://www.gotom.io/
  4. // @version 1.15.0
  5. // @license MIT
  6. // @author Mike Meier
  7. // @match https://favro.com/*
  8. // @match https://www.toggl.com/api/*
  9. // @grant GM.xmlHttpRequest
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // @grant GM.notification
  13. // @grant GM_addStyle
  14. // @require http://code.jquery.com/jquery-3.4.1.min.js
  15. // @require https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
  16. // @resource https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css
  17. // @description Start Toggl Timer when viewing a Favro ticket
  18. // ==/UserScript==
  19. /* jshint esversion: 6 */
  20. (function ($) {
  21. const style = `
  22. .favro-toggl-controls {
  23. position: absolute;
  24. top: 12px;
  25. left: 50%;
  26. color: red;
  27. z-index:1000;
  28. display: inline-block;
  29. cursor: move;
  30. }
  31. .favro-toggl-controls--content {
  32. background-color: #f3f7fb;
  33. box-shadow: 0 2px 8px 0 rgba(0,0,0,.04);
  34. padding: 5px;
  35. border-radius: 4px;
  36. border: 1px solid #D3D3D3;
  37. }
  38. .favro-toggl-controls--content .fa {
  39. cursor: pointer;
  40. margin: 2px;
  41. }
  42. .favro-toggl-controls--divider {
  43. padding-left: 10px;
  44. margin-left: 6px;
  45. border-left: 1px solid #D3D3D3;
  46. }
  47. .favro-toggl-controls--recording {
  48. animation: favro-toggl-pulse 1s cubic-bezier(.5, 0, 1, 1) infinite alternate;
  49. }
  50. @keyframes favro-toggl-pulse {
  51. from { opacity: 1; }
  52. to { opacity: 0; }
  53. }
  54. `;
  55.  
  56. const FAVRO_EMAIL_KEY_NAME = 'favro_email';
  57. const FAVRO_API_KEY_NAME = 'favro_api_key';
  58. const FAVRO_TICKET_PREFIX_KEY_NAME = 'favro_ticket_prefix';
  59. const FAVRO_ORGANIZATION_ID_KEY_NAME = 'favro_organization_id';
  60. const FAVRO_COLUMNS_TO_TRACK_KEY_NAME = 'favro_columns_to_track';
  61. const FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME = 'favro_pid_custom_field_id';
  62.  
  63. const FAVRO_API_BASE_URL = 'https://favro.com/api/v1';
  64.  
  65. const TOGGL_API_KEY_NAME = 'toggl_api_key';
  66. const TOGGL_WID_KEY_NAME = 'toggl_wid';
  67. const TOGGL_API_BASE_URL = 'https://www.toggl.com/api/v8';
  68.  
  69. const UI_POSITION_TOP_KEY_VALUE = 'ui_position_top';
  70. const UI_POSITION_LEFT_KEY_VALUE = 'ui_position_left';
  71.  
  72. const APP_AUTO_TOGGL_KEY_NAME = 'app_auto_toggl';
  73. const APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS = 'app_wait_before_tracking_seconds';
  74.  
  75. const APP_DEFAULT_WAIT_BEFORE_TRACKING = 5000;
  76. const APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY = 15000;
  77. const APP_INTERVAL_DETECT_OPEN_CARD_CHANGE = 1000;
  78.  
  79. const TICKET_NAME_SUFFIX = ' (Auto-Toggl)';
  80.  
  81. const ENV_VALUES = [
  82. FAVRO_EMAIL_KEY_NAME,
  83. FAVRO_API_KEY_NAME,
  84. FAVRO_TICKET_PREFIX_KEY_NAME,
  85. FAVRO_ORGANIZATION_ID_KEY_NAME,
  86. FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME,
  87. FAVRO_COLUMNS_TO_TRACK_KEY_NAME,
  88.  
  89. TOGGL_API_KEY_NAME,
  90. TOGGL_WID_KEY_NAME,
  91.  
  92. APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS
  93. ];
  94.  
  95. function start() {
  96. $.noConflict();
  97. ensureEnvironmentVariables();
  98. setupControlsContainer(setupCurrentTimeEntry);
  99.  
  100. window.setInterval(setupCurrentTimeEntry, APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY);
  101. window.setInterval(detectOpenCardChanges(onOpenCardChange()), APP_INTERVAL_DETECT_OPEN_CARD_CHANGE);
  102. window.onbeforeunload = stopTimeEntry;
  103. }
  104.  
  105. async function setupCurrentTimeEntry() {
  106. GM.xmlHttpRequest({
  107. method: 'GET',
  108. url: TOGGL_API_BASE_URL + '/time_entries/current',
  109. headers: await getTogglHeaders(),
  110. onload: (res) => {
  111. setCurrentTimeEntry(JSON.parse(res.response).data);
  112. }
  113. });
  114. }
  115.  
  116. let controlsContainer = null;
  117.  
  118. function setupControlsContainer(done) {
  119. $(function () {
  120. GM_addStyle(style);
  121. const container = `
  122. <div id="favro-toggl-controls" class="favro-toggl-controls">
  123. <div class="favro-toggl-controls--content">
  124. <i class="fa fa-play-circle" data-favro-toggl-action="start"></i>
  125. <i class="fa fa-stop-circle" data-favro-toggl-action="stop"></i>
  126. <i class="fa fa-toggle-off favro-toggl-controls--divider" data-favro-toggl-action="toggl-auto"></i> Auto
  127. <i class="fa fa-circle" data-recording-button></i>
  128. <span data-recording-text></span>
  129. </div>
  130. </div>
  131. `;
  132. //favro-toggl-controls--recording
  133. $('body').prepend(container);
  134. controlsContainer = $('#favro-toggl-controls');
  135. adjustControlsContainerPosition(controlsContainer);
  136.  
  137. controlsContainer.on('click', '[data-favro-toggl-action]', (e) => {
  138. const target = $(e.target);
  139. switch (target.data('favro-toggl-action')) {
  140. case 'start':
  141. if (currentOpenCardId) {
  142. startTimeEntry(currentOpenCardId, true);
  143. }
  144. break;
  145. case 'stop':
  146. stopTimeEntry();
  147. break;
  148. case 'toggl-auto':
  149. if (target.hasClass('fa-toggle-on')) {
  150. GM.setValue(APP_AUTO_TOGGL_KEY_NAME, false);
  151. target.removeClass('fa-toggle-on').addClass('fa-toggle-off');
  152. stopTimeEntry();
  153. } else {
  154. GM.setValue(APP_AUTO_TOGGL_KEY_NAME, true);
  155. target.removeClass('fa-toggle-off').addClass('fa-toggle-on');
  156. if (currentOpenCardId) {
  157. startTimeEntry(currentOpenCardId, true);
  158. }
  159. }
  160. break;
  161. }
  162. });
  163. controlsContainer.draggable({
  164. stop: () => {
  165. const pos = controlsContainer.position();
  166. GM.setValue(UI_POSITION_TOP_KEY_VALUE, pos.top);
  167. GM.setValue(UI_POSITION_LEFT_KEY_VALUE, pos.left);
  168. }
  169. });
  170.  
  171. done();
  172. });
  173. }
  174.  
  175. async function adjustControlsContainerPosition(controlsContainer) {
  176. const top = await GM.getValue(UI_POSITION_TOP_KEY_VALUE);
  177. const left = await GM.getValue(UI_POSITION_LEFT_KEY_VALUE);
  178. if (top > 0 && left > 0) {
  179. controlsContainer.css('top', top + 'px');
  180. controlsContainer.css('left', left + 'px');
  181. }
  182.  
  183. if (await isAutoToggl()) {
  184. controlsContainer.find('.fa-toggle-off').removeClass('fa-toggle-off').addClass('fa-toggle-on');
  185. }
  186. }
  187.  
  188. async function isAutoToggl() {
  189. return await GM.getValue(APP_AUTO_TOGGL_KEY_NAME) === true;
  190. }
  191.  
  192. let currentTimeEntry = null;
  193.  
  194. function setCurrentTimeEntry(newCurrentTimeEntry) {
  195. if ((newCurrentTimeEntry === null && currentTimeEntry === null) || newCurrentTimeEntry && currentTimeEntry && newCurrentTimeEntry.id === currentTimeEntry.id) {
  196. return;
  197. }
  198.  
  199. currentTimeEntry = newCurrentTimeEntry;
  200. if (currentTimeEntry) {
  201. const description = currentTimeEntry.description.substr(0, 8).trim();
  202. controlsContainer.find('[data-recording-text]').html(description);
  203. controlsContainer.find('[data-recording-button]').addClass('favro-toggl-controls--recording');
  204. } else {
  205. controlsContainer.find('[data-recording-text]').html('');
  206. controlsContainer.find('[data-recording-button]').removeClass('favro-toggl-controls--recording');
  207. }
  208. }
  209.  
  210. function updateControlsContainer() {
  211. if (!controlsContainer) {
  212. GM.notification({text: 'No controls available'});
  213. return;
  214. }
  215. }
  216.  
  217. function ensureEnvironmentVariables() {
  218. ENV_VALUES.forEach(async key => {
  219. await GM.getValue(key, '__IS_NOT_SET') !== '__IS_NOT_SET' || GM.setValue(key, prompt(key));
  220. });
  221. }
  222.  
  223. let currentOpenCardId = null;
  224.  
  225. function detectOpenCardChanges(onChange) {
  226. return async () => {
  227. const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
  228. const regex = new RegExp('\\?card=' + ticketPrefix + '([\\d]+)', 'i');
  229. let search = new URL(location.href).search;
  230. let matches = regex.exec(search);
  231. let openCard = matches === null ? null : parseInt(matches[1]);
  232. if (openCard !== currentOpenCardId) {
  233. const oldValue = currentOpenCardId;
  234. currentOpenCardId = openCard;
  235. onChange(oldValue, openCard);
  236. }
  237. }
  238. }
  239.  
  240. async function beforeSendFavro() {
  241. const favroToken = await GM.getValue(FAVRO_API_KEY_NAME);
  242. const email = await GM.getValue(FAVRO_EMAIL_KEY_NAME);
  243. const organizationId = await GM.getValue(FAVRO_ORGANIZATION_ID_KEY_NAME);
  244.  
  245. return (xhr) => {
  246. xhr.setRequestHeader('Authorization', 'Basic ' + btoa(email + ':' + favroToken));
  247. xhr.setRequestHeader('organizationId', organizationId);
  248. xhr.setRequestHeader('Content-Type', 'application/json');
  249. }
  250. }
  251.  
  252. async function getTogglHeaders() {
  253. const togglToken = await GM.getValue(TOGGL_API_KEY_NAME);
  254.  
  255. return {
  256. 'Authorization': 'Basic ' + btoa(togglToken + ':api_token'),
  257. 'Content-Type': 'application/json'
  258. };
  259. }
  260.  
  261. function getTogglPid(customFields, pidCustomFieldId) {
  262. if (!customFields) {
  263. return null;
  264. }
  265.  
  266. let pid = null;
  267. customFields.forEach(customField => {
  268. if (customField.customFieldId === pidCustomFieldId) {
  269. pid = customField.total;
  270. return true;
  271. }
  272. });
  273.  
  274. return pid;
  275. }
  276.  
  277. let currentTimeEntryTimoutId = null;
  278.  
  279. async function startTimeEntryForCard(card, doDelay) {
  280. const delay = doDelay ? await getTrackingWaitingTime() : 0;
  281.  
  282. currentTimeEntryTimoutId = window.setTimeout(async () => {
  283. const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
  284. const ticketName = ticketPrefix + card.sequentialId;
  285. const description = ticketName + ' / ' + card.name + TICKET_NAME_SUFFIX;
  286. const pidCustomFieldId = await GM.getValue(FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME);
  287. const wid = await GM.getValue(TOGGL_WID_KEY_NAME);
  288. const pid = getTogglPid(card.customFields, pidCustomFieldId);
  289. const data = JSON.stringify({
  290. time_entry: {
  291. wid: wid,
  292. pid: pid,
  293. description: description,
  294. created_with: 'tampermonkey favro-toggl-timer ' + GM_info.script.version,
  295. }
  296. });
  297.  
  298. GM.xmlHttpRequest({
  299. method: 'POST',
  300. url: TOGGL_API_BASE_URL + '/time_entries/start',
  301. data: data,
  302. headers: await getTogglHeaders(),
  303. onload: (res) => {
  304. setCurrentTimeEntry(JSON.parse(res.response).data);
  305. }
  306. });
  307. }, delay);
  308. }
  309.  
  310. async function getTrackingWaitingTime() {
  311. let waitingTime = parseInt(await GM.getValue(APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS));
  312. if (waitingTime > 0) {
  313. waitingTime = waitingTime * 1000;
  314. } else {
  315. waitingTime = 0;
  316. }
  317.  
  318. if (waitingTime < 1000 || waitingTime > 300000) {
  319. return APP_DEFAULT_WAIT_BEFORE_TRACKING;
  320. }
  321.  
  322. return waitingTime;
  323. }
  324.  
  325. async function stopTimeEntry() {
  326. if (currentTimeEntryTimoutId) {
  327. window.clearTimeout(currentTimeEntryTimoutId);
  328. }
  329.  
  330. if (!currentTimeEntry) {
  331. return;
  332. }
  333.  
  334. GM.xmlHttpRequest({
  335. method: 'PUT',
  336. url: TOGGL_API_BASE_URL + '/time_entries/' + currentTimeEntry.id + '/stop',
  337. headers: await getTogglHeaders(),
  338. onload: () => {
  339. setCurrentTimeEntry(null);
  340. }
  341. });
  342. }
  343.  
  344. function isCardInTrackableColumn(card, columnsToTrack) {
  345. if (columnsToTrack.length === 0) {
  346. return true;
  347. }
  348.  
  349. if (card.columnId && columnsToTrack.indexOf(card.columnId) !== -1) {
  350. return true;
  351. }
  352.  
  353. let found = false;
  354. const selector = '.boardcolumn .carditem .card-title-text:contains(\'' + $.escapeSelector(card.name) + '\')';
  355. $(selector).parents('.boardcolumn').each((index, elem) => {
  356. const columnId = $(elem).attr('id');
  357. if (columnsToTrack.indexOf(columnId) !== -1) {
  358. return found = true;
  359. }
  360. });
  361.  
  362. return found;
  363. }
  364.  
  365. function onOpenCardChange() {
  366. return async (oldCardId, newCardId) => {
  367. if (!(await isAutoToggl())) {
  368. return;
  369. }
  370.  
  371. await stopTimeEntry();
  372. if (newCardId) {
  373. await startTimeEntry(newCardId, false);
  374. }
  375. };
  376. }
  377.  
  378. async function startTimeEntry(cardId, manualStarted) {
  379. const sequentialId = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME) + cardId;
  380. let columnsToTrackEnv = await GM.getValue(FAVRO_COLUMNS_TO_TRACK_KEY_NAME);
  381. if (typeof columnsToTrackEnv !== "string") {
  382. columnsToTrackEnv = '';
  383. }
  384. const columnsToTrack = manualStarted ? [] : columnsToTrackEnv.split(',');
  385.  
  386. $.ajax({
  387. type: 'GET',
  388. url: FAVRO_API_BASE_URL + '/cards?cardSequentialId=' + sequentialId,
  389. beforeSend: await beforeSendFavro(),
  390. success: (res) => {
  391. const card = res.entities[0];
  392. if (!card) {
  393. GM.notification({text: 'No card found in favro for sequentialId ' + sequentialId});
  394. return;
  395. }
  396.  
  397. if (!isCardInTrackableColumn(card, columnsToTrack)) {
  398. return;
  399. }
  400.  
  401. startTimeEntryForCard(card, !manualStarted);
  402. },
  403. error: err => {
  404. GM.notification({text: 'Card sequentialId ' + sequentialId + ' fetch error: ' + err});
  405. }
  406. });
  407. }
  408.  
  409. start();
  410. })(window.jQuery);