// ==UserScript== // @name Phabricator Snooze Demo // @namespace https://www.jcbachmann.com // @version 1.3 // @description Enables snoozing of items on Phabricator interface using local Browser storage // @author J&C Bachmann GmbH // @match https://secure.phabricator.com/* // @grant none // ==/UserScript== (function() { 'use strict'; // Constants const DATEPICKER_JS = 'https://secure.phabricator.com/res/phabricator/8ae55229/rsrc/js/core/behavior-fancy-datepicker.js'; const DEBUG = false; // State variables var snoozeOverrideShow = false; var items = new Map(); var snoozedCount = 0; class Item { constructor(id) { this.id = id; this.blocks = Array(); // Read date value from storage var date = Date.parse(localStorage.getItem(this.id)); if (isNaN(date) === false) { date = new Date(date); } else { // Item not snoozed - set to last midnight date = new Date(); } date.setHours(0, 0, 0, 0); this.setDate(date); debug('(' + this.id + ') new item'); } setDate(date) { if (this.date !== undefined && this.date.getTime() == date.getTime()) { return; } this.date = date; // Only store snoozed item dates if (date > new Date()) { // Date in future -> snoozed localStorage.setItem(this.id, date.toISOString()); debug('(' + this.id + ') stored'); if (!this.snoozed) { snoozedCount++; updateItemCounter(); } this.snoozed = true; } else { // Date in past -> active if (localStorage.getItem(this.id) !== null) { localStorage.removeItem(this.id); debug('(' + this.id + ') removed'); } if (this.snoozed) { snoozedCount--; updateItemCounter(); } this.snoozed = false; } this.updateToBlocks(); } addBlock(block) { this.blocks.push(block); debug('(' + this.id + ') block added'); } updateToBlocks() { var self = this; this.blocks.forEach(function(block) { updateItemBlock(self, block); }); } updateFromBlocks() { var self = this; this.blocks.forEach(function(block) { self.setDate(getDateFromItemBlock(block)); }); } } // Debug logging function debug(message) { if (DEBUG) { console.log(message); } } // Two digit function td(d) { return (d < 10 ? '0' : '') + d; } // Convert date to string in format YYYY-MM-DD function dateToString(date) { return date.getFullYear() + '-' + td(date.getMonth() + 1) + '-' + td(date.getDate()); } // Escape special regex characters in string function escapeRegExp(str) { return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); } // Match link and extract item id - e.g. T123 function getItemId(item) { var regexString = '^' + escapeRegExp(window.location.origin) + '\/(.+)$'; var match = item.href.match(new RegExp(regexString)); if (match && match.length === 2) { return match[1]; } else { console.log('could not match \'' + item.href + '\' with \'' + regexString + '\': ' + match); return undefined; } } // Climb up the hierarchy until a list item is reached and declare this as the item block // If body is reached return null function getItemBlock(item) { var itemBlock = item; while (itemBlock.nodeName !== 'LI') { if (itemBlock == document.body) { return null; } itemBlock = itemBlock.parentNode; } return itemBlock; } // Scale past days to hue function daysToHue(days) { var minHue = 0; var maxHue = 200; var minDays = 0; var maxDays = 14; var hue = (maxHue - minHue) * (days - minDays) / (maxDays - minDays) + minHue; return Math.max(minHue, Math.min(hue, maxHue)); } // If not overwritten by flat item is hidden completely // On override item is marked with a color and otherwise displayed normally function filterItemBlock(itemBlock, item) { if (snoozeOverrideShow) { var snoozeItems = itemBlock.getElementsByClassName('snooze-link'); if (snoozeItems.length > 0) { var days = Math.ceil((item.date - new Date()) / (1000 * 60 * 60 * 24)); snoozeItems[0].style.backgroundColor = 'hsl(' + daysToHue(days) + ',100%,30%)'; snoozeItems[0].style.color = 'white'; snoozeItems[0].innerHTML = '
+' + days + '
'; } itemBlock.style.display = ''; } else { itemBlock.style.display = 'none'; } } // Revert changes introduced by item filter function unfilterItemBlock(itemBlock) { itemBlock.style.display = ''; itemBlock.style.backgroundColor = ''; } // Properly style item blocks by snoozed status function updateItemBlock(item, block) { if (item.date > new Date()) { filterItemBlock(block, item); } else { unfilterItemBlock(block); } setDateToItemBlock(block, item.date); } // Add snooze buttons in action blocks on right side function addSnoozeButton(itemBlock, item) { var actionsBlocks = itemBlock.getElementsByClassName('phui-oi-actions'); var actionsBlock; // Check if an action block exists otherwise create one if (actionsBlocks.length == 1) { actionsBlock = actionsBlocks[0]; } else { var frame = itemBlock.firstChild; if (frame.childNodes.length == 1) { actionsBlock = document.createElement('UL'); actionsBlock.classList.add('phui-oi-actions'); frame.appendChild(actionsBlock); } } // If still untouched add button and datepicker magic if (actionsBlock.getElementsByClassName('fa-clock-o').length === 0) { // Add item block only once item.addBlock(itemBlock); var itemNode = document.createElement('LI'); itemNode.classList.add('phui-list-item-view'); itemNode.classList.add('phui-list-item-type-link'); itemNode.classList.add('phui-list-item-has-icon'); itemNode.dataset.sigil = 'phabricator-date-control'; // Date value is read and written by datepicker var dateInput = document.createElement('INPUT'); dateInput.value = dateToString(item.date); dateInput.type = 'hidden'; dateInput.classList.add('date-input'); dateInput.dataset.sigil = 'date-input'; itemNode.appendChild(dateInput); // Time value is complete ignored by datepicker but still presence is required var timeInput = document.createElement('INPUT'); timeInput.type = 'hidden'; timeInput.dataset.sigil = 'time-input'; itemNode.appendChild(timeInput); var linkNode = document.createElement('A'); linkNode.classList.add('phui-list-item-href'); linkNode.classList.add('snooze-link'); linkNode.dataset.sigil = 'calendar-button'; linkNode.style.borderRadius = '3px'; linkNode.innerHTML = ''; itemNode.appendChild(linkNode); actionsBlock.appendChild(itemNode); updateItemBlock(item, itemBlock); } // Correct right offset for other items if (actionsBlock.offsetWidth > 0) { itemBlock.getElementsByClassName('phui-oi-content-box')[0].style.marginRight = (actionsBlock.offsetWidth + 6) + 'px'; } } function getDateFromItemBlock(itemBlock) { return new Date(itemBlock.getElementsByClassName('date-input')[0].value); } function setDateToItemBlock(itemBlock, date) { itemBlock.getElementsByClassName('date-input')[0].value = dateToString(date); } function updateItemCounter() { // Show total count of snoozed items near top icon document.getElementById('snoozedCounter').innerHTML = snoozedCount; } // Refresh list of items function seekItems() { Array.prototype.forEach.call( document.getElementsByClassName('phui-oi-link'), function(itemItem) { var itemBlock = getItemBlock(itemItem); if (itemBlock === null) { return; } // Get item object var itemId = getItemId(itemItem); var item = items.get(itemId); if (item === undefined) { item = new Item(itemId); items.set(itemId, item); } // Take care of item block decoration addSnoozeButton(itemBlock, item); } ); // Pull values from input fields items.forEach(function(item) { item.updateFromBlocks(); }); } // Listen for DOM changes for very fast updates function itemObserver() { var observer = new MutationObserver(function(mutations) { seekItems(); }); observer.observe(document.body, { attributes: true, childList: true, characterData: true }); } // Slow polling as there are still changes which slip through the observer function itemSeeker() { seekItems(); setTimeout(itemSeeker, 500); } function updateAllItemBlocks() { items.forEach(function(item) { item.updateToBlocks(); }); } function initSnoozeToggle() { var mainMenuAlerts = document.getElementsByClassName('phabricator-main-menu-alerts'); if (mainMenuAlerts.length === 0) { return false; } snoozeOverrideShow = localStorage.getItem('snooze override show') === 'true'; // Add toggle button near alarm icons var snoozeToggle = document.createElement('A'); snoozeToggle.classList.add('alert-notifications'); if (!snoozeOverrideShow) { // Start with correct default state snoozeToggle.classList.add('alert-unread'); } snoozeToggle.classList.add('snooze-toggle'); snoozeToggle.innerHTML = ''; mainMenuAlerts[0].appendChild(snoozeToggle); // Toggle override show snoozed on mouse click snoozeToggle.onclick = function(event) { snoozeOverrideShow = !snoozeOverrideShow; localStorage.setItem('snooze override show', snoozeOverrideShow); if (snoozeOverrideShow) { snoozeToggle.classList.remove('alert-unread'); } else { snoozeToggle.classList.add('alert-unread'); } updateAllItemBlocks(); }; // Export var exportDownload = document.createElement('A'); exportDownload.style.display = 'none'; exportDownload.setAttribute('download', 'phabricator-snoozed.json'); document.body.appendChild(exportDownload); var exportButton = document.createElement('A'); exportButton.classList.add('alert-notifications'); exportButton.innerHTML = ''; document.getElementsByClassName('phabricator-main-menu-alerts')[0].appendChild(exportButton); exportButton.onclick = function(event) { exportDownload.setAttribute('href', 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(localStorage))); exportDownload.click(); }; // Import var importUpload = document.createElement('INPUT'); importUpload.type = 'file'; importUpload.style.display = 'none'; importUpload.addEventListener('change', function(e) { var file = e.target.files[0]; if (file) { var reader = new FileReader(); reader.onload = function(e) { var data = JSON.parse(e.target.result); for (var key in data) { localStorage.setItem(key, data[key]); } location.reload(); }; reader.readAsText(file); } }); document.body.appendChild(importUpload); var importButton = document.createElement('A'); importButton.classList.add('alert-notifications'); importButton.innerHTML = ''; document.getElementsByClassName('phabricator-main-menu-alerts')[0].appendChild(importButton); importButton.onclick = function(event) { importUpload.click(); }; return true; } function initDatepicker() { // Add datepicker script var datepickerScript = document.createElement('SCRIPT'); datepickerScript.type = 'text/javascript'; datepickerScript.src = DATEPICKER_JS; datepickerScript.onload = function() { // Link date picker some time after javascript file is loaded var initDatepickerScript = document.createElement('SCRIPT'); initDatepickerScript.type = 'text/javascript'; initDatepickerScript.src = chrome.runtime.getURL('init-datepicker.js'); document.body.appendChild(initDatepickerScript); }; document.body.appendChild(datepickerScript); } // Initialization of whole system called once at start function init() { if (!initSnoozeToggle()) { console.log('Could not initialize snooze toggle - most likely login is required'); return; } initDatepicker(); itemSeeker(); itemObserver(); } init(); })();