diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d244cdd..75251c0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -100,5 +100,18 @@ "example": "work" } } + }, + + "start_next_work": { + "message": "Start work timer", + "description": "Button to start the next work timer" + }, + "start_next_break": { + "message": "Start break timer", + "description": "Button to start the next break timer" + }, + "exit": { + "message": "Done working", + "description": "Button to click when there's no more work to do" } } diff --git a/background.js b/background.js deleted file mode 100644 index d09ff79..0000000 --- a/background.js +++ /dev/null @@ -1,364 +0,0 @@ -/* - - Constants - -*/ - -var PREFS = loadPrefs(), -BADGE_BACKGROUND_COLORS = { - work: [192, 0, 0, 255], - break: [0, 192, 0, 255] -}, RING = new Audio("ring.ogg"), -ringLoaded = false; - -loadRingIfNecessary(); - -function defaultPrefs() { - return { - siteList: [ - 'facebook.com', - 'youtube.com', - 'twitter.com', - 'tumblr.com', - 'pinterest.com', - 'myspace.com', - 'livejournal.com', - 'digg.com', - 'stumbleupon.com', - 'reddit.com', - 'kongregate.com', - 'newgrounds.com', - 'addictinggames.com', - 'hulu.com' - ], - durations: { // in seconds - work: 25 * 60, - break: 5 * 60 - }, - shouldRing: true, - clickRestarts: false, - whitelist: false - } -} - -function loadPrefs() { - if(typeof localStorage['prefs'] !== 'undefined') { - return updatePrefsFormat(JSON.parse(localStorage['prefs'])); - } else { - return savePrefs(defaultPrefs()); - } -} - -function updatePrefsFormat(prefs) { - // Sometimes we need to change the format of the PREFS module. When just, - // say, adding boolean flags with false as the default, there's no - // compatibility issue. However, in more complicated situations, we need - // to modify an old PREFS module's structure for compatibility. - - if(prefs.hasOwnProperty('domainBlacklist')) { - // Upon adding the whitelist feature, the domainBlacklist property was - // renamed to siteList for clarity. - - prefs.siteList = prefs.domainBlacklist; - delete prefs.domainBlacklist; - savePrefs(prefs); - console.log("Renamed PREFS.domainBlacklist to PREFS.siteList"); - } - - if(!prefs.hasOwnProperty('showNotifications')) { - // Upon adding the option to disable notifications, added the - // showNotifications property, which defaults to true. - prefs.showNotifications = true; - savePrefs(prefs); - console.log("Added PREFS.showNotifications"); - } - - return prefs; -} - -function savePrefs(prefs) { - localStorage['prefs'] = JSON.stringify(prefs); - return prefs; -} - -function setPrefs(prefs) { - PREFS = savePrefs(prefs); - loadRingIfNecessary(); - return prefs; -} - -function loadRingIfNecessary() { - console.log('is ring necessary?'); - if(PREFS.shouldRing && !ringLoaded) { - console.log('ring is necessary'); - RING.onload = function () { - console.log('ring loaded'); - ringLoaded = true; - } - RING.load(); - } -} - -var ICONS = { - ACTION: { - CURRENT: {}, - PENDING: {} - }, - FULL: {}, -}, iconTypeS = ['default', 'work', 'break'], - iconType; -for(var i in iconTypeS) { - iconType = iconTypeS[i]; - ICONS.ACTION.CURRENT[iconType] = "icons/" + iconType + ".png"; - ICONS.ACTION.PENDING[iconType] = "icons/" + iconType + "_pending.png"; - ICONS.FULL[iconType] = "icons/" + iconType + "_full.png"; -} - -/* - - Models - -*/ - -function Pomodoro(options) { - this.mostRecentMode = 'break'; - this.nextMode = 'work'; - this.running = false; - - this.onTimerEnd = function (timer) { - this.running = false; - } - - this.start = function () { - var mostRecentMode = this.mostRecentMode, timerOptions = {}; - this.mostRecentMode = this.nextMode; - this.nextMode = mostRecentMode; - - for(var key in options.timer) { - timerOptions[key] = options.timer[key]; - } - timerOptions.type = this.mostRecentMode; - timerOptions.duration = options.getDurations()[this.mostRecentMode]; - this.running = true; - this.currentTimer = new Pomodoro.Timer(this, timerOptions); - this.currentTimer.start(); - } - - this.restart = function () { - if(this.currentTimer) { - this.currentTimer.restart(); - } - } -} - -Pomodoro.Timer = function Timer(pomodoro, options) { - var tickInterval, timer = this; - this.pomodoro = pomodoro; - this.timeRemaining = options.duration; - this.type = options.type; - - this.start = function () { - tickInterval = setInterval(tick, 1000); - options.onStart(timer); - options.onTick(timer); - } - - this.restart = function() { - this.timeRemaining = options.duration; - options.onTick(timer); - } - - this.timeRemainingString = function () { - if(this.timeRemaining >= 60) { - return Math.round(this.timeRemaining / 60) + "m"; - } else { - return (this.timeRemaining % 60) + "s"; - } - } - - function tick() { - timer.timeRemaining--; - options.onTick(timer); - if(timer.timeRemaining <= 0) { - clearInterval(tickInterval); - pomodoro.onTimerEnd(timer); - options.onEnd(timer); - } - } -} - -/* - - Views - -*/ - -// The code gets really cluttered down here. Refactor would be in order, -// but I'm busier with other projects >_< - -function locationsMatch(location, listedPattern) { - return domainsMatch(location.domain, listedPattern.domain) && - pathsMatch(location.path, listedPattern.path); -} - -function parseLocation(location) { - var components = location.split('/'); - return {domain: components.shift(), path: components.join('/')}; -} - -function pathsMatch(test, against) { - /* - index.php ~> [null]: pass - index.php ~> index: pass - index.php ~> index.php: pass - index.php ~> index.phpa: fail - /path/to/location ~> /path/to: pass - /path/to ~> /path/to: pass - /path/to/ ~> /path/to/location: fail - */ - - return !against || test.substr(0, against.length) == against; -} - -function domainsMatch(test, against) { - /* - google.com ~> google.com: case 1, pass - www.google.com ~> google.com: case 3, pass - google.com ~> www.google.com: case 2, fail - google.com ~> yahoo.com: case 3, fail - yahoo.com ~> google.com: case 2, fail - bit.ly ~> goo.gl: case 2, fail - mail.com ~> gmail.com: case 2, fail - gmail.com ~> mail.com: case 3, fail - */ - - // Case 1: if the two strings match, pass - if(test === against) { - return true; - } else { - var testFrom = test.length - against.length - 1; - - // Case 2: if the second string is longer than first, or they are the same - // length and do not match (as indicated by case 1 failing), fail - if(testFrom < 0) { - return false; - } else { - // Case 3: if and only if the first string is longer than the second and - // the first string ends with a period followed by the second string, - // pass - return test.substr(testFrom) === '.' + against; - } - } -} - -function isLocationBlocked(location) { - for(var k in PREFS.siteList) { - listedPattern = parseLocation(PREFS.siteList[k]); - if(locationsMatch(location, listedPattern)) { - // If we're in a whitelist, a matched location is not blocked => false - // If we're in a blacklist, a matched location is blocked => true - return !PREFS.whitelist; - } - } - - // If we're in a whitelist, an unmatched location is blocked => true - // If we're in a blacklist, an unmatched location is not blocked => false - return PREFS.whitelist; -} - -function executeInTabIfBlocked(action, tab) { - var file = "content_scripts/" + action + ".js", location; - location = tab.url.split('://'); - location = parseLocation(location[1]); - - if(isLocationBlocked(location)) { - chrome.tabs.executeScript(tab.id, {file: file}); - } -} - -function executeInAllBlockedTabs(action) { - var windows = chrome.windows.getAll({populate: true}, function (windows) { - var tabs, tab, domain, listedDomain; - for(var i in windows) { - tabs = windows[i].tabs; - for(var j in tabs) { - executeInTabIfBlocked(action, tabs[j]); - } - } - }); -} - -var notification, mainPomodoro = new Pomodoro({ - getDurations: function () { return PREFS.durations }, - timer: { - onEnd: function (timer) { - chrome.browserAction.setIcon({ - path: ICONS.ACTION.PENDING[timer.pomodoro.nextMode] - }); - chrome.browserAction.setBadgeText({text: ''}); - - if(PREFS.showNotifications) { - var nextModeName = chrome.i18n.getMessage(timer.pomodoro.nextMode); - notification = webkitNotifications.createNotification( - ICONS.FULL[timer.type], - chrome.i18n.getMessage("timer_end_notification_header"), - chrome.i18n.getMessage("timer_end_notification_body", nextModeName) - ); - notification.onclick = function () { - console.log("Will get last focused"); - chrome.windows.getLastFocused(function (window) { - chrome.windows.update(window.id, {focused: true}); - }); - this.cancel(); - }; - notification.show(); - } - - if(PREFS.shouldRing) { - console.log("playing ring", RING); - RING.play(); - } - }, - onStart: function (timer) { - chrome.browserAction.setIcon({ - path: ICONS.ACTION.CURRENT[timer.type] - }); - chrome.browserAction.setBadgeBackgroundColor({ - color: BADGE_BACKGROUND_COLORS[timer.type] - }); - if(timer.type == 'work') { - executeInAllBlockedTabs('block'); - } else { - executeInAllBlockedTabs('unblock'); - } - if(notification) notification.cancel(); - var tabViews = chrome.extension.getViews({type: 'tab'}), tab; - for(var i in tabViews) { - tab = tabViews[i]; - if(typeof tab.startCallbacks !== 'undefined') { - tab.startCallbacks[timer.type](); - } - } - }, - onTick: function (timer) { - chrome.browserAction.setBadgeText({text: timer.timeRemainingString()}); - } - } -}); - -chrome.browserAction.onClicked.addListener(function (tab) { - if(mainPomodoro.running) { - if(PREFS.clickRestarts) { - mainPomodoro.restart(); - } - } else { - mainPomodoro.start(); - } -}); - -chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { - if(mainPomodoro.mostRecentMode == 'work') { - executeInTabIfBlocked('block', tab); - } -}); - diff --git a/background/browserAction.js b/background/browserAction.js new file mode 100644 index 0000000..55708af --- /dev/null +++ b/background/browserAction.js @@ -0,0 +1,44 @@ +function updateBadgeText(completeAt) { + if (completeAt) { + var timeRemainingInMilliseconds = completeAt - Date.now(); + var timeRemainingInMinutes = Math.round(timeRemainingInMilliseconds / 1000 / 60); + var text = timeRemainingInMinutes.toString(); + } else { + var text = ""; + } + chrome.browserAction.setBadgeText({text: text}); +} + +Phases.onChanged.addListener(function(state) { + var phase = Phases.get(state.phaseName); + + // Update browser action appearance + chrome.browserAction.setBadgeBackgroundColor({ + color: phase.browserAction.badgeBackgroundColor + }); + chrome.browserAction.setIcon({ + path: phase.browserAction.iconUrl + }); + + // Start alarms for badge text + if (phase.completeAt) { + chrome.alarms.create("browserActionTick", {periodInMinutes: 1}); + } else { + // Clearing the timer may throw an warning if it doesn't exist yet, but + // it'll happen asynchronously and not interrupt the current function. + chrome.alarms.clear("browserActionTick"); + } + updateBadgeText(state.completeAt); +}); + +chrome.browserAction.onClicked.addListener(function() { + Phases.trigger("next"); +}); + +chrome.alarms.onAlarm.addListener(function(alarm) { + if (alarm.name === "browserActionTick") { + Phases.getCurrentState(function(state) { + updateBadgeText(state.completeAt); + }); + } +}); diff --git a/background/notifications.js b/background/notifications.js new file mode 100644 index 0000000..4182747 --- /dev/null +++ b/background/notifications.js @@ -0,0 +1,63 @@ +Phases.onChanged.addListener(function(state, transition) { + var phase = Phases.get(state.phaseName); + var transitions = Phases.getTransitions(state); + + // When a new phase starts, previous notifications are no longer relevant. + chrome.notifications.clear("warning", function() {}); + chrome.notifications.clear("complete", function() {}); + + // Completion notification + if (transition.notification) { + Options.get(["notifications", "audio"], function(items) { + if (items.notifications) { + var buttons = []; + if (transitions.next) { + buttons.push({ + title: phase.controls.next + }); + } + if (transitions.exit) { + buttons.push({ + title: phase.controls.exit + }); + } + chrome.notifications.create("complete", { + type: "basic", + title: "Ring, ring!", // TODO + message: transition.notification.message, + iconUrl: transition.notification.iconUrl, + buttons: buttons + }, function() {}); + } + if (items.audio) { + var ring = new Audio("/ring.ogg"); + ring.play(); + } + }); + } + + // Schedule warning notification + console.log("Considering warning phase: ", phase); + if ("warningNotification" in phase) { + Options.get(["warnAboutReblocking"], function(items) { + if (items.warnAboutReblocking) { + chrome.notifications.create("warning", phase.warningNotification, + function() {}); + } + }); + } +}); + +chrome.notifications.onButtonClicked.addListener(function(id, index) { + if (id === "complete") { + if (index === 0) { + Phases.trigger("next"); + } else { + Phases.trigger("exit"); + } + } else if (id === "warning") { + if (index === 0) { + Options.set({warnAboutReblocking: false}); + } + } +}); diff --git a/background/phaseAlarms.js b/background/phaseAlarms.js new file mode 100644 index 0000000..b867027 --- /dev/null +++ b/background/phaseAlarms.js @@ -0,0 +1,10 @@ +// Consider: if this block were included in lib/phases.js, then the background +// page, content scripts, and options page would *all* try to listen for the +// phaseComplete alarm and trigger the alarm action. Bad news. We only need one +// process in charge of this job, so we explicitly assign it only to the +// background page. +chrome.alarms.onAlarm.addListener(function(alarm) { + if (alarm.name === "phaseComplete") { + Phases.trigger("alarm"); + } +}); diff --git a/background/reset.js b/background/reset.js new file mode 100644 index 0000000..0db4a67 --- /dev/null +++ b/background/reset.js @@ -0,0 +1,9 @@ +// Yes, we're strict, but we want *some* nuclear option to reset the extension. +// Closing the browser is severe enough that people with even a modicum of +// motivation will know better, so we reset whenever the browser starts up, and +// therefore reset whenever the user presses the big red button of restarting +// the browser. +chrome.runtime.onStartup.addListener(function() { + chrome.alarms.clearAll(); + Phases.startTransition({start: "free"}); +}); diff --git a/background/tabMessageRouter.js b/background/tabMessageRouter.js new file mode 100644 index 0000000..3af4ac5 --- /dev/null +++ b/background/tabMessageRouter.js @@ -0,0 +1,29 @@ +// TODO: Right now we're putting the content script on every page and sending +// messages to every page, too. It simplifies the code a *ton*, but +// consider keeping track of the tabs to decide when we even need to +// inject the script at all. Warning: more difficult than it seems. + +function forwardPhaseChanged(tabId, state, transition) { + chrome.tabs.sendMessage(tabId, { + phaseChanged: { + newPhaseState: state, + transition: transition + } + }); +} + +// Forward phase changes to listening tabs +Phases.onChanged.addListener(function(state, transition) { + chrome.tabs.query({}, function(tabs) { + tabs.forEach(function(tab) { + forwardPhaseChanged(tab.id, state, transition); + }); + }); +}); + +// Listen for trigger requests, since tabs can't use all the APIs we can +chrome.runtime.onMessage.addListener(function(request) { + if ("trigger" in request) { + Phases.trigger(request.trigger); + } +}); diff --git a/content/blocking.css b/content/blocking.css new file mode 100644 index 0000000..e2554b9 --- /dev/null +++ b/content/blocking.css @@ -0,0 +1,29 @@ +#__MSG_@@extension_id__-overlay { + background-image: url('chrome-extension://__MSG_@@extension_id__/icons/work_full.png'), + -webkit-linear-gradient(bottom, #ccc 0%, #fff 75%); + background-position: center 64px, top; + background-repeat: no-repeat; + box-sizing: border-box; + color: #222; + display: block; + font: normal normal normal 16px/1 sans-serif; + left: 0; + height: 100%; + padding: 200px 1em 1em; + position: fixed; + text-align: center; + top: 0; + width: 100%; + /* Believe it or not, this is the largest z-index that Chrome 35 won't + * overflow to 0. So, this is how we win the z-index wars - unless someone + * else tries the same thing ;P It seems kinda weird to be this nuclear, + * but the point *is* to overlay all other content on the page, even if it + * came from another extension. For what it's worth, we considered hiding + * all other elements to avoid this war, but that also resets Flash and + * other plugins, and it'd be nice to avoid losing work where possible. */ + z-index: 179769313486231479999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999; +} + +#__MSG_@@extension_id__-overlay p { + margin: 0 0 1em 0; +} \ No newline at end of file diff --git a/content/blocking.js b/content/blocking.js new file mode 100644 index 0000000..4fd8b52 --- /dev/null +++ b/content/blocking.js @@ -0,0 +1,73 @@ +var EXTENSION_ID = chrome.i18n.getMessage("@@extension_id"); +var BLOCKED_CLASS_NAME = EXTENSION_ID + "-blocked"; + +var blocked = false; +var overlay, controls; + +// Phases.trigger requires API permissions that we don't have, so, when the +// controls try to trigger, have them send the router a message instead. +Phases.trigger = function(action) { + chrome.runtime.sendMessage({trigger: action}); +} + +function buildOverlay() { + var overlay = document.createElement("div"); + overlay.id = EXTENSION_ID + "-overlay"; + var messageKeys = ["site_blocked_info", "site_blocked_motivator"]; + messageKeys.forEach(function(key) { + var p = document.createElement("p"); + p.innerText = chrome.i18n.getMessage(key); + overlay.appendChild(p); + }); + return overlay; +} + +function block() { + console.log("Blocked."); + blocked = true; + document.documentElement.classList.add(BLOCKED_CLASS_NAME); + if (!overlay) { + overlay = buildOverlay(); + } + document.body.appendChild(overlay); +} + +function unblock() { + console.log("Unblocked."); + blocked = false; + document.documentElement.classList.remove(BLOCKED_CLASS_NAME); + document.body.removeChild(overlay); +} + +function updateOverlay(phase, transitions) { + if (blocked) { + if (controls) overlay.removeChild(controls); + controls = Controls.build(phase, transitions); + overlay.appendChild(controls); + } +} + +function toggleBlocked(phase, state) { + var transitions = Phases.getTransitions(state); + if (phase.blocked !== blocked) { + // TODO: forward matcher with phase changes to avoid redundant lookups? + SiteMatcher.getCurrent(function(matcher) { + if (!matcher.allows(document.location.href)) { + if (phase.blocked) { + block(); + } else { + unblock(); + } + } + updateOverlay(phase, transitions); + }); + } else { + updateOverlay(phase, transitions); + } +} + +Phases.onChanged.addListener(function(state) { + toggleBlocked(Phases.get(state.phaseName), state); +}); + +Phases.getCurrent(toggleBlocked); diff --git a/content/controls.css b/content/controls.css new file mode 100644 index 0000000..aa22b71 --- /dev/null +++ b/content/controls.css @@ -0,0 +1,4 @@ +.__MSG_@@extension_id__-controls button { + font: -webkit-small-control; + margin: 0 .25em; +} \ No newline at end of file diff --git a/content/controls.js b/content/controls.js new file mode 100644 index 0000000..a364c14 --- /dev/null +++ b/content/controls.js @@ -0,0 +1,27 @@ +var Controls = { + build: function(phase, transitions) { + var wrapper = document.createElement("div"); + wrapper.className = chrome.i18n.getMessage("@@extension_id") + "-controls"; + + if (transitions.next) { + var nextButton = this._buildOne("next"); + nextButton.innerText = phase.controls.next; + wrapper.appendChild(nextButton); + } + + if (transitions.exit) { + var exitButton = this._buildOne("exit"); + exitButton.innerText = phase.controls.exit; + wrapper.appendChild(exitButton); + } + + return wrapper; + }, + _buildOne: function(action) { + var button = document.createElement("button"); + button.addEventListener("click", function() { + Phases.trigger(action); + }); + return button; + } +}; diff --git a/content_scripts/block.js b/content_scripts/block.js deleted file mode 100644 index 00a46f6..0000000 --- a/content_scripts/block.js +++ /dev/null @@ -1,40 +0,0 @@ -(function () { - function ready() { - if(!document.getElementById('matchu-pomodoro-extension-overlay')) { - var overlay = document.createElement('div'), lines = [ - chrome.i18n.getMessage("site_blocked_info"), - chrome.i18n.getMessage("site_blocked_motivator") - ], p, img = document.createElement('img'); - overlay.id = 'matchu-pomodoro-extension-overlay'; - overlay.style.position = 'fixed'; - overlay.style.left = 0; - overlay.style.top = 0; - overlay.style.width = '100%'; - overlay.style.height = '100%'; - overlay.style.zIndex = 9000001; - overlay.style.backgroundImage = '-webkit-linear-gradient(bottom, #ccc 0%, #fff 75%)'; - overlay.style.padding = '5em 1em 1em'; - overlay.style.textAlign = 'center'; - overlay.style.color = '#000'; - overlay.style.font = 'normal normal normal 16px/1 sans-serif'; - - img.src = chrome.extension.getURL('icons/work_full.png'); - img.style.marginBottom = '1em'; - overlay.appendChild(img); - - for(var i in lines) { - p = document.createElement('p'); - p.innerText = lines[i]; - p.style.margin = '0 0 .5em 0'; - overlay.appendChild(p); - } - document.body.appendChild(overlay); - } - } - - if(typeof document === 'undefined') { - window.addEventListener("DOMContentLoaded", ready); - } else { - ready(); - } -})(); diff --git a/content_scripts/unblock.js b/content_scripts/unblock.js deleted file mode 100644 index 5939c94..0000000 --- a/content_scripts/unblock.js +++ /dev/null @@ -1,4 +0,0 @@ -(function () { - var overlay = document.getElementById('matchu-pomodoro-extension-overlay'); - document.body.removeChild(overlay); -})(); diff --git a/icons/icon128.png b/icons/icon128.png index 68d5c3a..4e10d89 100644 Binary files a/icons/icon128.png and b/icons/icon128.png differ diff --git a/icons/icon128_green.png b/icons/icon128_green.png new file mode 100644 index 0000000..00991da Binary files /dev/null and b/icons/icon128_green.png differ diff --git a/lib/options.js b/lib/options.js new file mode 100644 index 0000000..55df4cd --- /dev/null +++ b/lib/options.js @@ -0,0 +1,55 @@ +var Options = { + _DEFAULTS: { + "siteList": { + "sites": [ + "facebook.com", + "youtube.com", + "twitter.com", + "tumblr.com", + "pinterest.com", + "myspace.com", + "livejournal.com", + "digg.com", + "stumbleupon.com", + "reddit.com", + "kongregate.com", + "newgrounds.com", + "addictinggames.com", + "hulu.com" + ], + "type": "blacklist" + }, + "durations": { + "work": 25 * 60 * 1000, + "break": 5 * 60 * 1000 + }, + "notifications": true, + "audio": true, + "warnAboutReblocking": true + }, + _formatKey: function(key) { return "options." + key }, + _unformatKey: function(key) { return key.substr("options.".length) }, + _transformItems: function(items, transformKey) { + var newItems = {}; + Object.keys(items).forEach(function(key) { + newItems[transformKey(key)] = items[key]; + }); + return newItems; + }, + get: function(keys, callback) { + if (typeof keys === "string") keys = [keys]; + var newKeys = {}; + keys.forEach(function(key) { + newKeys[Options._formatKey(key)] = Options._DEFAULTS[key]; + }); + console.log("Getting options", newKeys); + chrome.storage.sync.get(newKeys, function(items) { + callback(Options._transformItems(items, Options._unformatKey)); + }); + }, + set: function(items, callback) { + var newItems = this._transformItems(items, this._formatKey); + console.log("Setting options", items, newItems); + chrome.storage.sync.set(newItems, callback); + } +}; diff --git a/lib/phases.js b/lib/phases.js new file mode 100644 index 0000000..af673b0 --- /dev/null +++ b/lib/phases.js @@ -0,0 +1,227 @@ +var Phases = { + _ALL: { + "free": { + blocked: false, + on: { + always: { + next: { + start: "work" + } + } + }, + browserAction: { + badgeBackgroundColor: [192, 0, 0, 255], + iconUrl: "icons/work_pending.png" + } + }, + "work": { + blocked: true, + on: { + whenRemainingCycles: { + alarm: { + start: "break", + notification: { + iconUrl: "icons/icon128_green.png", + message: "Your scheduled break timer has just started. Enjoy!" // TODO + } + }, + }, + whenNoRemainingCycles: { + alarm: { + start: "afterWork", + notification: { + iconUrl: "icons/icon128.png", + message: "Good work! Now it's time for a break." // TODO + } + } + } + }, + browserAction: { + badgeBackgroundColor: [192, 0, 0, 255], + iconUrl: "icons/work.png" + } + }, + "afterWork": { + blocked: true, + on: { + always: { + next: {start: "break"}, + exit: {start: "free"} + } + }, + browserAction: { + badgeBackgroundColor: [192, 0, 0, 255], + iconUrl: "icons/break_pending.png" + }, + controls: { + next: chrome.i18n.getMessage("start_next_break"), + exit: chrome.i18n.getMessage("exit") + } + }, + "break": { + blocked: false, + on: { + always: { + next: {start: "work"} + }, + whenRemainingCycles: { + alarm: { + start: "work", + changeInRemainingCycles: -1, + notification: { + iconUrl: "icons/icon128.png", + message: "Your scheduled work timer has just started. Get to it!" // TODO + } + }, + }, + whenNoRemainingCycles: { + alarm: { + start: "afterBreak", + notification: { + iconUrl: "icons/icon128_green.png", + message: "Now that you're relaxed, it's time to get back to work." // TODO + } + }, + exit: {whenNoRemainingCycles: {start: "free"}} + } + }, + browserAction: { + badgeBackgroundColor: [0, 192, 0, 255], + iconUrl: "icons/break.png" + }, + controls: { + next: chrome.i18n.getMessage("start_next_work"), + exit: chrome.i18n.getMessage("exit") + }, + warningNotification: { + type: "basic", + title: "Careful!", // TODO + message: "Once this break is over, distracting pages will be " + + "re-blocked immediately. " + + "Don't start something if you can't finish it by the end " + + "of the break.", // TODO + iconUrl: "icons/icon128_green.png", + buttons: [ + {title: "Got it; never warn me again."} // TODO + ] + } + }, + "afterBreak": { + blocked: true, + on: { + always: { + next: {start: "work"}, + exit: {start: "free"} + } + }, + browserAction: { + badgeBackgroundColor: [0, 192, 0, 255], + iconUrl: "icons/work_pending.png" + }, + controls: { + next: chrome.i18n.getMessage("start_next_work"), + exit: chrome.i18n.getMessage("exit") + } + } + }, + _DEFAULT_STATE: {phaseName: "free", completeAt: null, remainingCycles: 0}, + get: function(phaseName) { return this._ALL[phaseName] }, + getCurrentState: function(callback) { + chrome.storage.local.get( + {phaseState: this._DEFAULT_STATE}, + function(items) { + callback(items.phaseState); + } + ); + }, + getCurrent: function(callback) { + this.getCurrentState(function(state) { + callback(Phases.get(state.phaseName), state); + }); + }, + _getDurationFor: function(phaseName, callback) { + Options.get("durations", function(items) { + callback(items.durations[phaseName]); + }); + }, + startTransition: function(transition) { + var newPhaseName = transition.start; + var newPhase = Phases.get(newPhaseName); + Phases.getCurrentState(function(oldState) { + // TODO: skip this get for untimed phases + Phases._getDurationFor(newPhaseName, function(duration) { + var newPhaseState = {phaseName: newPhaseName}; + + var changeInRemainingCycles = transition.changeInRemainingCycles || 0; + newPhaseState.remainingCycles = + oldState.remainingCycles + changeInRemainingCycles; + // The phase state machine should only decrement when it knows that + // there are remaining cycles, and the UI should block attempts to + // do anything but add cycles, but this assertion helps us check those + // assumptions during development. + console.assert(newPhaseState.remainingCycles >= 0); + + chrome.alarms.clear("phaseComplete", function() {}); + var transitions = Phases.getTransitions(newPhaseState); + if ("alarm" in transitions) { + newPhaseState.completeAt = Date.now() + duration; + chrome.alarms.create("phaseComplete", { + when: newPhaseState.completeAt + }); + } + + chrome.storage.local.set({phaseState: newPhaseState}, function() { + chrome.runtime.sendMessage({ + phaseChanged: { + newPhaseState: newPhaseState, + transition: transition + } + }); + }); + }); + }); + }, + onChanged: { + addListener: function(callback) { + chrome.runtime.onMessage.addListener(function(request) { + if ("phaseChanged" in request) { + console.log("Phase change request", request); + var e = request.phaseChanged; + callback(e.newPhaseState, e.transition); + } + }); + } + }, + getTransitions: function(state) { + var transitions = {}; + + function addTransitions(someTransitions) { + if (typeof someTransitions === 'object') { + Object.keys(someTransitions).forEach(function(key) { + transitions[key] = someTransitions[key]; + }); + } + } + + var phase = this.get(state.phaseName); + + addTransitions(phase.on.always); + if (state.remainingCycles > 0) { + addTransitions(phase.on.whenRemainingCycles); + } else { + addTransitions(phase.on.whenNoRemainingCycles); + } + + return transitions; + }, + getCurrentTransitions: function(callback) { + this.getCurrentState(function(state) { + callback(Phases.getTransitions(state)); + }); + }, + trigger: function(actionName) { + this.getCurrentTransitions(function(transitions) { + Phases.startTransition(transitions[actionName]); + }); + } +}; \ No newline at end of file diff --git a/lib/siteMatcher.js b/lib/siteMatcher.js new file mode 100644 index 0000000..7d58966 --- /dev/null +++ b/lib/siteMatcher.js @@ -0,0 +1,80 @@ +function SiteMatcher(siteList) { + var sites = siteList.sites; + var isWhitelist = siteList.type === "whitelist"; + console.log("matcher create", siteList, sites, isWhitelist); + + function parseLocation(location) { + var components = location.split('/'); + return {domain: components.shift(), path: components.join('/')}; + } + + function locationsMatch(location, listedPattern) { + return domainsMatch(location.domain, listedPattern.domain) && + pathsMatch(location.path, listedPattern.path); + } + + function pathsMatch(test, against) { + /* + index.php ~> [null]: pass + index.php ~> index: pass + index.php ~> index.php: pass + index.php ~> index.phpa: fail + /path/to/location ~> /path/to: pass + /path/to ~> /path/to: pass + /path/to/ ~> /path/to/location: fail + */ + return !against || test.substr(0, against.length) === against; + } + + function domainsMatch(test, against) { + /* + google.com ~> google.com: case 1, pass + www.google.com ~> google.com: case 3, pass + google.com ~> www.google.com: case 2, fail + google.com ~> yahoo.com: case 3, fail + yahoo.com ~> google.com: case 2, fail + bit.ly ~> goo.gl: case 2, fail + mail.com ~> gmail.com: case 2, fail + gmail.com ~> mail.com: case 3, fail + */ + // Case 1: if the two strings match, pass + if(test === against) { + return true; + } else { + var testFrom = test.length - against.length - 1; + + // Case 2: if the second string is longer than first, or they are the same + // length and do not match (as indicated by case 1 failing), fail + if(testFrom < 0) { + return false; + } else { + // Case 3: if and only if the first string is longer than the second and + // the first string ends with a period followed by the second string, + // pass + return test.substr(testFrom) === '.' + against; + } + } + } + + this.allows = function allows(url) { + var location = parseLocation(url.split('://')[1]); + for(var k in sites) { + listedPattern = parseLocation(sites[k]); + if(locationsMatch(location, listedPattern)) { + // If we're in a whitelist, a matched location is allowed => true + // If we're in a blacklist, a matched location is not allowed => false + return isWhitelist; + } + } + + // If we're in a whitelist, an unmatched location is not allowed => false + // If we're in a blacklist, an unmatched location is allowed => true + return !isWhitelist; + } +} + +SiteMatcher.getCurrent = function(callback) { + Options.get("siteList", function(items) { + callback(new SiteMatcher(items.siteList)); + }); +} diff --git a/manifest.json b/manifest.json index ed779f5..654099e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,22 @@ { "background": { - "scripts": ["background.js"] + "scripts": ["lib/options.js", "lib/phases.js", "background/browserAction.js", + "background/notifications.js", "background/phaseAlarms.js", + "background/reset.js", "background/tabMessageRouter.js"], + "persistent": false }, "browser_action": { "default_icon": "icons/work_pending.png" }, + "content_scripts": [ + { + "matches": [""], + "css": ["content/controls.css", "content/blocking.css"], + "js": ["lib/options.js", "lib/phases.js", "lib/siteMatcher.js", + "content/blocking.js", "content/controls.js"], + "run_at": "document_end" + } + ], "default_locale": "en", "description": "__MSG_ext_description__", "icons": { @@ -14,8 +26,8 @@ }, "manifest_version": 2, "name": "__MSG_ext_name__", - "options_page": "options.html", - "permissions": [ "notifications", "tabs", "" ], + "options_page": "options/edit.html", + "permissions": [ "alarms", "notifications", "tabs", "storage", "" ], "version": "1.6.1", "web_accessible_resources": [ "icons/work_full.png", diff --git a/options.js b/options.js deleted file mode 100644 index 32dada8..0000000 --- a/options.js +++ /dev/null @@ -1,124 +0,0 @@ -/* - Localization -*/ - -// Localize all elements with a data-i18n="message_name" attribute -var localizedElements = document.querySelectorAll('[data-i18n]'), el, message; -for(var i = 0; i < localizedElements.length; i++) { - el = localizedElements[i]; - message = chrome.i18n.getMessage(el.getAttribute('data-i18n')); - - // Capitalize first letter if element has attribute data-i18n-caps - if(el.hasAttribute('data-i18n-caps')) { - message = message.charAt(0).toUpperCase() + message.substr(1); - } - - el.innerHTML = message; -} - -/* - Form interaction -*/ - -var form = document.getElementById('options-form'), - siteListEl = document.getElementById('site-list'), - whitelistEl = document.getElementById('blacklist-or-whitelist'), - showNotificationsEl = document.getElementById('show-notifications'), - shouldRingEl = document.getElementById('should-ring'), - clickRestartsEl = document.getElementById('click-restarts'), - saveSuccessfulEl = document.getElementById('save-successful'), - timeFormatErrorEl = document.getElementById('time-format-error'), - background = chrome.extension.getBackgroundPage(), - startCallbacks = {}, durationEls = {}; - -durationEls['work'] = document.getElementById('work-duration'); -durationEls['break'] = document.getElementById('break-duration'); - -var TIME_REGEX = /^([0-9]+)(:([0-9]{2}))?$/; - -form.onsubmit = function () { - console.log("form submitted"); - var durations = {}, duration, durationStr, durationMatch; - - for(var key in durationEls) { - durationStr = durationEls[key].value; - durationMatch = durationStr.match(TIME_REGEX); - if(durationMatch) { - console.log(durationMatch); - durations[key] = (60 * parseInt(durationMatch[1], 10)); - if(durationMatch[3]) { - durations[key] += parseInt(durationMatch[3], 10); - } - } else { - timeFormatErrorEl.className = 'show'; - return false; - } - } - - console.log(durations); - - background.setPrefs({ - siteList: siteListEl.value.split(/\r?\n/), - durations: durations, - showNotifications: showNotificationsEl.checked, - shouldRing: shouldRingEl.checked, - clickRestarts: clickRestartsEl.checked, - whitelist: whitelistEl.selectedIndex == 1 - }) - saveSuccessfulEl.className = 'show'; - return false; -} - -siteListEl.onfocus = formAltered; -showNotificationsEl.onchange = formAltered; -shouldRingEl.onchange = formAltered; -clickRestartsEl.onchange = formAltered; -whitelistEl.onchange = formAltered; - -function formAltered() { - saveSuccessfulEl.removeAttribute('class'); - timeFormatErrorEl.removeAttribute('class'); -} - -siteListEl.value = background.PREFS.siteList.join("\n"); -showNotificationsEl.checked = background.PREFS.showNotifications; -shouldRingEl.checked = background.PREFS.shouldRing; -clickRestartsEl.checked = background.PREFS.clickRestarts; -whitelistEl.selectedIndex = background.PREFS.whitelist ? 1 : 0; - -var duration, minutes, seconds; -for(var key in durationEls) { - duration = background.PREFS.durations[key]; - seconds = duration % 60; - minutes = (duration - seconds) / 60; - if(seconds >= 10) { - durationEls[key].value = minutes + ":" + seconds; - } else if(seconds > 0) { - durationEls[key].value = minutes + ":0" + seconds; - } else { - durationEls[key].value = minutes; - } - durationEls[key].onfocus = formAltered; -} - -function setInputDisabled(state) { - siteListEl.disabled = state; - whitelistEl.disabled = state; - for(var key in durationEls) { - durationEls[key].disabled = state; - } -} - -startCallbacks.work = function () { - document.body.className = 'work'; - setInputDisabled(true); -} - -startCallbacks.break = function () { - document.body.removeAttribute('class'); - setInputDisabled(false); -} - -if(background.mainPomodoro.mostRecentMode == 'work') { - startCallbacks.work(); -} diff --git a/options.html b/options/edit.html similarity index 74% rename from options.html rename to options/edit.html index d97ca87..20e1b4a 100644 --- a/options.html +++ b/options/edit.html @@ -3,13 +3,11 @@ -