- // ==UserScript==
- // @name Favro - Toggl Timer
- // @namespace https://www.gotom.io/
- // @version 1.15.0
- // @license MIT
- // @author Mike Meier
- // @match https://favro.com/*
- // @match https://www.toggl.com/api/*
- // @grant GM.xmlHttpRequest
- // @grant GM.setValue
- // @grant GM.getValue
- // @grant GM.notification
- // @grant GM_addStyle
- // @require http://code.jquery.com/jquery-3.4.1.min.js
- // @require https://code.jquery.com/ui/1.12.1/jquery-ui.min.js
- // @resource https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css
- // @description Start Toggl Timer when viewing a Favro ticket
- // ==/UserScript==
- /* jshint esversion: 6 */
- (function ($) {
- const style = `
- .favro-toggl-controls {
- position: absolute;
- top: 12px;
- left: 50%;
- color: red;
- z-index:1000;
- display: inline-block;
- cursor: move;
- }
-
- .favro-toggl-controls--content {
- background-color: #f3f7fb;
- box-shadow: 0 2px 8px 0 rgba(0,0,0,.04);
- padding: 5px;
- border-radius: 4px;
- border: 1px solid #D3D3D3;
- }
-
- .favro-toggl-controls--content .fa {
- cursor: pointer;
- margin: 2px;
- }
-
- .favro-toggl-controls--divider {
- padding-left: 10px;
- margin-left: 6px;
- border-left: 1px solid #D3D3D3;
- }
-
- .favro-toggl-controls--recording {
- animation: favro-toggl-pulse 1s cubic-bezier(.5, 0, 1, 1) infinite alternate;
- }
-
- @keyframes favro-toggl-pulse {
- from { opacity: 1; }
- to { opacity: 0; }
- }
- `;
-
- const FAVRO_EMAIL_KEY_NAME = 'favro_email';
- const FAVRO_API_KEY_NAME = 'favro_api_key';
- const FAVRO_TICKET_PREFIX_KEY_NAME = 'favro_ticket_prefix';
- const FAVRO_ORGANIZATION_ID_KEY_NAME = 'favro_organization_id';
- const FAVRO_COLUMNS_TO_TRACK_KEY_NAME = 'favro_columns_to_track';
- const FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME = 'favro_pid_custom_field_id';
-
- const FAVRO_API_BASE_URL = 'https://favro.com/api/v1';
-
- const TOGGL_API_KEY_NAME = 'toggl_api_key';
- const TOGGL_WID_KEY_NAME = 'toggl_wid';
- const TOGGL_API_BASE_URL = 'https://www.toggl.com/api/v8';
-
- const UI_POSITION_TOP_KEY_VALUE = 'ui_position_top';
- const UI_POSITION_LEFT_KEY_VALUE = 'ui_position_left';
-
- const APP_AUTO_TOGGL_KEY_NAME = 'app_auto_toggl';
- const APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS = 'app_wait_before_tracking_seconds';
-
- const APP_DEFAULT_WAIT_BEFORE_TRACKING = 5000;
- const APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY = 15000;
- const APP_INTERVAL_DETECT_OPEN_CARD_CHANGE = 1000;
-
- const TICKET_NAME_SUFFIX = ' (Auto-Toggl)';
-
- const ENV_VALUES = [
- FAVRO_EMAIL_KEY_NAME,
- FAVRO_API_KEY_NAME,
- FAVRO_TICKET_PREFIX_KEY_NAME,
- FAVRO_ORGANIZATION_ID_KEY_NAME,
- FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME,
- FAVRO_COLUMNS_TO_TRACK_KEY_NAME,
-
- TOGGL_API_KEY_NAME,
- TOGGL_WID_KEY_NAME,
-
- APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS
- ];
-
- function start() {
- $.noConflict();
- ensureEnvironmentVariables();
- setupControlsContainer(setupCurrentTimeEntry);
-
- window.setInterval(setupCurrentTimeEntry, APP_INTERVAL_FETCH_TOGGL_CURRENT_ENTRY);
- window.setInterval(detectOpenCardChanges(onOpenCardChange()), APP_INTERVAL_DETECT_OPEN_CARD_CHANGE);
- window.onbeforeunload = stopTimeEntry;
- }
-
- async function setupCurrentTimeEntry() {
- GM.xmlHttpRequest({
- method: 'GET',
- url: TOGGL_API_BASE_URL + '/time_entries/current',
- headers: await getTogglHeaders(),
- onload: (res) => {
- setCurrentTimeEntry(JSON.parse(res.response).data);
- }
- });
- }
-
- let controlsContainer = null;
-
- function setupControlsContainer(done) {
- $(function () {
- GM_addStyle(style);
- const container = `
- <div id="favro-toggl-controls" class="favro-toggl-controls">
- <div class="favro-toggl-controls--content">
- <i class="fa fa-play-circle" data-favro-toggl-action="start"></i>
- <i class="fa fa-stop-circle" data-favro-toggl-action="stop"></i>
- <i class="fa fa-toggle-off favro-toggl-controls--divider" data-favro-toggl-action="toggl-auto"></i> Auto
- <i class="fa fa-circle" data-recording-button></i>
- <span data-recording-text></span>
- </div>
- </div>
- `;
- //favro-toggl-controls--recording
- $('body').prepend(container);
- controlsContainer = $('#favro-toggl-controls');
- adjustControlsContainerPosition(controlsContainer);
-
- controlsContainer.on('click', '[data-favro-toggl-action]', (e) => {
- const target = $(e.target);
- switch (target.data('favro-toggl-action')) {
- case 'start':
- if (currentOpenCardId) {
- startTimeEntry(currentOpenCardId, true);
- }
- break;
- case 'stop':
- stopTimeEntry();
- break;
- case 'toggl-auto':
- if (target.hasClass('fa-toggle-on')) {
- GM.setValue(APP_AUTO_TOGGL_KEY_NAME, false);
- target.removeClass('fa-toggle-on').addClass('fa-toggle-off');
- stopTimeEntry();
- } else {
- GM.setValue(APP_AUTO_TOGGL_KEY_NAME, true);
- target.removeClass('fa-toggle-off').addClass('fa-toggle-on');
- if (currentOpenCardId) {
- startTimeEntry(currentOpenCardId, true);
- }
- }
- break;
- }
- });
- controlsContainer.draggable({
- stop: () => {
- const pos = controlsContainer.position();
- GM.setValue(UI_POSITION_TOP_KEY_VALUE, pos.top);
- GM.setValue(UI_POSITION_LEFT_KEY_VALUE, pos.left);
- }
- });
-
- done();
- });
- }
-
- async function adjustControlsContainerPosition(controlsContainer) {
- const top = await GM.getValue(UI_POSITION_TOP_KEY_VALUE);
- const left = await GM.getValue(UI_POSITION_LEFT_KEY_VALUE);
- if (top > 0 && left > 0) {
- controlsContainer.css('top', top + 'px');
- controlsContainer.css('left', left + 'px');
- }
-
- if (await isAutoToggl()) {
- controlsContainer.find('.fa-toggle-off').removeClass('fa-toggle-off').addClass('fa-toggle-on');
- }
- }
-
- async function isAutoToggl() {
- return await GM.getValue(APP_AUTO_TOGGL_KEY_NAME) === true;
- }
-
- let currentTimeEntry = null;
-
- function setCurrentTimeEntry(newCurrentTimeEntry) {
- if ((newCurrentTimeEntry === null && currentTimeEntry === null) || newCurrentTimeEntry && currentTimeEntry && newCurrentTimeEntry.id === currentTimeEntry.id) {
- return;
- }
-
- currentTimeEntry = newCurrentTimeEntry;
- if (currentTimeEntry) {
- const description = currentTimeEntry.description.substr(0, 8).trim();
- controlsContainer.find('[data-recording-text]').html(description);
- controlsContainer.find('[data-recording-button]').addClass('favro-toggl-controls--recording');
- } else {
- controlsContainer.find('[data-recording-text]').html('');
- controlsContainer.find('[data-recording-button]').removeClass('favro-toggl-controls--recording');
- }
- }
-
- function updateControlsContainer() {
- if (!controlsContainer) {
- GM.notification({text: 'No controls available'});
- return;
- }
- }
-
- function ensureEnvironmentVariables() {
- ENV_VALUES.forEach(async key => {
- await GM.getValue(key, '__IS_NOT_SET') !== '__IS_NOT_SET' || GM.setValue(key, prompt(key));
- });
- }
-
- let currentOpenCardId = null;
-
- function detectOpenCardChanges(onChange) {
- return async () => {
- const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
- const regex = new RegExp('\\?card=' + ticketPrefix + '([\\d]+)', 'i');
- let search = new URL(location.href).search;
- let matches = regex.exec(search);
- let openCard = matches === null ? null : parseInt(matches[1]);
- if (openCard !== currentOpenCardId) {
- const oldValue = currentOpenCardId;
- currentOpenCardId = openCard;
- onChange(oldValue, openCard);
- }
- }
- }
-
- async function beforeSendFavro() {
- const favroToken = await GM.getValue(FAVRO_API_KEY_NAME);
- const email = await GM.getValue(FAVRO_EMAIL_KEY_NAME);
- const organizationId = await GM.getValue(FAVRO_ORGANIZATION_ID_KEY_NAME);
-
- return (xhr) => {
- xhr.setRequestHeader('Authorization', 'Basic ' + btoa(email + ':' + favroToken));
- xhr.setRequestHeader('organizationId', organizationId);
- xhr.setRequestHeader('Content-Type', 'application/json');
- }
- }
-
- async function getTogglHeaders() {
- const togglToken = await GM.getValue(TOGGL_API_KEY_NAME);
-
- return {
- 'Authorization': 'Basic ' + btoa(togglToken + ':api_token'),
- 'Content-Type': 'application/json'
- };
- }
-
- function getTogglPid(customFields, pidCustomFieldId) {
- if (!customFields) {
- return null;
- }
-
- let pid = null;
- customFields.forEach(customField => {
- if (customField.customFieldId === pidCustomFieldId) {
- pid = customField.total;
- return true;
- }
- });
-
- return pid;
- }
-
- let currentTimeEntryTimoutId = null;
-
- async function startTimeEntryForCard(card, doDelay) {
- const delay = doDelay ? await getTrackingWaitingTime() : 0;
-
- currentTimeEntryTimoutId = window.setTimeout(async () => {
- const ticketPrefix = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME);
- const ticketName = ticketPrefix + card.sequentialId;
- const description = ticketName + ' / ' + card.name + TICKET_NAME_SUFFIX;
- const pidCustomFieldId = await GM.getValue(FAVRO_PID_CUSTOM_FIELD_ID_KEY_NAME);
- const wid = await GM.getValue(TOGGL_WID_KEY_NAME);
- const pid = getTogglPid(card.customFields, pidCustomFieldId);
- const data = JSON.stringify({
- time_entry: {
- wid: wid,
- pid: pid,
- description: description,
- created_with: 'tampermonkey favro-toggl-timer ' + GM_info.script.version,
- }
- });
-
- GM.xmlHttpRequest({
- method: 'POST',
- url: TOGGL_API_BASE_URL + '/time_entries/start',
- data: data,
- headers: await getTogglHeaders(),
- onload: (res) => {
- setCurrentTimeEntry(JSON.parse(res.response).data);
- }
- });
- }, delay);
- }
-
- async function getTrackingWaitingTime() {
- let waitingTime = parseInt(await GM.getValue(APP_WAIT_BEFORE_TRACKING_KEY_NAME_SECONDS));
- if (waitingTime > 0) {
- waitingTime = waitingTime * 1000;
- } else {
- waitingTime = 0;
- }
-
- if (waitingTime < 1000 || waitingTime > 300000) {
- return APP_DEFAULT_WAIT_BEFORE_TRACKING;
- }
-
- return waitingTime;
- }
-
- async function stopTimeEntry() {
- if (currentTimeEntryTimoutId) {
- window.clearTimeout(currentTimeEntryTimoutId);
- }
-
- if (!currentTimeEntry) {
- return;
- }
-
- GM.xmlHttpRequest({
- method: 'PUT',
- url: TOGGL_API_BASE_URL + '/time_entries/' + currentTimeEntry.id + '/stop',
- headers: await getTogglHeaders(),
- onload: () => {
- setCurrentTimeEntry(null);
- }
- });
- }
-
- function isCardInTrackableColumn(card, columnsToTrack) {
- if (columnsToTrack.length === 0) {
- return true;
- }
-
- if (card.columnId && columnsToTrack.indexOf(card.columnId) !== -1) {
- return true;
- }
-
- let found = false;
- const selector = '.boardcolumn .carditem .card-title-text:contains(\'' + $.escapeSelector(card.name) + '\')';
- $(selector).parents('.boardcolumn').each((index, elem) => {
- const columnId = $(elem).attr('id');
- if (columnsToTrack.indexOf(columnId) !== -1) {
- return found = true;
- }
- });
-
- return found;
- }
-
- function onOpenCardChange() {
- return async (oldCardId, newCardId) => {
- if (!(await isAutoToggl())) {
- return;
- }
-
- await stopTimeEntry();
- if (newCardId) {
- await startTimeEntry(newCardId, false);
- }
- };
- }
-
- async function startTimeEntry(cardId, manualStarted) {
- const sequentialId = await GM.getValue(FAVRO_TICKET_PREFIX_KEY_NAME) + cardId;
- let columnsToTrackEnv = await GM.getValue(FAVRO_COLUMNS_TO_TRACK_KEY_NAME);
- if (typeof columnsToTrackEnv !== "string") {
- columnsToTrackEnv = '';
- }
- const columnsToTrack = manualStarted ? [] : columnsToTrackEnv.split(',');
-
- $.ajax({
- type: 'GET',
- url: FAVRO_API_BASE_URL + '/cards?cardSequentialId=' + sequentialId,
- beforeSend: await beforeSendFavro(),
- success: (res) => {
- const card = res.entities[0];
- if (!card) {
- GM.notification({text: 'No card found in favro for sequentialId ' + sequentialId});
- return;
- }
-
- if (!isCardInTrackableColumn(card, columnsToTrack)) {
- return;
- }
-
- startTimeEntryForCard(card, !manualStarted);
- },
- error: err => {
- GM.notification({text: 'Card sequentialId ' + sequentialId + ' fetch error: ' + err});
- }
- });
- }
-
- start();
- })(window.jQuery);