├── .browserslistrc ├── scripts ├── run.rb ├── run-localized.rb ├── settings-v1.json ├── simulate-history.js ├── locales.rb ├── fontello-config.json ├── create-messages.rb ├── show-descriptions.rb ├── run-pseudo-localized.rb ├── create-package.rb ├── chrome.rb ├── validate-messages.rb └── package-manifest.json ├── src ├── options │ ├── App.vue │ ├── options.html │ ├── main.js │ ├── SoundSelect.vue │ ├── router.js │ ├── File.js │ ├── WeekDistribution.vue │ ├── Feedback.vue │ ├── Options.vue │ ├── CountdownSettings.vue │ ├── DayDistribution.vue │ ├── Heatmap.vue │ └── History.vue ├── Directives.js ├── expire │ ├── expire.html │ ├── main.js │ └── Expire.vue ├── countdown │ ├── countdown.html │ ├── main.js │ ├── TimerStats.vue │ ├── Timer.vue │ └── Countdown.vue ├── fonts.css ├── Mutex.js ├── TimerSound.js ├── background │ ├── Enum.js │ ├── RLE.js │ ├── StorageManager.js │ ├── Expiration.js │ ├── Alarms.js │ ├── Notification.js │ ├── main.js │ ├── SingletonPage.js │ ├── Services.js │ ├── Settings.js │ ├── Menu.js │ └── Timer.js ├── LocaleFormat.js ├── Sprite.vue ├── Filters.js ├── Noise.js ├── Metronome.js ├── Sounds.js ├── Service.js └── Chrome.js ├── babel.config.js ├── deploy ├── deploy_key.enc └── deploy_key.pub ├── package ├── images │ ├── 128.png │ ├── 16.png │ ├── 48.png │ ├── full.png │ ├── start.png │ ├── browser-action.png │ ├── play.svg │ ├── history.svg │ ├── pause.svg │ ├── restart.svg │ ├── check.svg │ ├── spinner.svg │ └── settings.svg ├── audio │ ├── 0a0ec499.mp3 │ ├── 0f034826.mp3 │ ├── 1a5066bd.mp3 │ ├── 2122d2a4.mp3 │ ├── 28d6b5be.mp3 │ ├── 2e13802a.mp3 │ ├── 2ed9509e.mp3 │ ├── 36e93c27.mp3 │ ├── 4cf03078.mp3 │ ├── 54b867f9.mp3 │ ├── 5cf807ce.mp3 │ ├── 5e122cee.mp3 │ ├── 6103cd58.mp3 │ ├── 6a215611.mp3 │ ├── 6a981bfc.mp3 │ ├── 72312dd3.mp3 │ ├── 72cb1b7f.mp3 │ ├── 831a5549.mp3 │ ├── 85cab25d.mp3 │ ├── 875326f9.mp3 │ ├── 88736c22.mp3 │ ├── 89dafd3e.mp3 │ ├── 8bce59b5.mp3 │ ├── 8dc834f8.mp3 │ ├── 92ff2a8a.mp3 │ ├── 9404f598.mp3 │ ├── 9bd67f7e.mp3 │ ├── a258e906.mp3 │ ├── a273ba0c.mp3 │ ├── ad6eac9e.mp3 │ ├── af607ff1.mp3 │ ├── b10d75f2.mp3 │ ├── b38e515f.mp3 │ ├── bc4e3db2.mp3 │ ├── bced7c21.mp3 │ ├── bd50add0.mp3 │ ├── be75f155.mp3 │ ├── cad167ea.mp3 │ ├── cba5f173.mp3 │ ├── ebe7deb8.mp3 │ ├── edab7b0d.mp3 │ ├── f62b45bc.mp3 │ ├── f9efd11b.mp3 │ ├── fd23aaf3.mp3 │ ├── fd64de98.mp3 │ ├── fe5d2a62.mp3 │ └── fee369b7.mp3 ├── fonts │ ├── SourceSansPro-Regular.ttf │ └── SourceSansPro-SemiBold.ttf ├── manifest.json ├── LICENSE ├── _locales │ └── en_GB │ │ └── messages.json └── ATTRIBUTION ├── postcss.config.js ├── assets ├── screenshots │ ├── menu.png │ ├── break.png │ ├── focus.png │ ├── stats-1.png │ ├── stats-2.png │ ├── settings.png │ └── notification.png └── promotional │ ├── tile-440x280.png │ ├── tile-440x280.xcf │ ├── tile-920x680.pdn │ ├── tile-920x680.png │ ├── fabfeltscript.ttf │ ├── tile-1400x560.pdn │ ├── tile-1400x560.png │ └── hacktoberfest.svg ├── .gitmodules ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── RELEASE.md ├── .travis.yml ├── package.json ├── LICENSE ├── Makefile ├── Runfile ├── tests └── unit │ └── history.spec.js ├── README.md ├── vue.config.js └── CONTRIBUTORS.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 60 2 | -------------------------------------------------------------------------------- /scripts/run.rb: -------------------------------------------------------------------------------- 1 | require 'chrome' 2 | 3 | run_chrome('en') 4 | -------------------------------------------------------------------------------- /src/options/App.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /deploy/deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/deploy/deploy_key.enc -------------------------------------------------------------------------------- /package/images/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/128.png -------------------------------------------------------------------------------- /package/images/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/16.png -------------------------------------------------------------------------------- /package/images/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/48.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package/images/full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/full.png -------------------------------------------------------------------------------- /package/images/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/start.png -------------------------------------------------------------------------------- /assets/screenshots/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/menu.png -------------------------------------------------------------------------------- /package/audio/0a0ec499.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/0a0ec499.mp3 -------------------------------------------------------------------------------- /package/audio/0f034826.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/0f034826.mp3 -------------------------------------------------------------------------------- /package/audio/1a5066bd.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/1a5066bd.mp3 -------------------------------------------------------------------------------- /package/audio/2122d2a4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/2122d2a4.mp3 -------------------------------------------------------------------------------- /package/audio/28d6b5be.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/28d6b5be.mp3 -------------------------------------------------------------------------------- /package/audio/2e13802a.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/2e13802a.mp3 -------------------------------------------------------------------------------- /package/audio/2ed9509e.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/2ed9509e.mp3 -------------------------------------------------------------------------------- /package/audio/36e93c27.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/36e93c27.mp3 -------------------------------------------------------------------------------- /package/audio/4cf03078.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/4cf03078.mp3 -------------------------------------------------------------------------------- /package/audio/54b867f9.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/54b867f9.mp3 -------------------------------------------------------------------------------- /package/audio/5cf807ce.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/5cf807ce.mp3 -------------------------------------------------------------------------------- /package/audio/5e122cee.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/5e122cee.mp3 -------------------------------------------------------------------------------- /package/audio/6103cd58.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/6103cd58.mp3 -------------------------------------------------------------------------------- /package/audio/6a215611.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/6a215611.mp3 -------------------------------------------------------------------------------- /package/audio/6a981bfc.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/6a981bfc.mp3 -------------------------------------------------------------------------------- /package/audio/72312dd3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/72312dd3.mp3 -------------------------------------------------------------------------------- /package/audio/72cb1b7f.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/72cb1b7f.mp3 -------------------------------------------------------------------------------- /package/audio/831a5549.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/831a5549.mp3 -------------------------------------------------------------------------------- /package/audio/85cab25d.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/85cab25d.mp3 -------------------------------------------------------------------------------- /package/audio/875326f9.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/875326f9.mp3 -------------------------------------------------------------------------------- /package/audio/88736c22.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/88736c22.mp3 -------------------------------------------------------------------------------- /package/audio/89dafd3e.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/89dafd3e.mp3 -------------------------------------------------------------------------------- /package/audio/8bce59b5.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/8bce59b5.mp3 -------------------------------------------------------------------------------- /package/audio/8dc834f8.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/8dc834f8.mp3 -------------------------------------------------------------------------------- /package/audio/92ff2a8a.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/92ff2a8a.mp3 -------------------------------------------------------------------------------- /package/audio/9404f598.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/9404f598.mp3 -------------------------------------------------------------------------------- /package/audio/9bd67f7e.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/9bd67f7e.mp3 -------------------------------------------------------------------------------- /package/audio/a258e906.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/a258e906.mp3 -------------------------------------------------------------------------------- /package/audio/a273ba0c.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/a273ba0c.mp3 -------------------------------------------------------------------------------- /package/audio/ad6eac9e.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/ad6eac9e.mp3 -------------------------------------------------------------------------------- /package/audio/af607ff1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/af607ff1.mp3 -------------------------------------------------------------------------------- /package/audio/b10d75f2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/b10d75f2.mp3 -------------------------------------------------------------------------------- /package/audio/b38e515f.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/b38e515f.mp3 -------------------------------------------------------------------------------- /package/audio/bc4e3db2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/bc4e3db2.mp3 -------------------------------------------------------------------------------- /package/audio/bced7c21.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/bced7c21.mp3 -------------------------------------------------------------------------------- /package/audio/bd50add0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/bd50add0.mp3 -------------------------------------------------------------------------------- /package/audio/be75f155.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/be75f155.mp3 -------------------------------------------------------------------------------- /package/audio/cad167ea.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/cad167ea.mp3 -------------------------------------------------------------------------------- /package/audio/cba5f173.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/cba5f173.mp3 -------------------------------------------------------------------------------- /package/audio/ebe7deb8.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/ebe7deb8.mp3 -------------------------------------------------------------------------------- /package/audio/edab7b0d.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/edab7b0d.mp3 -------------------------------------------------------------------------------- /package/audio/f62b45bc.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/f62b45bc.mp3 -------------------------------------------------------------------------------- /package/audio/f9efd11b.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/f9efd11b.mp3 -------------------------------------------------------------------------------- /package/audio/fd23aaf3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/fd23aaf3.mp3 -------------------------------------------------------------------------------- /package/audio/fd64de98.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/fd64de98.mp3 -------------------------------------------------------------------------------- /package/audio/fe5d2a62.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/fe5d2a62.mp3 -------------------------------------------------------------------------------- /package/audio/fee369b7.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/audio/fee369b7.mp3 -------------------------------------------------------------------------------- /assets/screenshots/break.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/break.png -------------------------------------------------------------------------------- /assets/screenshots/focus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/focus.png -------------------------------------------------------------------------------- /assets/screenshots/stats-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/stats-1.png -------------------------------------------------------------------------------- /assets/screenshots/stats-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/stats-2.png -------------------------------------------------------------------------------- /assets/screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/settings.png -------------------------------------------------------------------------------- /assets/promotional/tile-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-440x280.png -------------------------------------------------------------------------------- /assets/promotional/tile-440x280.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-440x280.xcf -------------------------------------------------------------------------------- /assets/promotional/tile-920x680.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-920x680.pdn -------------------------------------------------------------------------------- /assets/promotional/tile-920x680.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-920x680.png -------------------------------------------------------------------------------- /assets/screenshots/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/screenshots/notification.png -------------------------------------------------------------------------------- /package/images/browser-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/images/browser-action.png -------------------------------------------------------------------------------- /assets/promotional/fabfeltscript.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/fabfeltscript.ttf -------------------------------------------------------------------------------- /assets/promotional/tile-1400x560.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-1400x560.pdn -------------------------------------------------------------------------------- /assets/promotional/tile-1400x560.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/assets/promotional/tile-1400x560.png -------------------------------------------------------------------------------- /package/fonts/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/fonts/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /package/fonts/SourceSansPro-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skyme5/marinara/master/package/fonts/SourceSansPro-SemiBold.ttf -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deploy/chrome-extension-localization"] 2 | path = deploy/chrome-extension-localization 3 | url = https://github.com/schmich/chrome-extension-localization 4 | -------------------------------------------------------------------------------- /scripts/run-localized.rb: -------------------------------------------------------------------------------- 1 | require 'chrome' 2 | require 'locales' 3 | 4 | id, _ = choose_locale('Run Chrome under which locale?') 5 | language = id.gsub('_', '-') 6 | run_chrome(language) 7 | -------------------------------------------------------------------------------- /src/Directives.js: -------------------------------------------------------------------------------- 1 | const focus = { 2 | inserted(el) { 3 | let input = el.querySelector('input'); 4 | (input || el).focus(); 5 | } 6 | } 7 | 8 | export { 9 | focus 10 | }; -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Notice 2 | 3 | - If this is your first time contributing, please add yourself to the contributors list at https://github.com/schmich/marinara/blob/master/CONTRIBUTORS.md 4 | - Remove this message before submitting your pull request 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Workspaces 2 | .idea/ 3 | *.code-workspace 4 | 5 | # Development files. 6 | *.zip 7 | .DS_Store 8 | deploy_key 9 | node_modules/ 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # The modules folder is generated by building src. 17 | package/modules/ 18 | -------------------------------------------------------------------------------- /src/expire/expire.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Marinara 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /scripts/settings-v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "focus": { 3 | "duration": 25, 4 | "desktopNotification": true, 5 | "newTabNotification": true, 6 | "sound": null 7 | }, 8 | "break": { 9 | "duration": 5, 10 | "desktopNotification": true, 11 | "newTabNotification": true, 12 | "sound": null 13 | }, 14 | "version": 1 15 | } 16 | -------------------------------------------------------------------------------- /src/countdown/countdown.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Marinara 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Marinara 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/expire/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Expire from './Expire'; 3 | import M from '../Messages'; 4 | 5 | Vue.config.productionTip = false; 6 | Vue.config.devtools = false; 7 | 8 | Vue.mixin({ 9 | computed: { 10 | M() { 11 | return M; 12 | } 13 | } 14 | }); 15 | 16 | new Vue({ 17 | render: h => h(Expire) 18 | }).$mount('#app'); -------------------------------------------------------------------------------- /src/countdown/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Countdown from './Countdown'; 3 | import M from '../Messages'; 4 | 5 | Vue.config.productionTip = false; 6 | Vue.config.devtools = false; 7 | 8 | Vue.mixin({ 9 | computed: { 10 | M() { 11 | return M; 12 | } 13 | } 14 | }); 15 | 16 | new Vue({ 17 | render: h => h(Countdown) 18 | }).$mount('#app'); -------------------------------------------------------------------------------- /src/options/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App'; 3 | import router from './router'; 4 | import M from '../Messages'; 5 | 6 | Vue.config.productionTip = false; 7 | Vue.config.devtools = false; 8 | 9 | Vue.mixin({ 10 | computed: { 11 | M() { 12 | return M; 13 | } 14 | } 15 | }); 16 | 17 | new Vue({ 18 | router, 19 | render: h => h(App) 20 | }).$mount('#app'); -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | - Sync with origin: `git pull --ff-only` 4 | - Bump `version` in `package/manifest.json` 5 | - Run tests: `make test` 6 | - Create package: `make release` 7 | - Publish package at https://chrome.google.com/webstore/devconsole 8 | - Upload updated package 9 | - Update descriptions for each language: `make show-descriptions` 10 | - Commit: `git commit -a` 11 | - Tag release: `git tag -s xx -m "Release xx."` 12 | - Push changes: `git push && git push --tags` 13 | -------------------------------------------------------------------------------- /scripts/simulate-history.js: -------------------------------------------------------------------------------- 1 | // Simulate Pomodoro history entries. 2 | 3 | async function simulate(count) { 4 | function rand(lo, hi) { 5 | return lo + Math.floor(Math.random() * (hi - lo + 1)); 6 | } 7 | let origin = new Date(2015, 0, 0, 0); 8 | let range = (new Date()) - +origin; 9 | for (let i = 0; i < count; ++i) { 10 | let timestamp = +origin + rand(0, range); 11 | await controller.history.addPomodoro(25 * 60, new Date(timestamp)); 12 | } 13 | } 14 | 15 | simulate(2000).then(() => console.log('done')); 16 | -------------------------------------------------------------------------------- /src/options/SoundSelect.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /scripts/locales.rb: -------------------------------------------------------------------------------- 1 | def all_locales 2 | locales = Dir["package/_locales/*"] 3 | .select { |f| File.directory?(f) } 4 | .map { |d| [d.split(/[\\\/]/).last, File.join(d, 'messages.json')] } 5 | .to_h 6 | end 7 | 8 | def choose_locale(prompt) 9 | puts prompt 10 | all_locales.each_with_index do |(id, file), i| 11 | puts "#{(i + 1).to_s(36)}. #{id}" 12 | end 13 | 14 | print '> ' 15 | locale = all_locales.to_a[gets.strip.to_i(36) - 1] 16 | if locale.nil? 17 | puts 'Invalid choice. Exiting.' 18 | exit 19 | end 20 | 21 | locale 22 | end 23 | -------------------------------------------------------------------------------- /src/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(/fonts/SourceSansPro-Regular.ttf) format('truetype'); 6 | } 7 | @font-face { 8 | font-family: 'Source Sans Pro'; 9 | font-style: normal; 10 | font-weight: 600; 11 | src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(/fonts/SourceSansPro-SemiBold.ttf) format('truetype'); 12 | } 13 | body, html, button, select, input { 14 | font: 15px 'Source Sans Pro', sans-serif; 15 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | script: make test 5 | env: 6 | global: 7 | - COMMIT_AUTHOR_NAME: "'Chris Schmich'" 8 | - COMMIT_AUTHOR_EMAIL: schmch@gmail.com 9 | - LOCALES_PATH: package/_locales 10 | deploy: 11 | provider: script 12 | script: bash ./deploy/chrome-extension-localization/deploy/deploy.sh 13 | skip_cleanup: true 14 | on: 15 | branch: master 16 | before_install: 17 | - git submodule update --init --recursive 18 | - openssl aes-256-cbc -K $encrypted_0cfb57c34e7b_key -iv $encrypted_0cfb57c34e7b_iv 19 | -in deploy/deploy_key.enc -out deploy/deploy_key -d 20 | -------------------------------------------------------------------------------- /scripts/fontello-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "5d2d07f112b8de19f2c0dbfec3e42c05", 11 | "css": "spin", 12 | "code": 59448, 13 | "src": "fontelico" 14 | }, 15 | { 16 | "uid": "cb13afd4722a849d48056540bb74c47e", 17 | "css": "play", 18 | "code": 59393, 19 | "src": "entypo" 20 | }, 21 | { 22 | "uid": "130380e481a7defc690dfb24123a1f0c", 23 | "css": "circle", 24 | "code": 61713, 25 | "src": "fontawesome" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /src/Mutex.js: -------------------------------------------------------------------------------- 1 | export default class 2 | { 3 | constructor() { 4 | this.queue = []; 5 | this.pending = false; 6 | } 7 | 8 | async exclusive(fn) { 9 | try { 10 | var release = await this.acquire(); 11 | return await fn(); 12 | } finally { 13 | release(); 14 | } 15 | } 16 | 17 | async acquire() { 18 | const release = () => { 19 | this.pending = this.queue.length > 0; 20 | let next = this.queue.shift(); 21 | next && next(); 22 | }; 23 | 24 | if (this.pending) { 25 | await new Promise(resolve => this.queue.push(resolve)); 26 | return release; 27 | } else { 28 | this.pending = true; 29 | return release; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /deploy/deploy_key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDq4dNdhhNhQBMeeiQsb+qQVHuvpcpZhy/M6p2UPv+tnyPawmo6ERm7Qq6rzTTXbr5xGJqqhloRBKTIy9AHoAC9NV0ZHfRNWMNK3XPxLl3nnsYCj3yzjr34Q6L1wwDDkEa0FvIu3bti6lEjE9ouggms2QuM4KUZWSDArNm5DAvgQP7X1ErhWRsEKoJLCQnlej4GXLXibI6N3PFR/tyx3T8zOt4rlpv7SFttfYZnaGKHp1A/u6HGiF2GyoHJh34K9AHMOP0SRJIPd1t8dbZxtlejtiHv6GSBMEnPYMhJRukB74i76p3wGQvjuZGqhsQifDSr0NYwvT5gJ5eveh0r5QXLR7rageHROhObTQIT5r+NrlthfveIYtaaPevNlIakMZedizvpjXqaJ6eUsPch0Q6E2OzfWkXXDUFiJT1pCptBIvvrpm5410oL2AChJhS4+7DPGNvHHIbQTvhYbGSCLZXhMC7iREAnxbllY4iP8KRSkhMUieJNvzp9w8hrdx4AfR5L0kcjITVfJKzXgOZeHuO4+rMWOdQgM1UeMPrs3D+hVpkEQdjtBFogI5n0UppRVZZ5ND3lgWV+RMGk73hTzi3lmHvM1QecIH4S/ThJk/0RP1IaJD2jSOH8GoxPMTdOYcg7o7f1nBaDHvaYfYPxAkz1DyAN4LFz5AIf1xz5qGM3vQ== schmch@gmail.com 2 | -------------------------------------------------------------------------------- /src/TimerSound.js: -------------------------------------------------------------------------------- 1 | import Metronome from './Metronome'; 2 | import { Noise, whiteNoise, pinkNoise, brownNoise } from './Noise'; 3 | 4 | async function createTimerSound(timerSound) { 5 | if (!timerSound) { 6 | return null; 7 | } 8 | 9 | if (timerSound.metronome) { 10 | let { files, bpm } = timerSound.metronome; 11 | let period = (60 / bpm) * 1000; 12 | return await Metronome.create(files, period); 13 | } 14 | 15 | let node = { 16 | 'white-noise': whiteNoise, 17 | 'pink-noise': pinkNoise, 18 | 'brown-noise': brownNoise 19 | }[timerSound.procedural]; 20 | 21 | if (!node) { 22 | throw new Error('Invalid procedural timer sound.'); 23 | } 24 | 25 | return await Noise.create(node); 26 | } 27 | 28 | export default createTimerSound; -------------------------------------------------------------------------------- /scripts/create-messages.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | messages_filename = ARGV.first 4 | if messages_filename.nil? 5 | puts 'Expected path to messages.json.' 6 | exit 1 7 | end 8 | 9 | # Do not translate line endings. 10 | $stdout.binmode 11 | 12 | puts "class Messages\n{" 13 | 14 | locale = JSON.load(File.read(messages_filename)) 15 | locale.sort_by(&:first).each do |name, value| 16 | message, description, placeholders = value['message'], value['description'], value['placeholders'] 17 | if placeholders.nil? || placeholders.empty? 18 | puts " get #{name}() {\n return chrome.i18n.getMessage('#{name}', []);\n }" 19 | else 20 | params = placeholders.keys.join(', ') 21 | puts " #{name}(#{params}) {\n return chrome.i18n.getMessage('#{name}', [#{params}]);\n }" 22 | end 23 | end 24 | 25 | puts "}\n\nexport default new Messages();" 26 | -------------------------------------------------------------------------------- /scripts/show-descriptions.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'clipboard' 3 | 4 | def load_messages(locale) 5 | JSON.parse(File.read(File.join('package', '_locales', locale, 'messages.json')).force_encoding('UTF-8')) 6 | end 7 | 8 | $en = load_messages('en') 9 | def message(messages, name) 10 | if messages[name] 11 | messages[name]['message'] 12 | else 13 | $en[name]['message'] 14 | end 15 | end 16 | 17 | Dir['package/_locales/*'].sort.each do |path| 18 | locale = File.basename(path) 19 | messages = load_messages(locale) 20 | parts = [ 21 | message(messages, 'chrome_web_store_description'), 22 | 'https://github.com/schmich/marinara', 23 | message(messages, 'disclaimer') 24 | ] 25 | 26 | puts '-' * 80 27 | puts locale 28 | 29 | description = parts.join("\n\n") 30 | puts description 31 | Clipboard.copy(description) 32 | puts "\nCopied to clipboard." 33 | 34 | gets 35 | end 36 | -------------------------------------------------------------------------------- /scripts/run-pseudo-localized.rb: -------------------------------------------------------------------------------- 1 | require 'chrome' 2 | require 'json' 3 | 4 | def pseudolocalize(string) 5 | out = '' 6 | in_var = false 7 | string.each_char do |c| 8 | if !in_var 9 | in_var = (c == '%' || c == '$') 10 | else 11 | in_var = !!(c =~ /[-_0-9A-Za-z%$]/) 12 | end 13 | if in_var 14 | out += c 15 | else 16 | out += c.tr('a-zA-Z', "ăɓĉɗȅƒɠɧɨĵķļɱƞǒρƣɾšƭʊʋɯϰɣʐĂßĈĎƩƑƓĤĨĵЌ£ʍƝǑƤǬƦЅҬǓѶƜЖ¥Ƶ") 17 | end 18 | end 19 | return "<#{out}>" 20 | end 21 | 22 | messages = JSON.parse(File.read('package/_locales/en/messages.json')) 23 | 24 | messages.each do |k, v| 25 | item = messages[k] 26 | item['message'] = pseudolocalize(item['message']) 27 | end 28 | 29 | Dir.mkdir('package/_locales/en_GB') rescue Errno::EEXIST 30 | 31 | File.open('package/_locales/en_GB/messages.json', 'w') do |file| 32 | file.puts(JSON.pretty_generate(messages)) 33 | end 34 | 35 | run_chrome('en-GB') 36 | -------------------------------------------------------------------------------- /src/background/Enum.js: -------------------------------------------------------------------------------- 1 | class EnumOption 2 | { 3 | constructor(name, value) { 4 | if (!Object.is(value, undefined)) { 5 | this.value = value; 6 | } 7 | 8 | this.symbol = Symbol.for(name); 9 | 10 | Object.freeze(this); 11 | } 12 | 13 | [Symbol.toPrimitive](hint) { 14 | return this.value; 15 | } 16 | 17 | toString() { 18 | return this.symbol; 19 | } 20 | 21 | valueOf() { 22 | return this.value; 23 | } 24 | 25 | toJSON() { 26 | return this.value; 27 | } 28 | } 29 | 30 | class Enum 31 | { 32 | constructor(options) { 33 | for (let key in options) { 34 | this[key] = new EnumOption(key, options[key]); 35 | } 36 | 37 | Object.freeze(this); 38 | } 39 | 40 | keys() { 41 | return Object.keys(this); 42 | } 43 | 44 | contains(option) { 45 | if (!(option instanceof EnumOption)) { 46 | return false; 47 | } 48 | 49 | return this[Symbol.keyFor(option.symbol)] === symbol; 50 | } 51 | } 52 | 53 | export default Enum; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marinara", 3 | "private": true, 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "dev": "vue-cli-service build --mode development --no-unsafe-inline --dest package/modules --watch", 7 | "build": "vue-cli-service build --mode production --no-unsafe-inline --dest package/modules", 8 | "test:unit": "vue-cli-service test:unit" 9 | }, 10 | "dependencies": { 11 | "d3": "^5.0.0", 12 | "events": "^3.0.0", 13 | "moment": "^2.24.0", 14 | "tippy.js": "^3.2.0", 15 | "vue": "^2.5.17", 16 | "vue-router": "^3.0.1" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "^4.5.3", 20 | "@vue/cli-plugin-unit-mocha": "^4.5.3", 21 | "@vue/cli-service": "^4.5.3", 22 | "@vue/test-utils": "^1.0.3", 23 | "chai": "^4.2.0", 24 | "filemanager-webpack-plugin": "^2.0.5", 25 | "node-sass": "^4.14.1", 26 | "sass-loader": "^9.0.3", 27 | "vue-template-compiler": "^2.6.11", 28 | "webpack-touch": "^1.0.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/LocaleFormat.js: -------------------------------------------------------------------------------- 1 | import M from './Messages'; 2 | import { timeFormatLocale } from 'd3'; 3 | 4 | const monthIds = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; 5 | const dayIds = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; 6 | 7 | const days = dayIds.map(d => M[d]); 8 | const shortDays = dayIds.map(d => M[`${d}_short`]); 9 | const months = monthIds.map(m => M[m]); 10 | const shortMonths = monthIds.map(m => M[`${m}_short`]); 11 | 12 | const formatter = timeFormatLocale({ 13 | decimal: M.decimal_separator, 14 | thousands: M.thousands_separator, 15 | grouping: [3], 16 | dateTime: M.date_time_format, 17 | date: M.date_format, 18 | time: M.time_format, 19 | periods: [M.time_period_am, M.time_period_pm], 20 | days, 21 | shortDays, 22 | months, 23 | shortMonths 24 | }).format; 25 | 26 | export { 27 | days, 28 | shortDays, 29 | months, 30 | shortMonths, 31 | formatter 32 | }; -------------------------------------------------------------------------------- /src/background/RLE.js: -------------------------------------------------------------------------------- 1 | class RLE 2 | { 3 | static compress(arr) { 4 | let result = []; 5 | let start = 0; 6 | let group = arr[0]; 7 | 8 | // Intentionally go past the end of the array to simplify adding last group. 9 | for (let i = 1; i <= arr.length; ++i) { 10 | if (arr[i] === group) { 11 | continue; 12 | } 13 | 14 | result.push(i - start); 15 | result.push(group); 16 | start = i; 17 | group = arr[i]; 18 | } 19 | 20 | return result; 21 | } 22 | 23 | static decompress(arr) { 24 | let result = []; 25 | for (let i = 0; i < arr.length; i += 2) { 26 | for (let j = 0; j < arr[i]; ++j) { 27 | result.push(arr[i + 1]); 28 | } 29 | } 30 | 31 | return result; 32 | } 33 | 34 | static append(arr, el) { 35 | if (arr.length > 0 && el === arr[arr.length - 1]) { 36 | arr[arr.length - 2]++; 37 | } else { 38 | arr.push(1, el); 39 | } 40 | 41 | return arr; 42 | } 43 | } 44 | 45 | export default RLE; -------------------------------------------------------------------------------- /package/images/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /package/images/history.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_app_name__", 3 | "short_name": "__MSG_app_name_short__", 4 | "description": "__MSG_app_desc__", 5 | "default_locale": "en", 6 | "version": "32", 7 | "manifest_version": 2, 8 | "author": "Chris Schmich ", 9 | "homepage_url": "https://github.com/schmich/marinara", 10 | "offline_enabled": true, 11 | "permissions": [ 12 | "alarms", 13 | "contextMenus", 14 | "storage", 15 | "notifications" 16 | ], 17 | "browser_action": { 18 | "default_title": "Marinara", 19 | "default_icon": "images/browser-action.png" 20 | }, 21 | "background": { 22 | "persistent": true, 23 | "scripts": [ 24 | "modules/chunk-common.js", 25 | "modules/chunk-vendors.js", 26 | "modules/background.js" 27 | ] 28 | }, 29 | "options_page": "modules/options.html", 30 | "icons": { 31 | "16": "images/16.png", 32 | "48": "images/48.png", 33 | "128": "images/128.png" 34 | }, 35 | "minimum_chrome_version": "55", 36 | "content_security_policy": "script-src 'self'; object-src 'self'" 37 | } 38 | -------------------------------------------------------------------------------- /package/images/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Schmich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Sprite.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | 14 | -------------------------------------------------------------------------------- /package/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Schmich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/options/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | import Options from './Options'; 4 | import Settings from './Settings'; 5 | import History from './History'; 6 | import Feedback from './Feedback'; 7 | import M from '../Messages'; 8 | 9 | Vue.use(Router); 10 | 11 | const router = new Router({ 12 | mode: 'hash', 13 | base: '/', 14 | routes: [ 15 | { 16 | path: '/', 17 | component: Options, 18 | children: [ 19 | { 20 | path: '', 21 | redirect: 'settings' 22 | }, 23 | { 24 | path: 'settings', 25 | name: 'settings', 26 | component: Settings, 27 | meta: { title: M.settings } 28 | }, 29 | { 30 | path: 'history', 31 | name: 'history', 32 | component: History, 33 | meta: { title: M.history } 34 | }, 35 | { 36 | path: 'feedback', 37 | name: 'feedback', 38 | component: Feedback, 39 | meta: { title: M.feedback } 40 | } 41 | ] 42 | } 43 | ] 44 | }); 45 | 46 | router.beforeEach((to, from, next) => { 47 | document.title = `${to.meta.title} - ${M.app_name_short}`; 48 | next(); 49 | }); 50 | 51 | export default router; 52 | -------------------------------------------------------------------------------- /src/Filters.js: -------------------------------------------------------------------------------- 1 | import M from './Messages'; 2 | import { formatter } from './LocaleFormat'; 3 | 4 | function integer(value) { 5 | return value.toLocaleString(); 6 | } 7 | 8 | function float(value, digits) { 9 | return value.toLocaleString(navigator.language, { 10 | minimumFractionDigits: digits, 11 | maximumFractionDigits: digits 12 | }); 13 | } 14 | 15 | function strftime(value, format) { 16 | return formatter(format)(value); 17 | } 18 | 19 | function pomodoroCount(count) { 20 | if (count === 0) { 21 | return M.pomodoro_count_zero; 22 | } else if (count === 1) { 23 | return M.pomodoro_count_one; 24 | } else { 25 | return M.pomodoro_count_many(count.toLocaleString()); 26 | } 27 | } 28 | 29 | function mmss(seconds) { 30 | let minutes = Math.floor(seconds / 60); 31 | if (minutes < 10) { 32 | minutes = '0' + minutes; 33 | } 34 | seconds = Math.floor(seconds % 60); 35 | if (seconds < 10) { 36 | seconds = '0' + seconds; 37 | } 38 | return `${minutes}:${seconds}`; 39 | } 40 | 41 | function clamp(value, lo, hi) { 42 | if (value <= lo) { 43 | return lo; 44 | } 45 | 46 | if (value >= hi) { 47 | return hi; 48 | } 49 | 50 | return value; 51 | } 52 | 53 | export { 54 | float, 55 | integer, 56 | strftime, 57 | pomodoroCount, 58 | mmss, 59 | clamp 60 | }; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dev 2 | 3 | # Run development environment. 4 | dev: 5 | npm run dev 6 | 7 | .PHONY: release package 8 | 9 | # Create Chrome extension package (.zip). 10 | release: production 11 | ruby -Iscripts scripts/create-package.rb 12 | 13 | production: messages 14 | npm run build 15 | 16 | .PHONY: messages validate-messages 17 | 18 | # Create Messages.js. 19 | messages: validate-messages src/Messages.js 20 | 21 | # Sanity check all messages.json files. 22 | validate-messages: 23 | ruby -Iscripts scripts/validate-messages.rb 24 | 25 | # JS bindings for messages in messages.json. 26 | src/Messages.js: package/_locales/en/messages.json 27 | ruby -Iscripts scripts/create-messages.rb "$<" > "$@" 28 | 29 | .PHONY: test 30 | 31 | # Run tests. 32 | test: 33 | npm run test:unit 34 | 35 | .PHONY: run run-loc run-pseudo show-descriptions 36 | 37 | # Run Chrome with a new (temporary) user profile with Marinara loaded. 38 | run: 39 | ruby -Iscripts scripts/run.rb 40 | 41 | # Run Chrome under a different locale. 42 | run-loc: 43 | ruby -Iscripts scripts/run-localized.rb 44 | 45 | # Run Chrome with psuedo-localized messages. 46 | run-pseudo: 47 | ruby -Iscripts scripts/run-pseudo-localized.rb 48 | 49 | # Show and copy descriptions for Chrome Web Store. 50 | show-descriptions: 51 | ruby -Iscripts scripts/show-descriptions.rb 52 | -------------------------------------------------------------------------------- /Runfile: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'fileutils' 3 | require 'tempfile' 4 | 5 | def sound_name 6 | SecureRandom.uuid.split('-').first 7 | end 8 | 9 | dir :pwd 10 | run :rename do |file| 11 | parts = file.split('.') 12 | dir = File.dirname(file) 13 | new_name = [sound_name(), *parts.slice(1, parts.length)].compact.join('.') 14 | out = File.join(dir, new_name) 15 | FileUtils.mv(file, out) 16 | puts "#{file} -> #{out}" 17 | end 18 | 19 | dir :pwd 20 | run :convert do |file| 21 | name = sound_name() 22 | out = "#{name}.mp3" 23 | `ffmpeg -i "#{file}" -q:a 6 "#{out}"` 24 | unless $?.success? 25 | $stderr.puts 'Failed to convert.' 26 | next 27 | end 28 | puts "#{file} -> #{out}" 29 | end 30 | 31 | dir :pwd 32 | run :waveform do |file| 33 | temp = Tempfile.new(["#{File.basename(file)}-", '.png']) 34 | path = temp.path 35 | temp.close(true) 36 | 37 | `ffmpeg -i "#{file}" -filter_complex \ 38 | "[0:a]aformat=channel_layouts=mono, \ 39 | compand=gain=-6, \ 40 | showwavespic=s=600x120:colors=#9cf42f[fg]; \ 41 | color=s=600x120:color=#44582c, \ 42 | drawgrid=width=iw/10:height=ih/5:color=#9cf42f@0.1[bg]; \ 43 | [bg][fg]overlay=format=rgb,drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=1:color=#9cf42f" \ 44 | -vframes 1 "#{path}"` 45 | 46 | `open "#{path}"` if $?.success? 47 | end 48 | -------------------------------------------------------------------------------- /package/images/restart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /scripts/create-package.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'zip' 3 | require 'set' 4 | 5 | this_dir = File.expand_path(File.dirname(__FILE__)) 6 | package_dir = File.expand_path(File.join(this_dir, '..', 'package')) 7 | 8 | # Ensure the files we're about to package match the predefined manifest. 9 | # If a file is added or removed, package-manifest.json must be updated. 10 | expected_files = Set.new(JSON.parse(File.read(File.join(this_dir, 'package-manifest.json')))) 11 | actual_files = Set.new(Dir[File.join(package_dir, '**/*')].select { |f| File.file?(f) }.map { |f| f.sub(package_dir + '/', '') }) 12 | 13 | unexpected_files = actual_files - expected_files 14 | missing_files = expected_files - actual_files 15 | 16 | if unexpected_files.any? 17 | puts "Unexpected files in package: #{unexpected_files.to_a.join(', ')}" 18 | exit 1 19 | elsif missing_files.any? 20 | puts "Files missing from package: #{missing_files.to_a.join(', ')}" 21 | exit 1 22 | end 23 | 24 | version = JSON.load(File.read(File.join(package_dir, 'manifest.json')))['version'] 25 | out = "marinara-#{version}.zip" 26 | 27 | if File.exist?(out) 28 | puts "#{out} already exists." 29 | exit 1 30 | end 31 | 32 | Zip::File::open(out, 'w') do |zip| 33 | Dir[File.join(package_dir, '**/*')].each do |path| 34 | zip.add(path.sub(package_dir + '/', ''), path) 35 | end 36 | end 37 | 38 | puts "Package created: #{out}." 39 | -------------------------------------------------------------------------------- /src/background/StorageManager.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | class StorageManager extends EventEmitter 4 | { 5 | constructor(schema, storage) { 6 | super(); 7 | this.schema = schema; 8 | this.storage = storage; 9 | } 10 | 11 | async get() { 12 | let [payload, modified] = this._upgrade(await this.storage.get()); 13 | if (modified) { 14 | await this.storage.clear(); 15 | await this.storage.set(payload); 16 | } 17 | 18 | return payload; 19 | } 20 | 21 | async set(payload) { 22 | var [payload, _] = this._upgrade(payload); 23 | await this.storage.set(payload); 24 | this.emit('change', payload); 25 | } 26 | 27 | _upgrade(payload) { 28 | let modified = false; 29 | 30 | if (Object.keys(payload).length === 0) { 31 | modified = true; 32 | payload = this.schema.default; 33 | } 34 | 35 | if (!payload.version) { 36 | throw new Error('Missing version.'); 37 | } 38 | 39 | if (payload.version < this.schema.version) { 40 | modified = true; 41 | for (let version = payload.version; version < this.schema.version; ++version) { 42 | let method = `from${version}To${version + 1}`; 43 | payload = this.schema[method](payload); 44 | 45 | if (payload.version !== (version + 1)) { 46 | throw new Error('Unexpected version.'); 47 | } 48 | } 49 | } 50 | 51 | return [payload, modified]; 52 | } 53 | } 54 | 55 | export default StorageManager; -------------------------------------------------------------------------------- /package/images/check.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /package/images/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 12 | 13 | 14 | 18 | 22 | 23 | 24 | 28 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/background/Expiration.js: -------------------------------------------------------------------------------- 1 | import { SingletonPage, PageHost } from './SingletonPage'; 2 | import { Service, ServiceBroker } from '../Service'; 3 | 4 | class ExpirationService extends Service 5 | { 6 | constructor(properties, page) { 7 | super(); 8 | this.properties = properties; 9 | this.page = page; 10 | } 11 | 12 | async getProperties() { 13 | // When the expiration page asks for properties, it has loaded, 14 | // so we can focus it now. 15 | setTimeout(() => this.page.focus(), 0); 16 | return this.properties; 17 | } 18 | } 19 | 20 | class ExpirationPage 21 | { 22 | static async show(title, messages, action, pomodoros, phase) { 23 | let page = await SingletonPage.show(chrome.extension.getURL('modules/expire.html'), PageHost.Tab); 24 | return new ExpirationPage(page, title, messages, action, pomodoros, phase); 25 | } 26 | 27 | constructor(page, title, messages, action, pomodoros, phase) { 28 | this.page = page; 29 | 30 | const properties = { title, messages, pomodoros, action, phase }; 31 | this.service = new ExpirationService(properties, page); 32 | ServiceBroker.register(this.service); 33 | 34 | const self = this; 35 | chrome.tabs.onRemoved.addListener(function removed(id) { 36 | if (id !== page.tabId) { 37 | return; 38 | } 39 | 40 | // Service no longer needed. 41 | ServiceBroker.unregister(self.service); 42 | chrome.tabs.onRemoved.removeListener(removed); 43 | }); 44 | } 45 | 46 | close() { 47 | this.page.close(); 48 | } 49 | } 50 | 51 | const ExpirationClient = ExpirationService.proxy; 52 | 53 | export { 54 | ExpirationPage, 55 | ExpirationClient 56 | }; -------------------------------------------------------------------------------- /src/background/Alarms.js: -------------------------------------------------------------------------------- 1 | import Chrome from '../Chrome'; 2 | import Mutex from '../Mutex'; 3 | 4 | let settings = null; 5 | let mutex = new Mutex(); 6 | 7 | async function install(timer, settingsManager) { 8 | settings = await settingsManager.get(); 9 | settingsManager.on('change', async newSettings => { 10 | settings = newSettings; 11 | await setAlarm(settings); 12 | }); 13 | chrome.alarms.onAlarm.addListener(alarm => onAlarm(alarm, timer)); 14 | await setAlarm(settings); 15 | } 16 | 17 | async function setAlarm(settings) { 18 | await mutex.exclusive(async () => { 19 | await Chrome.alarms.clearAll(); 20 | 21 | let time = settings.autostart && settings.autostart.time; 22 | if (!time) { 23 | return; 24 | } 25 | 26 | const now = new Date(); 27 | 28 | let startAt = new Date(); 29 | startAt.setHours(...time.split(':'), 0, 0); 30 | if (startAt <= now) { 31 | // The trigger is in the past. Set it for tomorrow instead. 32 | startAt.setDate(startAt.getDate() + 1); 33 | } 34 | 35 | Chrome.alarms.create('autostart', { when: +startAt, }); 36 | }); 37 | } 38 | 39 | async function onAlarm(alarm, timer) { 40 | if (alarm.name !== 'autostart') { 41 | return; 42 | } 43 | 44 | // Set next autostart alarm. 45 | await setAlarm(settings); 46 | 47 | if (!timer.isStopped) { 48 | return; 49 | } 50 | 51 | // Start a new cycle. 52 | timer.startCycle(); 53 | 54 | Chrome.notifications.create({ 55 | type: 'basic', 56 | title: M.autostart_notification_title, 57 | message: M.autostart_notification_message, 58 | iconUrl: 'images/128.png', 59 | isClickable: false, 60 | requireInteraction: true 61 | }); 62 | } 63 | 64 | export { 65 | install 66 | }; -------------------------------------------------------------------------------- /scripts/chrome.rb: -------------------------------------------------------------------------------- 1 | require 'tmpdir' 2 | require 'os' 3 | 4 | def run_chrome(language) 5 | if OS.mac? 6 | run_chrome_macos(language) 7 | elsif OS.windows? 8 | run_chrome_windows(language) 9 | else 10 | $stderr.puts "Launching Chrome on this OS (#{RUBY_PLATFORM}) is not implemented." 11 | end 12 | end 13 | 14 | def run_chrome_windows(language) 15 | user_data_dir = Dir.mktmpdir('marinara') 16 | extension_dir = File.join(Dir.pwd, 'package') 17 | 18 | args = [ 19 | '--no-first-run', 20 | "--lang=#{language}", 21 | "--user-data-dir=#{user_data_dir}", 22 | "--load-extension=#{extension_dir}", 23 | 'about:blank' 24 | ] 25 | 26 | puts "Running Chrome with locale #{language}." 27 | system('\Program Files (x86)\Google\Chrome\Application\chrome.exe', *args) 28 | end 29 | 30 | def run_chrome_macos(language) 31 | user_data_dir = Dir.mktmpdir('marinara') 32 | extension_dir = File.join(Dir.pwd, 'package') 33 | 34 | orig = `defaults read com.google.Chrome AppleLanguages 2>&1` 35 | if $?.success? 36 | languages = orig.lines.slice(1, orig.lines.length - 2).map { |s| s.strip.tr(',', ' ') } 37 | restore = -> { `defaults write com.google.Chrome AppleLanguages '(#{languages.join(',')})'` } 38 | else 39 | restore = -> { `defaults delete com.google.Chrome AppleLanguages` } 40 | end 41 | 42 | `defaults write com.google.Chrome AppleLanguages '("#{language}")'` 43 | 44 | begin 45 | args = [ 46 | '--new', 47 | '-a', 48 | 'Google Chrome', 49 | '--args', 50 | '--no-first-run', 51 | "--user-data-dir=#{user_data_dir}", 52 | "--load-extension=#{extension_dir}", 53 | 'about:blank' 54 | ] 55 | 56 | puts "Running Chrome with locale #{language}." 57 | system('open', *args) 58 | ensure 59 | restore.call 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /package/_locales/en_GB/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "heatmap_date_format": { 3 | "message": "%d/%m/%Y", 4 | "description": "Heatmap tooltip date format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format. Shown on the settings-history page." 5 | }, 6 | "hour_format": { 7 | "message": "%H", 8 | "description": "Hour-only format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format. Shown on the daily distribution chart on the settings-history page." 9 | }, 10 | "hour_minute_format": { 11 | "message": "%H:%M", 12 | "description": "Hour-with-minutes format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format. Shown in the daily distribution chart tooltips on the settings-history page." 13 | }, 14 | "date_format": { 15 | "message": "%d/%m/%Y", 16 | "description": "Date-only format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format." 17 | }, 18 | "date_time_format": { 19 | "message": "%d/%m/%Y, %H:%M:%S", 20 | "description": "Date-with-time format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format." 21 | }, 22 | "time_format": { 23 | "message": "%H:%M:%S", 24 | "description": "Time-only format. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format." 25 | }, 26 | "time_period_am": { 27 | "message": "", 28 | "description": "12-hour clock AM specifier. Can be empty if 24-hour clock is used. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format." 29 | }, 30 | "time_period_pm": { 31 | "message": "", 32 | "description": "12-hour clock PM specifier. Can be empty if 24-hour clock is used. See format options at https://github.com/d3/d3-time-format/blob/master/README.md#locale_format." 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/unit/history.spec.js: -------------------------------------------------------------------------------- 1 | 2 | import { assert } from 'chai'; 3 | import * as History from '@/background/History'; 4 | 5 | describe('History', () => { 6 | it('merges empty history', () => { 7 | const basic = { 8 | pomodoros: [1], 9 | durations: [25], 10 | timezones: [0] 11 | }; 12 | 13 | const empty = { 14 | pomodoros: [], 15 | durations: [], 16 | timezones: [] 17 | }; 18 | 19 | var { count, merged } = History.merge(empty, empty); 20 | assert.equal(count, 0); 21 | assert.deepEqual(merged, empty); 22 | 23 | var { count, merged } = History.merge(basic, empty); 24 | assert.equal(count, 0); 25 | assert.deepEqual(merged, basic); 26 | 27 | var { count, merged } = History.merge(empty, basic); 28 | assert.equal(count, 1); 29 | assert.deepEqual(merged, basic); 30 | }); 31 | 32 | it('does not merge duplicate Pomodoros', () => { 33 | const history = { 34 | pomodoros: [1, 2, 3, 4, 5], 35 | durations: [25, 25, 25, 25, 25], 36 | timezones: [5, 5, 5, 5, 5] 37 | }; 38 | 39 | const { count, merged } = History.merge(history, history); 40 | assert.equal(count, 0); 41 | assert.deepEqual(merged, history); 42 | }); 43 | 44 | it('merges Pomodoros', () => { 45 | const left = { 46 | pomodoros: [1, 3, 5, 7], 47 | durations: [10, 11, 12, 13], 48 | timezones: [60, 120, 180, 240] 49 | }; 50 | 51 | const right = { 52 | pomodoros: [0, 2, 4, 6, 7], 53 | durations: [20, 21, 22, 23, 13], 54 | timezones: [300, 360, 420, 480, 240] 55 | }; 56 | 57 | const expected = { 58 | pomodoros: [0, 1, 2, 3, 4, 5, 6, 7], 59 | durations: [20, 10, 21, 11, 22, 12, 23, 13], 60 | timezones: [300, 60, 360, 120, 420, 180, 480, 240] 61 | }; 62 | 63 | const { count, merged } = History.merge(left, right); 64 | assert.equal(count, 4); 65 | assert.deepEqual(merged, expected); 66 | }); 67 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Marinara: Pomodoro® Assistant 2 | 3 | Marinara is a [time management assistant for Chrome](https://chrome.google.com/webstore/detail/marinara/lojgmehidjdhhbmpjfamhpkpodfcodef) that follows the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique). 4 | 5 | Pomodoro® and The Pomodoro Technique® are trademarks of Francesco Cirillo. Marinara is not affiliated or associated with or endorsed by Pomodoro®, The Pomodoro Technique® or Francesco Cirillo. 6 | 7 | ## Features 8 | 9 | - Short & long breaks 10 | - Toolbar icon with countdown timer 11 | - Track Pomodoro history & stats 12 | - Configurable long break intervals 13 | - Configurable timer durations 14 | - Desktop & tab notifications 15 | - Audio notifications with over 20 sounds 16 | - Ticking timer sounds 17 | - Scheduled automatic timers 18 | - Open source software 19 | 20 | ## Screenshots 21 | 22 | ![](assets/screenshots/focus.png) 23 | ![](assets/screenshots/stats-1.png) 24 | ![](assets/screenshots/stats-2.png) 25 | ![](assets/screenshots/settings.png) 26 | ![](assets/screenshots/menu.png) 27 | ![](assets/screenshots/notification.png) 28 | ![](assets/screenshots/break.png) 29 | 30 | ## Developer Setup 31 | 32 | Currently, Marinara is configured for developers working and packaging releases on Mac OS. Support for Linux or Windows is welcome. 33 | 34 | Marinara uses the system `ruby` and `make` tools to build releases. 35 | 36 | It also uses [jq](https://stedolan.github.io/jq/) to manipulate the `manifest.json`. You can quickly install it using [Homebrew](https://brew.sh/): 37 | 38 | brew install jq 39 | 40 | Once installed you can package a release by running: 41 | 42 | make package 43 | 44 | This will produce a packaged extension ready for uploading to the Chrome Web Store in the root directory of the project. 45 | 46 | ## License 47 | 48 | Pomodoro® and The Pomodoro Technique® are trademarks of Francesco Cirillo. Marinara is not affiliated or associated with or endorsed by Pomodoro®, The Pomodoro Technique® or Francesco Cirillo. 49 | 50 | Copyright © 2015 Chris Schmich \ 51 | MIT License. See [LICENSE](LICENSE) for details. 52 | -------------------------------------------------------------------------------- /src/background/Notification.js: -------------------------------------------------------------------------------- 1 | import Chrome from '../Chrome'; 2 | import Mutex from '../Mutex'; 3 | 4 | class Notification 5 | { 6 | constructor(title, message, onClick = null) { 7 | this.title = title; 8 | this.message = message; 9 | this.buttons = []; 10 | this.notificationId = null; 11 | this.onClick = onClick; 12 | } 13 | 14 | addButton(title, onClick) { 15 | this.buttons.push({ title, onClick }); 16 | } 17 | 18 | async show() { 19 | if (this.notificationId != null) { 20 | return; 21 | } 22 | 23 | let options = { 24 | type: 'basic', 25 | title: this.title, 26 | message: this.message, 27 | iconUrl: 'images/128.png', 28 | isClickable: !!this.action, 29 | requireInteraction: true, 30 | buttons: this.buttons.map(b => { 31 | return { 32 | title: b.title, 33 | iconUrl: 'images/start.png' 34 | }; 35 | }) 36 | }; 37 | 38 | this.notificationId = await Chrome.notifications.create(options); 39 | 40 | let notificationClicked = notificationId => { 41 | if (notificationId !== this.notificationId) { 42 | return; 43 | } 44 | this.onClick && this.onClick(); 45 | chrome.notifications.clear(notificationId); 46 | }; 47 | 48 | let buttonClicked = (notificationId, buttonIndex) => { 49 | if (notificationId !== this.notificationId) { 50 | return; 51 | } 52 | this.buttons[buttonIndex].onClick(); 53 | chrome.notifications.clear(notificationId); 54 | }; 55 | 56 | let notificationClosed = notificationId => { 57 | if (notificationId !== this.notificationId) { 58 | return; 59 | } 60 | chrome.notifications.onClicked.removeListener(notificationClicked); 61 | chrome.notifications.onButtonClicked.removeListener(buttonClicked); 62 | chrome.notifications.onClosed.removeListener(notificationClosed); 63 | this.notificationId = null; 64 | }; 65 | 66 | chrome.notifications.onClicked.addListener(notificationClicked); 67 | chrome.notifications.onButtonClicked.addListener(buttonClicked); 68 | chrome.notifications.onClosed.addListener(notificationClosed); 69 | } 70 | 71 | close() { 72 | if (this.notificationId != null) { 73 | chrome.notifications.clear(this.notificationId); 74 | } 75 | } 76 | } 77 | 78 | export default Notification; -------------------------------------------------------------------------------- /src/options/File.js: -------------------------------------------------------------------------------- 1 | function save(filename, data) { 2 | let link = document.createElement('a'); 3 | link.style = 'display: none; width: 0; height: 0;'; 4 | link.download = filename; 5 | link.href = `data:application/octet-stream,${encodeURIComponent(data)}`; 6 | document.body.appendChild(link); 7 | link.click(); 8 | document.body.removeChild(link); 9 | } 10 | 11 | async function readText(acceptFileType) { 12 | let input = document.createElement('input'); 13 | input.type = 'file'; 14 | input.accept = acceptFileType; 15 | input.style = 'display: none; width: 0; height: 0'; 16 | 17 | // See note below about file input cancellation. 18 | let cancelTimeout = null; 19 | let onBodyFocusIn = null; 20 | 21 | try { 22 | return await new Promise((resolve, reject) => { 23 | input.onchange = e => { 24 | clearTimeout(cancelTimeout); 25 | 26 | let file = e.target.files[0]; 27 | let reader = new FileReader(); 28 | 29 | reader.onload = async f => { 30 | let content = f.target.result; 31 | resolve(content); 32 | }; 33 | 34 | reader.readAsText(file); 35 | }; 36 | 37 | input.onabort = () => resolve(null); 38 | input.onclose = () => resolve(null); 39 | input.oncancel = () => resolve(null); 40 | input.onerror = e => reject(e); 41 | 42 | // File input cancellation is not defined in the HTML5 spec, so cancellation 43 | // events are not directly surfaced through the element. As a workaround, we listen 44 | // to the body focusin event to determine when the open file dialog is closed, 45 | // then wait 5 seconds, and finally, we see if the file input element has any selected 46 | // files. If no files are selected, we assume the dialog was canceled. 47 | onBodyFocusIn = () => { 48 | if (!cancelTimeout) { 49 | cancelTimeout = setTimeout(() => { 50 | if (input.value.length == 0) { 51 | resolve(null); 52 | } 53 | }, 5 * 1000); 54 | } 55 | }; 56 | 57 | document.body.addEventListener('focusin', onBodyFocusIn); 58 | document.body.appendChild(input); 59 | input.click(); 60 | }); 61 | } finally { 62 | document.body.removeChild(input); 63 | document.body.removeEventListener('focusin', onBodyFocusIn); 64 | } 65 | } 66 | 67 | export { 68 | save, 69 | readText 70 | }; -------------------------------------------------------------------------------- /src/background/main.js: -------------------------------------------------------------------------------- 1 | import { PomodoroTimer } from './Timer'; 2 | import Chrome from '../Chrome'; 3 | import { createPomodoroMenu } from './Menu'; 4 | import { History } from './History'; 5 | import StorageManager from './StorageManager'; 6 | import { SettingsSchema, PersistentSettings } from './Settings'; 7 | import { HistoryService, SoundsService, SettingsService, PomodoroService, OptionsService } from './Services'; 8 | import { BadgeObserver, TimerSoundObserver, ExpirationSoundObserver, NotificationObserver, HistoryObserver, CountdownObserver, MenuObserver } from './Observers'; 9 | import { ServiceBroker } from '../Service'; 10 | import * as Alarms from './Alarms'; 11 | 12 | async function run() { 13 | chrome.runtime.onUpdateAvailable.addListener(() => { 14 | // We must listen to (but do nothing with) the onUpdateAvailable event in order to 15 | // defer updating the extension until the next time Chrome is restarted. We do not want 16 | // the extension to automatically reload on update since a Pomodoro might be running. 17 | // See https://developer.chrome.com/apps/runtime#event-onUpdateAvailable. 18 | }); 19 | 20 | let settingsManager = new StorageManager(new SettingsSchema(), Chrome.storage.sync); 21 | let settings = await PersistentSettings.create(settingsManager); 22 | let timer = new PomodoroTimer(settings); 23 | let history = new History(); 24 | 25 | let menu = createPomodoroMenu(timer); 26 | timer.observe(new HistoryObserver(history)); 27 | timer.observe(new BadgeObserver()); 28 | timer.observe(new NotificationObserver(timer, settings, history)); 29 | timer.observe(new ExpirationSoundObserver(settings)); 30 | timer.observe(new TimerSoundObserver(settings)); 31 | timer.observe(new CountdownObserver(settings)); 32 | timer.observe(new MenuObserver(menu)); 33 | 34 | menu.apply(); 35 | settingsManager.on('change', () => menu.apply()); 36 | 37 | Alarms.install(timer, settingsManager); 38 | chrome.browserAction.onClicked.addListener(() => { 39 | if (timer.isRunning) { 40 | timer.pause(); 41 | } else if (timer.isPaused) { 42 | timer.resume(); 43 | } else { 44 | timer.start(); 45 | } 46 | }); 47 | 48 | ServiceBroker.register(new HistoryService(history)); 49 | ServiceBroker.register(new SoundsService()); 50 | ServiceBroker.register(new SettingsService(settingsManager)); 51 | ServiceBroker.register(new PomodoroService(timer)); 52 | ServiceBroker.register(new OptionsService()); 53 | } 54 | 55 | run(); -------------------------------------------------------------------------------- /scripts/validate-messages.rb: -------------------------------------------------------------------------------- 1 | require 'locales' 2 | require 'json' 3 | require 'set' 4 | 5 | class ValidationError < StandardError 6 | def initialize(message, file, message_id = nil) 7 | super(message) 8 | @file = file 9 | @message_id = message_id 10 | end 11 | 12 | attr_reader :file, :message_id 13 | end 14 | 15 | def validate(id, file, en_message_names) 16 | content = File.read(file) 17 | begin 18 | messages = JSON.parse(content) 19 | rescue => e 20 | raise ValidationError.new("Invalid JSON: #{e}", file) 21 | end 22 | 23 | messages.each do |id, obj| 24 | message = obj['message'] 25 | 26 | referenced = Set.new(message.scan(/\$.*?\$/)) 27 | defined = Set.new(obj['placeholders']&.map(&:first)&.map { |name| "$#{name}$" }) 28 | 29 | undefined = referenced - defined 30 | unless undefined.empty? 31 | error = "Placeholder referenced but not defined: #{undefined.to_a.join(', ')}" 32 | raise ValidationError.new(error, file, id) 33 | end 34 | 35 | unreferenced = defined - referenced 36 | unless unreferenced.empty? 37 | error = "Placeholder defined but not referenced: #{unreferenced.to_a.join(', ')}" 38 | raise ValidationError.new(error, file, id) 39 | end 40 | 41 | contents = obj['placeholders']&.map(&:last)&.map { |p| p['content'] } || [] 42 | if contents.length != contents.uniq.length 43 | error = "Placeholders use the same positions: #{contents}." 44 | raise ValidationError.new(error, file, id) 45 | end 46 | end 47 | 48 | invalid_names = messages.keys.uniq - en_message_names 49 | if invalid_names.any? 50 | error = "Messages not defined in en locale:\n" + invalid_names.map { |n| "\t\t#{n}" }.join("\n") 51 | raise ValidationError.new(error, file) 52 | end 53 | 54 | names = content.scan(/^ \"(.*?)\":/m).flatten 55 | 56 | unique = Set.new 57 | duplicates = Set.new 58 | names.each do |name| 59 | if !unique.add?(name) 60 | duplicates.add(name) 61 | end 62 | end 63 | 64 | if duplicates.any? 65 | error = "Found duplicate message names:\n" + duplicates.to_a.map { |n| "\t\t#{n}" }.join("\n") 66 | raise ValidationError.new(error, file) 67 | end 68 | end 69 | 70 | begin 71 | locales = all_locales 72 | if locales.empty? 73 | raise 'No locales found.' 74 | end 75 | 76 | en_messages = JSON.parse(File.read(locales['en'])) 77 | en_message_names = en_messages.keys 78 | 79 | locales.each do |id, file| 80 | validate(id, file, en_message_names) 81 | end 82 | 83 | puts 'OK.' 84 | rescue ValidationError => e 85 | location = [e.file, e.message_id].compact.join(', ') 86 | puts "Error in #{location}:\n\t#{e}" 87 | exit 1 88 | end 89 | -------------------------------------------------------------------------------- /src/Noise.js: -------------------------------------------------------------------------------- 1 | class Noise 2 | { 3 | static async create(createNode) { 4 | let context = new AudioContext(); 5 | await context.suspend(); 6 | 7 | let node = createNode(context); 8 | node.connect(context.destination); 9 | return new Noise(context); 10 | } 11 | 12 | constructor(context) { 13 | this.context = context; 14 | } 15 | 16 | async start() { 17 | await this.context.resume(); 18 | } 19 | 20 | async stop() { 21 | await this.context.suspend(); 22 | } 23 | 24 | async close() { 25 | if (!this.context) { 26 | return; 27 | } 28 | 29 | await this.stop(); 30 | await this.context.close(); 31 | this.context = null; 32 | } 33 | } 34 | 35 | // Noise generation adapted from Zach Denton's noise.js. 36 | // https://github.com/zacharydenton/noise.js 37 | // https://noisehack.com/generate-noise-web-audio-api/ 38 | 39 | function whiteNoise(context) { 40 | const bufferSize = 4096; 41 | 42 | let node = context.createScriptProcessor(bufferSize, 1, 1); 43 | node.onaudioprocess = e => { 44 | let output = e.outputBuffer.getChannelData(0); 45 | for (let i = 0; i < bufferSize; i++) { 46 | output[i] = Math.random() * 2 - 1; 47 | output[i] *= 0.01; 48 | } 49 | }; 50 | 51 | return node; 52 | } 53 | 54 | function pinkNoise(context) { 55 | const bufferSize = 4096; 56 | let b0, b1, b2, b3, b4, b5, b6; 57 | b0 = b1 = b2 = b3 = b4 = b5 = b6 = 0.0; 58 | 59 | let node = context.createScriptProcessor(bufferSize, 1, 1); 60 | node.onaudioprocess = e => { 61 | let output = e.outputBuffer.getChannelData(0); 62 | for (let i = 0; i < bufferSize; i++) { 63 | let white = Math.random() * 2 - 1; 64 | b0 = 0.99886 * b0 + white * 0.0555179; 65 | b1 = 0.99332 * b1 + white * 0.0750759; 66 | b2 = 0.96900 * b2 + white * 0.1538520; 67 | b3 = 0.86650 * b3 + white * 0.3104856; 68 | b4 = 0.55000 * b4 + white * 0.5329522; 69 | b5 = -0.7616 * b5 - white * 0.0168980; 70 | output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; 71 | output[i] *= 0.005; 72 | b6 = white * 0.115926; 73 | } 74 | }; 75 | 76 | return node; 77 | } 78 | 79 | function brownNoise(context) { 80 | const bufferSize = 4096; 81 | let lastOut = 0.0; 82 | 83 | let node = context.createScriptProcessor(bufferSize, 1, 1); 84 | node.onaudioprocess = e => { 85 | let output = e.outputBuffer.getChannelData(0); 86 | for (let i = 0; i < bufferSize; i++) { 87 | let white = Math.random() * 2 - 1; 88 | output[i] = (lastOut + (0.02 * white)) / 1.02; 89 | lastOut = output[i]; 90 | output[i] *= 0.2; 91 | } 92 | } 93 | 94 | return node; 95 | } 96 | 97 | export { 98 | Noise, 99 | whiteNoise, 100 | pinkNoise, 101 | brownNoise 102 | }; -------------------------------------------------------------------------------- /src/background/SingletonPage.js: -------------------------------------------------------------------------------- 1 | import Chrome from '../Chrome'; 2 | import Enum from './Enum'; 3 | 4 | const PageHost = new Enum({ 5 | Tab: 0, 6 | Window: 1 7 | }); 8 | 9 | function canonical(url) { 10 | // The canonical page URL is the combined origin, path, and sorted query, 11 | // lowercased, excluding hashes and trailing slashes. 12 | // This will only reliably work with internal extension pages. 13 | url.searchParams.sort(); 14 | return `${url.origin}${url.pathname.replace(/\/$/, '')}${url.searchParams}`.toLowerCase(); 15 | } 16 | 17 | class SingletonPage 18 | { 19 | static async show(url, host, properties = {}) { 20 | // Search existing extension pages to see if page is already open. 21 | let targetUrl = new URL(url); 22 | let targetCanonical = canonical(targetUrl); 23 | 24 | let windows = chrome.extension.getViews(); 25 | for (let window of windows) { 26 | if (canonical(new URL(window.location.href)) !== targetCanonical) { 27 | continue; 28 | } 29 | 30 | // We found a matching page. Update its hash and return it. 31 | let tabId = await new Promise(resolve => window.chrome.tabs.getCurrent(tab => resolve(tab.id))); 32 | window.location.hash = targetUrl.hash; 33 | return new SingletonPage(tabId); 34 | } 35 | 36 | // Page does not exist, so create it. 37 | if (host === PageHost.Tab) { 38 | let tab = await Chrome.tabs.create({ url, active: false, ...properties }); 39 | return new SingletonPage(tab.id); 40 | } else if (host === PageHost.Window) { 41 | let window = await Chrome.windows.create({ url, type: 'popup', ...properties }); 42 | return new SingletonPage(window.tabs[0].id); 43 | } else { 44 | throw new Error('Invalid page host.'); 45 | } 46 | } 47 | 48 | constructor(tabId) { 49 | this.tabId = tabId; 50 | 51 | const self = this; 52 | chrome.tabs.onRemoved.addListener(function removed(id) { 53 | if (id === self.tabId) { 54 | chrome.tabs.onRemoved.removeListener(removed); 55 | self.tabId = null; 56 | } 57 | }); 58 | } 59 | 60 | focus() { 61 | if (!this.tabId) { 62 | return; 63 | } 64 | 65 | const focusWindow = tab => chrome.windows.update(tab.windowId, { focused: true }); 66 | const focusTab = id => { 67 | try { 68 | chrome.tabs.update(id, { active: true, highlighted: true }, focusWindow); 69 | } catch (e) { 70 | // Firefox doesn't currently allow setting highlighted for chrome.tabs.update() 71 | // TODO: File a FF bug for this 72 | chrome.tabs.update(id, { active: true }, focusWindow); 73 | } 74 | }; 75 | 76 | focusTab(this.tabId); 77 | } 78 | 79 | close() { 80 | if (!this.tabId) { 81 | return; 82 | } 83 | 84 | chrome.tabs.remove(this.tabId, () => {}); 85 | } 86 | } 87 | 88 | export { 89 | PageHost, 90 | SingletonPage 91 | }; -------------------------------------------------------------------------------- /src/options/WeekDistribution.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const FileManagerPlugin = require('filemanager-webpack-plugin'); 2 | const WebpackTouch = require('webpack-touch'); 3 | 4 | module.exports = { 5 | configureWebpack: { 6 | node: false, 7 | // Disable eval. Required for Chrome extension CSP. 8 | // See https://github.com/webpack/webpack/issues/5627#issuecomment-374386048. 9 | devtool: 'inline-source-map', 10 | // Override vue-cli's file naming to keep consistent naming 11 | // between development and production builds. 12 | output: { 13 | filename: '[name].js', 14 | chunkFilename: '[name].js' 15 | }, 16 | plugins: [ 17 | new FileManagerPlugin({ 18 | onStart: { 19 | // Ensure package/modules exists so WebpackTouch below works. 20 | mkdir: ['package/modules'] 21 | }, 22 | onEnd: { 23 | // Delete background.html after build since it is not used. 24 | delete: ['package/modules/background.html'] 25 | } 26 | }), 27 | // Touch chunks after build to ensure they exist. This is necessary in development 28 | // since we don't build them, but we still refer to them (see manifest.json). 29 | new WebpackTouch({ filename: 'package/modules/chunk-vendors.js' }), 30 | new WebpackTouch({ filename: 'package/modules/chunk-common.js' }) 31 | ] 32 | }, 33 | // Leave CSS embedded in JS modules instead of 34 | // extracting it into dedicated .css files. 35 | css: { 36 | extract: false 37 | }, 38 | publicPath: '/modules', 39 | // Save on production package size by excluding source maps. 40 | productionSourceMap: false, 41 | // Exclude content hashes from filenames since we do 42 | // not require them for versioning. 43 | filenameHashing: false, 44 | // Cannot use runtime compiler due to Chrome extension CSP. 45 | // See https://cli.vuejs.org/config/#runtimecompiler. 46 | runtimeCompiler: false, 47 | pages: { 48 | options: { 49 | entry: 'src/options/main.js', 50 | template: 'src/options/options.html', 51 | filename: 'options.html', 52 | chunks: ['chunk-vendors', 'chunk-common', 'options'] 53 | }, 54 | expire: { 55 | entry: 'src/expire/main.js', 56 | template: 'src/expire/expire.html', 57 | filename: 'expire.html', 58 | chunks: ['chunk-vendors', 'chunk-common', 'expire'] 59 | }, 60 | countdown: { 61 | entry: 'src/countdown/main.js', 62 | template: 'src/countdown/countdown.html', 63 | filename: 'countdown.html', 64 | chunks: ['chunk-vendors', 'chunk-common', 'countdown'] 65 | }, 66 | background: 'src/background/main.js' 67 | }, 68 | chainWebpack: config => { 69 | // Preserve HTML whitespace in vue templates. 70 | // See https://github.com/vuejs/vue-cli/issues/1020. 71 | config.module 72 | .rule('vue') 73 | .use('vue-loader') 74 | .loader('vue-loader') 75 | .tap(options => { 76 | options.compilerOptions.preserveWhitespace = true; 77 | return options; 78 | }); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/Metronome.js: -------------------------------------------------------------------------------- 1 | import Chrome from './Chrome'; 2 | import Mutex from './Mutex'; 3 | 4 | function loadAudio(context, file) { 5 | return new Promise(async (resolve, reject) => { 6 | let content = await Chrome.files.readBinary(file); 7 | context.decodeAudioData(content, buffer => resolve(buffer), error => reject(error)); 8 | }); 9 | } 10 | 11 | function roundUp(value, interval) { 12 | return interval * Math.ceil(value / interval); 13 | } 14 | 15 | class Metronome 16 | { 17 | constructor(context, buffers, period) { 18 | this.buffers = buffers; 19 | this.period = period; 20 | this.context = context; 21 | this.interval = null; 22 | 23 | this.scheduledTime = 0; 24 | this.soundIndex = 0; 25 | 26 | this.contextLock = new Mutex(); 27 | } 28 | 29 | static async create(soundFiles, period) { 30 | let context = new AudioContext(); 31 | await context.suspend(); 32 | 33 | let buffers = []; 34 | for (let file of soundFiles) { 35 | let buffer = await loadAudio(context, file); 36 | buffers.push(buffer); 37 | } 38 | 39 | return new Metronome(context, buffers, period); 40 | } 41 | 42 | async start() { 43 | await this.contextLock.exclusive(async () => { 44 | if (!this.context || this.interval) { 45 | return; 46 | } 47 | 48 | const schedulePeriod = 15 * 1000; 49 | const scheduleSize = Math.max(2, 2 * (schedulePeriod / this.period)); 50 | 51 | const schedule = () => { 52 | let frontierTime = roundUp(this.context.currentTime * 1000, this.period) + this.period * scheduleSize; 53 | while (this.scheduledTime < frontierTime) { 54 | this.scheduledTime += this.period; 55 | if (this.scheduledTime <= this.context.currentTime * 1000) { 56 | continue; 57 | } 58 | 59 | let source = this.context.createBufferSource(); 60 | source.buffer = this.buffers[this.soundIndex]; 61 | this.soundIndex = (this.soundIndex + 1) % this.buffers.length; 62 | 63 | source.connect(this.context.destination); 64 | source.start(this.scheduledTime / 1000); 65 | } 66 | }; 67 | 68 | this.interval = setInterval(() => schedule(), schedulePeriod); 69 | schedule(); 70 | 71 | await this.context.resume(); 72 | }); 73 | } 74 | 75 | async stop() { 76 | await this.contextLock.exclusive(async () => { 77 | await this._stop(); 78 | }); 79 | } 80 | 81 | async close() { 82 | await this.contextLock.exclusive(async () => { 83 | if (!this.context) { 84 | return; 85 | } 86 | 87 | await this._stop(); 88 | await this.context.close(); 89 | this.context = null; 90 | }); 91 | } 92 | 93 | // Assumes contextLock is held. 94 | async _stop() { 95 | if (!this.context || !this.interval) { 96 | return; 97 | } 98 | 99 | clearInterval(this.interval); 100 | this.interval = null; 101 | await this.context.suspend(); 102 | } 103 | } 104 | 105 | export default Metronome; -------------------------------------------------------------------------------- /src/countdown/TimerStats.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/package-manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | "ATTRIBUTION", 3 | "audio/0a0ec499.mp3", 4 | "audio/0f034826.mp3", 5 | "audio/1a5066bd.mp3", 6 | "audio/2122d2a4.mp3", 7 | "audio/28d6b5be.mp3", 8 | "audio/2e13802a.mp3", 9 | "audio/2ed9509e.mp3", 10 | "audio/36e93c27.mp3", 11 | "audio/4cf03078.mp3", 12 | "audio/54b867f9.mp3", 13 | "audio/5cf807ce.mp3", 14 | "audio/5e122cee.mp3", 15 | "audio/6103cd58.mp3", 16 | "audio/6a215611.mp3", 17 | "audio/6a981bfc.mp3", 18 | "audio/72312dd3.mp3", 19 | "audio/72cb1b7f.mp3", 20 | "audio/831a5549.mp3", 21 | "audio/85cab25d.mp3", 22 | "audio/875326f9.mp3", 23 | "audio/88736c22.mp3", 24 | "audio/89dafd3e.mp3", 25 | "audio/8bce59b5.mp3", 26 | "audio/8dc834f8.mp3", 27 | "audio/92ff2a8a.mp3", 28 | "audio/9404f598.mp3", 29 | "audio/9bd67f7e.mp3", 30 | "audio/a258e906.mp3", 31 | "audio/a273ba0c.mp3", 32 | "audio/ad6eac9e.mp3", 33 | "audio/af607ff1.mp3", 34 | "audio/b10d75f2.mp3", 35 | "audio/b38e515f.mp3", 36 | "audio/bc4e3db2.mp3", 37 | "audio/bced7c21.mp3", 38 | "audio/bd50add0.mp3", 39 | "audio/be75f155.mp3", 40 | "audio/cad167ea.mp3", 41 | "audio/cba5f173.mp3", 42 | "audio/ebe7deb8.mp3", 43 | "audio/edab7b0d.mp3", 44 | "audio/f62b45bc.mp3", 45 | "audio/f9efd11b.mp3", 46 | "audio/fd23aaf3.mp3", 47 | "audio/fd64de98.mp3", 48 | "audio/fe5d2a62.mp3", 49 | "audio/fee369b7.mp3", 50 | "fonts/SourceSansPro-Regular.ttf", 51 | "fonts/SourceSansPro-SemiBold.ttf", 52 | "images/128.png", 53 | "images/16.png", 54 | "images/48.png", 55 | "images/browser-action.png", 56 | "images/check.svg", 57 | "images/full.png", 58 | "images/history.svg", 59 | "images/pause.svg", 60 | "images/play.svg", 61 | "images/restart.svg", 62 | "images/settings.svg", 63 | "images/spinner.svg", 64 | "images/start.png", 65 | "LICENSE", 66 | "manifest.json", 67 | "modules/background.js", 68 | "modules/chunk-common.js", 69 | "modules/chunk-vendors.js", 70 | "modules/countdown.html", 71 | "modules/countdown.js", 72 | "modules/expire.html", 73 | "modules/expire.js", 74 | "modules/options.html", 75 | "modules/options.js", 76 | "_locales/ar/messages.json", 77 | "_locales/bn/messages.json", 78 | "_locales/ca/messages.json", 79 | "_locales/cs/messages.json", 80 | "_locales/da/messages.json", 81 | "_locales/de/messages.json", 82 | "_locales/el/messages.json", 83 | "_locales/en/messages.json", 84 | "_locales/en_GB/messages.json", 85 | "_locales/es/messages.json", 86 | "_locales/fa/messages.json", 87 | "_locales/fi/messages.json", 88 | "_locales/fr/messages.json", 89 | "_locales/he/messages.json", 90 | "_locales/id/messages.json", 91 | "_locales/it/messages.json", 92 | "_locales/ja/messages.json", 93 | "_locales/lt/messages.json", 94 | "_locales/nl/messages.json", 95 | "_locales/no/messages.json", 96 | "_locales/pl/messages.json", 97 | "_locales/pt_BR/messages.json", 98 | "_locales/pt_PT/messages.json", 99 | "_locales/ro/messages.json", 100 | "_locales/ru/messages.json", 101 | "_locales/sr/messages.json", 102 | "_locales/sv/messages.json", 103 | "_locales/te/messages.json", 104 | "_locales/th/messages.json", 105 | "_locales/tr/messages.json", 106 | "_locales/uk/messages.json", 107 | "_locales/vi/messages.json", 108 | "_locales/zh_CN/messages.json", 109 | "_locales/zh_TW/messages.json" 110 | ] 111 | -------------------------------------------------------------------------------- /src/options/Feedback.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 114 | 115 | -------------------------------------------------------------------------------- /src/options/Options.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /src/Sounds.js: -------------------------------------------------------------------------------- 1 | import Chrome from './Chrome'; 2 | import M from './Messages'; 3 | 4 | function createNotificationSounds() { 5 | let sounds = [ 6 | { name: M.tone, file: 'f62b45bc.mp3' }, 7 | { name: M.digital_watch, file: 'be75f155.mp3' }, 8 | { name: M.analog_alarm_clock, file: '0f034826.mp3' }, 9 | { name: M.digital_alarm_clock, file: 'fee369b7.mp3' }, 10 | { name: M.electronic_chime, file: '28d6b5be.mp3' }, 11 | { name: M.gong_1, file: '8bce59b5.mp3' }, 12 | { name: M.gong_2, file: '85cab25d.mp3' }, 13 | { name: M.computer_magic, file: '5cf807ce.mp3' }, 14 | { name: M.fire_pager, file: 'b38e515f.mp3' }, 15 | { name: M.glass_ping, file: '2ed9509e.mp3' }, 16 | { name: M.music_box, file: 'ebe7deb8.mp3' }, 17 | { name: M.pin_drop, file: '2e13802a.mp3' }, 18 | { name: M.robot_blip_1, file: 'bd50add0.mp3' }, 19 | { name: M.robot_blip_2, file: '36e93c27.mp3' }, 20 | { name: M.ship_bell, file: '9404f598.mp3' }, 21 | { name: M.train_horn, file: '6a215611.mp3' }, 22 | { name: M.bike_horn, file: '72312dd3.mp3' }, 23 | { name: M.bell_ring, file: 'b10d75f2.mp3' }, 24 | { name: M.reception_bell, file: '54b867f9.mp3' }, 25 | { name: M.toaster_oven, file: 'a258e906.mp3' }, 26 | { name: M.battle_horn, file: '88736c22.mp3' }, 27 | { name: M.ding, file: '1a5066bd.mp3' }, 28 | { name: M.dong, file: '5e122cee.mp3' }, 29 | { name: M.ding_dong, file: '92ff2a8a.mp3' }, 30 | { name: M.airplane, file: '72cb1b7f.mp3' } 31 | ]; 32 | 33 | for (let sound of sounds) { 34 | sound.file = `/audio/${sound.file}`; 35 | } 36 | 37 | return sounds; 38 | } 39 | 40 | function createTimerSounds() { 41 | let sounds = [ 42 | { name: M.stopwatch, files: ['4cf03078.mp3', 'edab7b0d.mp3'] }, 43 | { name: M.wristwatch, files: ['8dc834f8.mp3', '831a5549.mp3'] }, 44 | { name: M.clock, files: ['af607ff1.mp3', 'fd23aaf3.mp3'] }, 45 | { name: M.wall_clock, files: ['6103cd58.mp3', 'cad167ea.mp3'] }, 46 | { name: M.desk_clock, files: ['6a981bfc.mp3', 'fd64de98.mp3'] }, 47 | { name: M.wind_up_clock, files: ['bc4e3db2.mp3', 'f9efd11b.mp3'] }, 48 | { name: M.antique_clock, files: ['875326f9.mp3', 'cba5f173.mp3'] }, 49 | { name: M.small_clock, files: ['89dafd3e.mp3', '0a0ec499.mp3'] }, 50 | { name: M.large_clock, files: ['2122d2a4.mp3', 'a273ba0c.mp3'] }, 51 | { name: M.wood_block, files: ['ad6eac9e.mp3'] }, 52 | { name: M.metronome, files: ['bced7c21.mp3', '9bd67f7e.mp3'] }, 53 | { name: M.pulse, files: ['fe5d2a62.mp3'] } 54 | ]; 55 | 56 | for (let sound of sounds) { 57 | sound.files = sound.files.map(file => `/audio/${file}`); 58 | } 59 | 60 | return sounds; 61 | } 62 | 63 | async function play(filename) { 64 | if (!filename) { 65 | return; 66 | } 67 | 68 | // We use AudioContext instead of Audio since it works more 69 | // reliably in different browsers (Chrome, FF, Brave). 70 | let context = new AudioContext(); 71 | 72 | let source = context.createBufferSource(); 73 | source.connect(context.destination); 74 | source.buffer = await new Promise(async (resolve, reject) => { 75 | let content = await Chrome.files.readBinary(filename); 76 | context.decodeAudioData(content, buffer => resolve(buffer), error => reject(error)); 77 | }); 78 | 79 | await new Promise(resolve => { 80 | // Cleanup audio context after sound plays. 81 | source.onended = () => { 82 | context.close(); 83 | resolve(); 84 | } 85 | source.start(); 86 | }); 87 | } 88 | 89 | const notification = createNotificationSounds(); 90 | const timer = createTimerSounds(); 91 | 92 | export { 93 | notification, 94 | timer, 95 | play 96 | }; -------------------------------------------------------------------------------- /src/options/CountdownSettings.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Marinara: Contributors 2 | 3 | Marinara is open source software and is built by volunteers from around the world. 4 | 5 | - [Chris Schmich](https://github.com/schmich): Lead development & design 6 | - [Dmitry Muravyev](https://github.com/dimuravyev): Notification enhancements 7 | - [Mark Bennett](https://github.com/MarkBennett): Firefox compatibility 8 | - [José Varela](https://github.com/joselcvarela): Portuguese (Portugal) translation 9 | - [Omar Francisco](https://github.com/ofou): Spanish translation 10 | - [Maite Clausell](https://github.com/mcmtradu): Spanish translation 11 | - [Mathieu Lescaudron](https://github.com/MLescaudron): French translation 12 | - [Nicolai Hoilund](https://github.com/nicolaihoilund): Danish translation 13 | - [Jeroen Deviaene](https://github.com/jerodev): Dutch translation 14 | - [Robin Haveneers](https://github.com/haveneersrobin): Dutch translation 15 | - [Alex Olson](https://github.com/alexkolson): German translation 16 | - [William Killerud](https://github.com/wkillerud): Norwegian Bokmål translation 17 | - [Lambis Elef](https://github.com/lambiselef): Greek translation 18 | - [Ashraful Haq Rahat](https://github.com/MAHRahat): Bengali translation 19 | - [XTacDK](https://github.com/XTacDK): Polish translation 20 | - [lena15n](https://github.com/lena15n): Russian translation 21 | - [Po Chun, Lu](https://github.com/Sirius207): Chinese (Taiwan) translation 22 | - [Omega Chiu](https://github.com/omeganc): Chinese (Taiwan) translation 23 | - [Kietzmann](https://github.com/Kietzmann): Ukrainian translation 24 | - [Shashank SVRSN](https://github.com/fossterer): Telugu translation 25 | - [frozzie](https://github.com/frozzie): Chinese (China) translation 26 | - [myTimeGone](https://github.com/myTimeGone): Chinese (China) translation 27 | - [Julianna Brandão](https://github.com/JuhBass): Portuguese (Brazil) translation 28 | - [Teerapong Chantakard](https://github.com/azygous13): Thai translation 29 | - [Sarp Başaraner](https://github.com/sgbasaraner): Turkish translation 30 | - [L1Q](https://github.com/L1Q): Russian translation 31 | - [Maite Clausell](https://github.com/mcmtradu): Catalan translation 32 | - [Craig Loftus](https://github.com/craigloftus): Autostart timers 33 | - [Mathias Mikkelsen](https://github.com/Fysikeren): Danish translation 34 | - [Giovanni Pessiva](https://github.com/giovannipessiva): Italian translation 35 | - [TomG777](https://github.com/TomG777): Dutch translation 36 | - [João Pedro Sconetto](https://github.com/sconetto): Portuguese (Brazil) translation 37 | - [Ridho Pratama](https://github.com/ridho9): Indonesian translation 38 | - [Ivan Nesic](https://github.com/fatkaratekid): Serbian translation 39 | - [Duc Trinh](https://github.com/dmtri): Vietnamese translation 40 | - [Markus Deibel](https://github.com/msdeibel): German translation 41 | - [Pyrox](https://github.com/Pyr0x1): Italian translation 42 | - [kbigwheel](https://github.com/bigwheel): Japanese translation 43 | - [André Laszlo](https://github.com/andrelaszlo): Swedish translation 44 | - [Jaroslav Svoboda](https://github.com/multiflexi): Czech translation 45 | - [David Bautista](https://github.com/dbautistav): Notification Tab enhancement 46 | - [Baptiste Jacquemet](https://github.com/bjacquemet): French Translation 47 | - [Valentina De Col](https://github.com/valentinadc): Italian translation 48 | - [Ma Xiaoliang](https://github.com/ma-xiao-liang): Update Chinese(China) Translation 49 | - [Pooria Morovati](https://github.com/pooriamo): Persian translation 50 | - [Esko Rikkonen](https://github.com/eskorikkonen): Finnish translation 51 | - [Darkpingouin](https://github.com/Darkpingouin): French translation 52 | - [Faisal] (https://github.com/tr0id): Arabic translation 53 | - [rootEnginear](https://github.com/rootEnginear): Thai translation 54 | - [Federico Matteoni](https://github.com/fexed): Italian translation 55 | - [Iosif Dan](https://github.com/danutiosif): Romanian translation 56 | - [Brian L](https://github.com/brianl9995): Spanish translation 57 | - [Wesley Matos](https://github.com/wricke): Portuguese (Brazil) translation 58 | - [Rachel Ng](https://github.com/rachelnml): Malay translation 59 | -------------------------------------------------------------------------------- /src/options/DayDistribution.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/background/Services.js: -------------------------------------------------------------------------------- 1 | import * as Sounds from '../Sounds'; 2 | import { Service } from '../Service'; 3 | import { SingletonPage, PageHost } from './SingletonPage'; 4 | 5 | class SettingsService extends Service 6 | { 7 | constructor(settingsManager) { 8 | super(); 9 | this.settingsManager = settingsManager; 10 | } 11 | 12 | async getSettings() { 13 | return await this.settingsManager.get(); 14 | } 15 | 16 | async setSettings(settings) { 17 | if (!this._isValid(settings)) { 18 | return; 19 | } 20 | 21 | await this.settingsManager.set(settings); 22 | } 23 | 24 | _isValid(settings) { 25 | let phasesValid = [settings.focus, settings.shortBreak, settings.longBreak].every(this._isPhaseValid); 26 | if (!phasesValid) { 27 | return false; 28 | } 29 | 30 | let autostart = settings.autostart && settings.autostart.time; 31 | if (autostart && !autostart.match(/^\d+:\d+$/)) { 32 | return false; 33 | } 34 | 35 | return true; 36 | } 37 | 38 | _isPhaseValid(phase) { 39 | let { duration, timerSound, countdown } = phase; 40 | if (isNaN(duration) || duration <= 0 || duration > 999) { 41 | return false; 42 | } 43 | 44 | if (timerSound && timerSound.metronome) { 45 | let { bpm } = timerSound.metronome; 46 | if (isNaN(bpm) || bpm <= 0 || bpm > 1000) { 47 | return false; 48 | } 49 | } 50 | 51 | if (countdown.host === 'window') { 52 | let { resolution } = countdown; 53 | 54 | // Resolution must either be 'fullscreen' or a [width, height] array. 55 | let isValid = (resolution === 'fullscreen') || (Array.isArray(resolution) && resolution.length === 2 && resolution.every(Number.isInteger)); 56 | if (!isValid) { 57 | return false; 58 | } 59 | } 60 | 61 | return true; 62 | } 63 | } 64 | 65 | class HistoryService extends Service 66 | { 67 | constructor(history) { 68 | super(); 69 | this.history = history; 70 | } 71 | 72 | async getStats(since) { 73 | return await this.history.stats(since); 74 | } 75 | 76 | async getCSV() { 77 | return await this.history.toCSV(); 78 | } 79 | 80 | async getAll() { 81 | return await this.history.all(); 82 | } 83 | 84 | async merge(history) { 85 | return await this.history.merge(history); 86 | } 87 | 88 | async clearHistory() { 89 | return await this.history.clear(); 90 | } 91 | } 92 | 93 | class PomodoroService extends Service 94 | { 95 | constructor(timer) { 96 | super(); 97 | this.timer = timer; 98 | this.timer.observe(this); 99 | } 100 | 101 | async start() { 102 | this.timer.start(); 103 | } 104 | 105 | async pause() { 106 | this.timer.pause(); 107 | } 108 | 109 | async resume() { 110 | this.timer.resume(); 111 | } 112 | 113 | async restart() { 114 | this.timer.restart(); 115 | } 116 | 117 | async getStatus() { 118 | return this.timer.status; 119 | } 120 | 121 | onStart(...args) { 122 | this.emit('start', ...args); 123 | } 124 | 125 | onStop(...args) { 126 | this.emit('stop', ...args); 127 | } 128 | 129 | onPause(...args) { 130 | this.emit('pause', ...args); 131 | } 132 | 133 | onResume(...args) { 134 | this.emit('resume', ...args); 135 | } 136 | 137 | onTick(...args) { 138 | this.emit('tick', ...args); 139 | } 140 | 141 | onExpire(...args) { 142 | this.emit('expire', ...args); 143 | } 144 | } 145 | 146 | class SoundsService extends Service 147 | { 148 | async getNotificationSounds() { 149 | return Sounds.notification; 150 | } 151 | 152 | async getTimerSounds() { 153 | return Sounds.timer; 154 | } 155 | } 156 | 157 | class OptionsService extends Service 158 | { 159 | async showPage(optionPage) { 160 | let manifest = chrome.runtime.getManifest(); 161 | let url = chrome.extension.getURL(manifest.options_page + '#/' + optionPage); 162 | let page = await SingletonPage.show(url, PageHost.Tab); 163 | page.focus(); 164 | } 165 | 166 | async showSettingsPage() { 167 | return await this.showPage('settings'); 168 | } 169 | 170 | async showHistoryPage() { 171 | return await this.showPage('history'); 172 | } 173 | 174 | async showFeedbackPage() { 175 | return await this.showPage('feedback'); 176 | } 177 | } 178 | 179 | const SettingsClient = SettingsService.proxy; 180 | const HistoryClient = HistoryService.proxy; 181 | const PomodoroClient = PomodoroService.proxy; 182 | const SoundsClient = SoundsService.proxy; 183 | const OptionsClient = OptionsService.proxy; 184 | 185 | export { 186 | SettingsService, 187 | SettingsClient, 188 | HistoryService, 189 | HistoryClient, 190 | PomodoroService, 191 | PomodoroClient, 192 | SoundsService, 193 | SoundsClient, 194 | OptionsService, 195 | OptionsClient 196 | }; -------------------------------------------------------------------------------- /src/countdown/Timer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 112 | 113 | -------------------------------------------------------------------------------- /src/expire/Expire.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 137 | 138 | -------------------------------------------------------------------------------- /src/countdown/Countdown.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 107 | 108 | -------------------------------------------------------------------------------- /src/Service.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | class ServiceBroker 4 | { 5 | constructor() { 6 | this.services = {}; 7 | chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); 8 | } 9 | 10 | static get instance() { 11 | if (!this._instance) { 12 | this._instance = new ServiceBroker(); 13 | } 14 | return this._instance; 15 | } 16 | 17 | static register(service) { 18 | return this.instance.register(service); 19 | } 20 | 21 | static async invoke(call) { 22 | return await this.instance.invoke(call); 23 | } 24 | 25 | register(service) { 26 | this.services[service.constructor.name] = service; 27 | return service; 28 | } 29 | 30 | unregister(service) { 31 | delete this.services[service.constructor.name]; 32 | return service; 33 | } 34 | 35 | async invoke({ serviceName, methodName, args }) { 36 | let service = this.services[serviceName]; 37 | if (service) { 38 | if (service[methodName]) { 39 | // Service is defined in this context, call method directly. 40 | return await service[methodName](...args); 41 | } else { 42 | throw new Exception(`Invalid service request: ${serviceName}.${methodName}.`); 43 | } 44 | } 45 | 46 | // Service is defined in another context, use sendMessage to call it. 47 | return await new Promise((resolve, reject) => { 48 | let message = { serviceName, methodName, args }; 49 | chrome.runtime.sendMessage(message, ({ result, error }) => { 50 | if (error !== undefined) { 51 | reject(error); 52 | } else { 53 | resolve(result); 54 | } 55 | }); 56 | }); 57 | } 58 | 59 | onMessage({ serviceName, methodName, args }, sender, respond) { 60 | let service = this.services[serviceName]; 61 | if (!service || methodName === undefined) { 62 | // Service is not defined in this context, so we have nothing to do. 63 | return; 64 | } 65 | 66 | if (!service[methodName]) { 67 | respond({ error: `Invalid service request: ${serviceName}.${methodName}.` }); 68 | return true; 69 | } 70 | 71 | (async () => { 72 | try { 73 | respond({ result: await service[methodName](...args) }); 74 | } catch (e) { 75 | console.error(e); 76 | respond({ error: `${e}` }); 77 | } 78 | })(); 79 | 80 | return true; 81 | } 82 | } 83 | 84 | class ServiceProxy extends EventEmitter 85 | { 86 | constructor(serviceName) { 87 | super(); 88 | this.serviceName = serviceName; 89 | this.listenerCount = 0; 90 | 91 | this.on('removeListener', () => { 92 | if (--this.listenerCount === 0) { 93 | chrome.runtime.onMessage.removeListener(this.onMessage); 94 | } 95 | }); 96 | 97 | this.on('newListener', () => { 98 | if (++this.listenerCount === 1) { 99 | this.onMessage = this._onMessage.bind(this); 100 | chrome.runtime.onMessage.addListener(this.onMessage); 101 | } 102 | }); 103 | } 104 | 105 | dispose() { 106 | this.removeAllListeners(); 107 | chrome.runtime.onMessage.removeListener(this.onMessage); 108 | } 109 | 110 | get(target, prop, receiver) { 111 | if (this[prop]) { 112 | return this[prop]; 113 | } 114 | 115 | const self = this; 116 | return async function() { 117 | const call = { 118 | serviceName: self.serviceName, 119 | methodName: prop, 120 | args: Array.from(arguments) 121 | }; 122 | return await ServiceBroker.invoke(call); 123 | }; 124 | } 125 | 126 | _onMessage({ serviceName, eventName, args }, sender, respond) { 127 | if (serviceName !== this.serviceName || eventName === undefined) { 128 | return; 129 | } 130 | 131 | this.emit(eventName, ...args); 132 | } 133 | } 134 | 135 | class Service 136 | { 137 | constructor() { 138 | this.clients = {}; 139 | this.serviceName = this.constructor.name; 140 | } 141 | 142 | emit(eventName, ...args) { 143 | chrome.runtime.sendMessage({ 144 | serviceName: this.serviceName, 145 | eventName, 146 | args 147 | }); 148 | } 149 | 150 | static get proxy() { 151 | const serviceName = this.name; 152 | const create = () => new Proxy(function() {}, new ServiceProxy(serviceName)); 153 | 154 | const handler = { 155 | construct(target, args) { 156 | return create(); 157 | }, 158 | get(target, prop, receiver) { 159 | // Support one-shot service invocations. 160 | // This creates a client, performs the RPC, then cleans up. 161 | // Example usage: let result = await SomeClient.once.doThing('abc', 123); 162 | 163 | if (prop !== 'once') { 164 | return undefined; 165 | } 166 | 167 | return new Proxy(function() {}, { 168 | get(target, prop, receiver) { 169 | return (...args) => { 170 | let client = create(); 171 | try { 172 | return client[prop](...args); 173 | } finally { 174 | client.dispose(); 175 | } 176 | }; 177 | } 178 | }); 179 | } 180 | }; 181 | 182 | return new Proxy(function() {}, handler); 183 | } 184 | } 185 | 186 | export { 187 | Service, 188 | ServiceBroker 189 | }; -------------------------------------------------------------------------------- /package/images/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/options/Heatmap.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 32 | 33 | 218 | -------------------------------------------------------------------------------- /src/background/Settings.js: -------------------------------------------------------------------------------- 1 | // Deep clone an object. Assumes only simple types and array/object aggregates of simple types. 2 | function clone(obj) { 3 | if (obj instanceof Array) { 4 | let copy = []; 5 | for (let el of obj) { 6 | copy.push(clone(el)); 7 | } 8 | return copy; 9 | } else if (obj instanceof Object) { 10 | let copy = {}; 11 | for (let prop in obj) { 12 | copy[prop] = clone(obj[prop]); 13 | } 14 | return copy; 15 | } else { 16 | return obj; 17 | } 18 | } 19 | 20 | class SettingsSchema 21 | { 22 | get version() { 23 | return 7; 24 | } 25 | 26 | get default() { 27 | return { 28 | focus: { 29 | duration: 25, 30 | timerSound: null, 31 | countdown: { 32 | host: null, 33 | autoclose: true, 34 | resolution: [500, 500] 35 | }, 36 | notifications: { 37 | desktop: true, 38 | tab: true, 39 | sound: null 40 | } 41 | }, 42 | shortBreak: { 43 | duration: 5, 44 | timerSound: null, 45 | countdown: { 46 | host: null, 47 | autoclose: true, 48 | resolution: [500, 500] 49 | }, 50 | notifications: { 51 | desktop: true, 52 | tab: true, 53 | sound: null 54 | } 55 | }, 56 | longBreak: { 57 | duration: 15, 58 | interval: 4, 59 | timerSound: null, 60 | countdown: { 61 | host: null, 62 | autoclose: true, 63 | resolution: [500, 500] 64 | }, 65 | notifications: { 66 | desktop: true, 67 | tab: true, 68 | sound: null 69 | } 70 | }, 71 | autostart: { 72 | time: null, 73 | }, 74 | version: this.version 75 | }; 76 | } 77 | 78 | from1To2(v1) { 79 | return { 80 | focus: { 81 | duration: v1.focus.duration, 82 | notifications: { 83 | desktop: v1.focus.desktopNotification, 84 | tab: v1.focus.newTabNotification, 85 | sound: v1.focus.sound ? new URL(v1.focus.sound).pathname : null 86 | } 87 | }, 88 | shortBreak: { 89 | duration: v1.break.duration, 90 | notifications: { 91 | desktop: v1.break.desktopNotification, 92 | tab: v1.break.newTabNotification, 93 | sound: v1.break.sound ? new URL(v1.break.sound).pathname : null 94 | } 95 | }, 96 | longBreak: { 97 | duration: 15, 98 | interval: 4, 99 | notifications: { 100 | desktop: v1.break.desktopNotification, 101 | tab: v1.break.newTabNotification, 102 | sound: v1.break.sound ? new URL(v1.break.sound).pathname : null 103 | } 104 | }, 105 | version: 2 106 | }; 107 | } 108 | 109 | from2To3(v2) { 110 | const fileNameMap = { 111 | '/audio/battle-horn.mp3': '/audio/88736c22.mp3', 112 | '/audio/bell-ring.mp3': '/audio/b10d75f2.mp3', 113 | '/audio/bike-horn.mp3': '/audio/72312dd3.mp3', 114 | '/audio/computer-magic.mp3': '/audio/5cf807ce.mp3', 115 | '/audio/din-ding.mp3': '/audio/72cb1b7f.mp3', 116 | '/audio/ding-dong.mp3': '/audio/92ff2a8a.mp3', 117 | '/audio/ding.mp3': '/audio/1a5066bd.mp3', 118 | '/audio/dong.mp3': '/audio/5e122cee.mp3', 119 | '/audio/electronic-chime.mp3': '/audio/28d6b5be.mp3', 120 | '/audio/fire-pager.mp3': '/audio/b38e515f.mp3', 121 | '/audio/glass-ping.mp3': '/audio/2ed9509e.mp3', 122 | '/audio/gong-1.mp3': '/audio/8bce59b5.mp3', 123 | '/audio/gong-2.mp3': '/audio/85cab25d.mp3', 124 | '/audio/music-box.mp3': '/audio/ebe7deb8.mp3', 125 | '/audio/pin-dropping.mp3': '/audio/2e13802a.mp3', 126 | '/audio/reception-bell.mp3': '/audio/54b867f9.mp3', 127 | '/audio/robot-blip-1.mp3': '/audio/bd50add0.mp3', 128 | '/audio/robot-blip-2.mp3': '/audio/36e93c27.mp3', 129 | '/audio/ship-bell.mp3': '/audio/9404f598.mp3', 130 | '/audio/toaster-oven.mp3': '/audio/a258e906.mp3', 131 | '/audio/tone.mp3': '/audio/f62b45bc.mp3', 132 | '/audio/train-horn.mp3': '/audio/6a215611.mp3' 133 | }; 134 | 135 | let v3 = clone(v2); 136 | v3.version = 3; 137 | 138 | for (let group of [v3.focus, v3.shortBreak, v3.longBreak].map(s => s.notifications)) { 139 | if (group.sound) { 140 | let newName = fileNameMap[group.sound.toLowerCase()]; 141 | group.sound = newName || group.sound; 142 | } 143 | } 144 | 145 | return v3; 146 | } 147 | 148 | from3To4(v3) { 149 | let v4 = clone(v3); 150 | v4.version = 4; 151 | 152 | v4.focus.timerSound = null; 153 | v4.shortBreak.timerSound = null; 154 | v4.longBreak.timerSound = null; 155 | 156 | return v4; 157 | } 158 | 159 | from4To5(v4) { 160 | let v5 = clone(v4); 161 | v5.version = 5; 162 | 163 | v5.autostart = { 164 | time: null 165 | }; 166 | 167 | return v5; 168 | } 169 | 170 | from5To6(v5) { 171 | let v6 = clone(v5); 172 | v6.version = 6; 173 | 174 | if (v6.focus.timerSound) { 175 | v6.focus.timerSound = { 176 | metronome: v6.focus.timerSound 177 | }; 178 | } 179 | 180 | return v6; 181 | } 182 | 183 | from6To7(v6) { 184 | let v7 = clone(v6); 185 | v7.version = 7; 186 | 187 | v7.focus.countdown = { 188 | host: null, 189 | autoclose: true, 190 | resolution: [500, 500] 191 | }; 192 | 193 | v7.shortBreak.countdown = { 194 | host: null, 195 | autoclose: true, 196 | resolution: [500, 500] 197 | }; 198 | 199 | v7.longBreak.countdown = { 200 | host: null, 201 | autoclose: true, 202 | resolution: [500, 500] 203 | }; 204 | 205 | return v7; 206 | } 207 | } 208 | 209 | class PersistentSettings 210 | { 211 | static async create(settingsManager) { 212 | let settings = await settingsManager.get(); 213 | 214 | // When the settings change, we update the underlying settigs object, which 215 | // allows users of the settings to see the updated values. 216 | settingsManager.on('change', newSettings => settings = newSettings); 217 | 218 | // We return a proxy object that forwards all getters 219 | // to the underlying settings object. 220 | return new Proxy(function() {}, { 221 | get(target, prop, receiver) { 222 | return settings[prop]; 223 | } 224 | }); 225 | } 226 | } 227 | 228 | export { 229 | SettingsSchema, 230 | PersistentSettings 231 | }; -------------------------------------------------------------------------------- /assets/promotional/hacktoberfest.svg: -------------------------------------------------------------------------------- 1 | logo 2 | -------------------------------------------------------------------------------- /src/Chrome.js: -------------------------------------------------------------------------------- 1 | class ChromeError extends Error 2 | { 3 | constructor(...params) { 4 | super(...params); 5 | } 6 | } 7 | 8 | class Chrome 9 | { 10 | static get tabs() { 11 | return Tabs; 12 | } 13 | 14 | static get windows() { 15 | return Windows; 16 | } 17 | 18 | static get notifications() { 19 | return Notifications; 20 | } 21 | 22 | static get storage() { 23 | return Storage; 24 | } 25 | 26 | static get files() { 27 | return Files; 28 | } 29 | 30 | static get alarms() { 31 | return Alarms; 32 | } 33 | 34 | static get runtime() { 35 | return Runtime; 36 | } 37 | } 38 | 39 | function promise(fn) { 40 | return new Promise((resolve, reject) => { 41 | const callback = (...results) => { 42 | const err = chrome.runtime.lastError; 43 | if (err) { 44 | reject(new ChromeError(err.message)); 45 | } else { 46 | resolve(...results); 47 | } 48 | }; 49 | 50 | fn(callback); 51 | }); 52 | } 53 | 54 | class Tabs 55 | { 56 | static async create(options) { 57 | // Create tab in specific window. 58 | const createInWindow = async windowId => { 59 | // Get the currently active tab in this window and make it the 'opener' 60 | // of the tab we're creating. When our tab is closed, the opener tab will 61 | // be reactivated. 62 | let tabs = await Chrome.tabs.query({ active: true, windowId }); 63 | let openerTab = (tabs && tabs.length > 0) ? tabs[0] : {}; 64 | let openerTabId = openerTab.id || null; 65 | let index = openerTab.index + 1 || 0; 66 | 67 | let tabOptions = { ...options, windowId, openerTabId, index }; 68 | return promise(callback => { 69 | chrome.tabs.create(tabOptions, callback); 70 | }); 71 | }; 72 | 73 | try { 74 | let targetWindow = await Chrome.windows.getLastFocused({ windowTypes: ['normal'] }); 75 | if (targetWindow) { 76 | return createInWindow(targetWindow.id); 77 | } 78 | } catch (e) { 79 | if (e instanceof ChromeError) { 80 | // We assume there was no last focused window, ignore. 81 | console.error(e); 82 | } else { 83 | throw e; 84 | } 85 | } 86 | 87 | // No active window for our tab, so we must create our own. 88 | let windowOptions = { focused: !!options.active }; 89 | let newWindow = await Chrome.windows.create(windowOptions); 90 | return createInWindow(newWindow.id); 91 | } 92 | 93 | static async getCurrent() { 94 | return promise(callback => { 95 | chrome.tabs.getCurrent(callback); 96 | }); 97 | } 98 | 99 | static async update(tabId, updateProperties) { 100 | return promise(callback => { 101 | chrome.tabs.update(tabId, updateProperties, callback); 102 | }); 103 | } 104 | 105 | static async query(queryInfo) { 106 | return promise(callback => { 107 | chrome.tabs.query(queryInfo, callback); 108 | }); 109 | } 110 | } 111 | 112 | class Windows 113 | { 114 | static async getAll(getInfo) { 115 | return promise(callback => { 116 | chrome.windows.getAll(getInfo, callback); 117 | }); 118 | } 119 | 120 | static async getLastFocused(getInfo) { 121 | return promise(callback => { 122 | chrome.windows.getLastFocused(getInfo, callback); 123 | }); 124 | } 125 | 126 | static async create(createData) { 127 | if (createData.state === 'maximized' && createData.type === 'popup') { 128 | let { os } = await Chrome.runtime.getPlatformInfo(); 129 | if (os === chrome.runtime.PlatformOs.MAC) { 130 | // Bug workaround: On macOS, creating maximized popup windows is bugged 131 | // and creates really small windows instead. Here, we work around this 132 | // behavior by creating a window with its size equal to the screen size. 133 | createData = { 134 | ...createData, 135 | width: window.screen.width, 136 | height: window.screen.height, 137 | left: 0, 138 | top: 0 139 | }; 140 | delete createData.state; 141 | } 142 | } 143 | 144 | return promise(callback => { 145 | chrome.windows.create(createData, callback); 146 | }); 147 | } 148 | 149 | static async update(windowId, updateInfo) { 150 | return promise(callback => { 151 | chrome.windows.update(windowId, updateInfo, callback); 152 | }); 153 | } 154 | } 155 | 156 | class Notifications 157 | { 158 | static async create(options) { 159 | return promise(callback => { 160 | try { 161 | chrome.notifications.create('', options, callback); 162 | } catch (e) { 163 | // This is failing on Firefox as it doesn't support the buttons option for the notification and raises an exception when this is called. (see http://bugzil.la/1190681) 164 | // Try again with a subset of options that are more broadly supported 165 | const compatibleOptions = { 166 | type: options.type, 167 | iconUrl: options.iconUrl, 168 | title: options.title, 169 | message: options.message 170 | }; 171 | chrome.notifications.create('', compatibleOptions, callback); 172 | } 173 | }); 174 | } 175 | } 176 | 177 | class Storage 178 | { 179 | constructor(store) { 180 | this.store = store; 181 | } 182 | 183 | get(keys = null) { 184 | return promise(callback => { 185 | this.store.get(keys, callback); 186 | }); 187 | } 188 | 189 | set(obj) { 190 | return promise(callback => { 191 | this.store.set(obj, callback); 192 | }); 193 | } 194 | 195 | clear() { 196 | return promise(callback => { 197 | this.store.clear(callback); 198 | }); 199 | } 200 | 201 | static get sync() { 202 | if (!this._sync) { 203 | this._sync = new Storage(chrome.storage.sync); 204 | } 205 | return this._sync; 206 | } 207 | 208 | static get local() { 209 | if (!this._local) { 210 | this._local = new Storage(chrome.storage.local); 211 | } 212 | return this._local; 213 | } 214 | } 215 | 216 | class Files 217 | { 218 | static async readFile(file) { 219 | let url = chrome.runtime.getURL(file); 220 | let response = await fetch(url); 221 | return await response.text(); 222 | } 223 | 224 | static async readBinary(file) { 225 | let url = chrome.runtime.getURL(file); 226 | let response = await fetch(url); 227 | return await response.arrayBuffer(); 228 | } 229 | } 230 | 231 | class Alarms 232 | { 233 | static create(name, alarmInfo) { 234 | return chrome.alarms.create(name, alarmInfo); 235 | } 236 | 237 | static async clearAll() { 238 | return promise(callback => { 239 | chrome.alarms.clearAll(callback) 240 | }); 241 | } 242 | } 243 | 244 | class Runtime 245 | { 246 | static getPlatformInfo() { 247 | return promise(callback => { 248 | chrome.runtime.getPlatformInfo(callback) 249 | }); 250 | } 251 | } 252 | 253 | export default Chrome; 254 | -------------------------------------------------------------------------------- /src/background/Menu.js: -------------------------------------------------------------------------------- 1 | import M from '../Messages'; 2 | import { OptionsClient } from './Services'; 3 | 4 | class Menu 5 | { 6 | constructor(contexts, ...groups) { 7 | this.contexts = contexts; 8 | this.groups = groups; 9 | } 10 | 11 | addGroup(group) { 12 | this.groups.push(group); 13 | } 14 | 15 | apply() { 16 | chrome.contextMenus.removeAll(); 17 | 18 | let firstGroup = true; 19 | for (let group of this.groups) { 20 | let firstItem = true; 21 | for (let item of group.items) { 22 | if (!item.visible) { 23 | continue; 24 | } 25 | 26 | if (firstItem && !firstGroup) { 27 | chrome.contextMenus.create({ type: 'separator', contexts: this.contexts }); 28 | } 29 | 30 | firstGroup = false; 31 | firstItem = false; 32 | 33 | if (item instanceof ParentMenu) { 34 | let id = chrome.contextMenus.create({ 35 | title: item.title, 36 | contexts: this.contexts 37 | }); 38 | 39 | for (let child of item.children) { 40 | if (!child.visible) { 41 | continue; 42 | } 43 | 44 | chrome.contextMenus.create({ 45 | title: child.title, 46 | contexts: this.contexts, 47 | onclick: () => child.run(), 48 | parentId: id 49 | }); 50 | } 51 | } else { 52 | chrome.contextMenus.create({ 53 | title: item.title, 54 | contexts: this.contexts, 55 | onclick: () => item.run() 56 | }); 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | class MenuGroup 64 | { 65 | constructor(...items) { 66 | this.items = items; 67 | } 68 | 69 | addItem(item) { 70 | this.items.push(item); 71 | } 72 | } 73 | 74 | class ParentMenu 75 | { 76 | constructor(...children) { 77 | this.children = children; 78 | } 79 | 80 | addChild(child) { 81 | this.children.push(child); 82 | } 83 | 84 | get title() { 85 | return ''; 86 | } 87 | 88 | get visible() { 89 | return false; 90 | } 91 | } 92 | 93 | class RestartTimerParentMenu extends ParentMenu 94 | { 95 | constructor(...children) { 96 | super(...children); 97 | } 98 | 99 | get title() { 100 | return M.restart_timer; 101 | } 102 | 103 | get visible() { 104 | return true; 105 | } 106 | } 107 | 108 | class Action 109 | { 110 | get title() { 111 | return ''; 112 | } 113 | 114 | get visible() { 115 | return false; 116 | } 117 | 118 | run() { 119 | } 120 | } 121 | 122 | class StartFocusingAction extends Action 123 | { 124 | constructor(timer) { 125 | super(); 126 | this.timer = timer; 127 | } 128 | 129 | get title() { 130 | return M.start_focusing; 131 | } 132 | 133 | get visible() { 134 | return true; 135 | } 136 | 137 | run() { 138 | this.timer.startFocus(); 139 | } 140 | } 141 | 142 | class StartShortBreakAction extends Action 143 | { 144 | constructor(timer) { 145 | super(); 146 | this.timer = timer; 147 | } 148 | 149 | get title() { 150 | return this.timer.hasLongBreak ? M.start_short_break : M.start_break; 151 | } 152 | 153 | get visible() { 154 | return true; 155 | } 156 | 157 | run() { 158 | this.timer.startShortBreak(); 159 | } 160 | } 161 | 162 | class StartLongBreakAction extends Action 163 | { 164 | constructor(timer) { 165 | super(); 166 | this.timer = timer; 167 | } 168 | 169 | get title() { 170 | return M.start_long_break; 171 | } 172 | 173 | get visible() { 174 | return this.timer.hasLongBreak; 175 | } 176 | 177 | run() { 178 | this.timer.startLongBreak(); 179 | } 180 | } 181 | 182 | class StopTimerAction extends Action 183 | { 184 | constructor(timer) { 185 | super(); 186 | this.timer = timer; 187 | } 188 | 189 | get title() { 190 | return M.stop_timer; 191 | } 192 | 193 | get visible() { 194 | return this.timer.isRunning || this.timer.isPaused; 195 | } 196 | 197 | run() { 198 | this.timer.stop(); 199 | } 200 | } 201 | 202 | class PauseTimerAction extends Action 203 | { 204 | constructor(timer) { 205 | super(); 206 | this.timer = timer; 207 | } 208 | 209 | get title() { 210 | return M.pause_timer; 211 | } 212 | 213 | get visible() { 214 | return this.timer.isRunning; 215 | } 216 | 217 | run() { 218 | this.timer.pause(); 219 | } 220 | } 221 | 222 | class ResumeTimerAction extends Action 223 | { 224 | constructor(timer) { 225 | super(); 226 | this.timer = timer; 227 | } 228 | 229 | get title() { 230 | return M.resume_timer; 231 | } 232 | 233 | get visible() { 234 | return this.timer.isPaused; 235 | } 236 | 237 | run() { 238 | this.timer.resume(); 239 | } 240 | } 241 | 242 | class PomodoroHistoryAction extends Action 243 | { 244 | constructor() { 245 | super(); 246 | } 247 | 248 | get title() { 249 | return M.pomodoro_history; 250 | } 251 | 252 | get visible() { 253 | return true; 254 | } 255 | 256 | async run() { 257 | await OptionsClient.once.showHistoryPage(); 258 | } 259 | } 260 | 261 | class StartPomodoroCycleAction extends Action 262 | { 263 | constructor(timer) { 264 | super(); 265 | this.timer = timer; 266 | } 267 | 268 | get title() { 269 | if (this.timer.isRunning || this.timer.isPaused) { 270 | return M.restart_pomodoro_cycle; 271 | } else { 272 | return M.start_pomodoro_cycle; 273 | } 274 | } 275 | 276 | get visible() { 277 | return this.timer.hasLongBreak; 278 | } 279 | 280 | run() { 281 | this.timer.startCycle(); 282 | } 283 | } 284 | 285 | class PomodoroMenuSelector 286 | { 287 | constructor(timer, inactive, active) { 288 | this.timer = timer; 289 | this.inactive = inactive; 290 | this.active = active; 291 | } 292 | 293 | apply() { 294 | let menu = (this.timer.isRunning || this.timer.isPaused) ? this.active : this.inactive; 295 | menu.apply(); 296 | } 297 | } 298 | 299 | function createPomodoroMenu(timer) { 300 | let pause = new PauseTimerAction(timer); 301 | let resume = new ResumeTimerAction(timer); 302 | let stop = new StopTimerAction(timer); 303 | 304 | let startCycle = new StartPomodoroCycleAction(timer); 305 | let startFocus = new StartFocusingAction(timer); 306 | let startShortBreak = new StartShortBreakAction(timer); 307 | let startLongBreak = new StartLongBreakAction(timer); 308 | let viewHistory = new PomodoroHistoryAction(); 309 | 310 | let inactive = new Menu(['browser_action'], 311 | new MenuGroup( 312 | startCycle, 313 | startFocus, 314 | startShortBreak, 315 | startLongBreak 316 | ), 317 | new MenuGroup( 318 | viewHistory 319 | ) 320 | ); 321 | 322 | let active = new Menu(['browser_action'], 323 | new MenuGroup( 324 | pause, 325 | resume, 326 | stop, 327 | new RestartTimerParentMenu( 328 | startFocus, 329 | startShortBreak, 330 | startLongBreak 331 | ), 332 | startCycle 333 | ), 334 | new MenuGroup( 335 | viewHistory 336 | ) 337 | ); 338 | 339 | return new PomodoroMenuSelector(timer, inactive, active); 340 | } 341 | 342 | export { 343 | createPomodoroMenu 344 | }; -------------------------------------------------------------------------------- /src/options/History.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 194 | 195 | -------------------------------------------------------------------------------- /package/ATTRIBUTION: -------------------------------------------------------------------------------- 1 | File: audio/72312dd3.mp3 2 | Author: StickInTheMud 3 | URL: http://soundbible.com/1446-Bike-Horn.html 4 | License: Creative Commons Sampling Plus 1.0 (https://creativecommons.org/licenses/sampling+/1.0/) 5 | 6 | File: audio/6a215611.mp3 7 | Author: Mike Koenig 8 | URL: http://soundbible.com/1695-Train-Honk-Horn-2x.html 9 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 10 | 11 | File: audio/28d6b5be.mp3 12 | Author: KevanGC 13 | URL: http://soundbible.com/1598-Electronic-Chime.html 14 | License: Public Domain 15 | 16 | File: audio/8bce59b5.mp3 17 | Author: Dianakc 18 | URL: http://soundbible.com/2062-Metal-Gong-1.html 19 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 20 | 21 | File: audio/9404f598.mp3 22 | Author: Mike Koenig 23 | URL: http://soundbible.com/1746-Ship-Bell.html 24 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 25 | 26 | File: audio/5cf807ce.mp3 27 | Author: Microsift 28 | URL: http://soundbible.com/1630-Computer-Magic.html 29 | License: Public Domain 30 | 31 | File: audio/ebe7deb8.mp3 32 | Author: Big Daddy 33 | URL: http://soundbible.com/1619-Music-Box.html 34 | License: Public Domain 35 | 36 | File: audio/2ed9509e.mp3 37 | Author: Go445 38 | URL: http://soundbible.com/2084-Glass-Ping.html 39 | License: Creative Commons Attribution-NonCommercial (https://creativecommons.org/licenses/by-nc/3.0/us/) 40 | 41 | File: audio/2e13802a.mp3 42 | Author: Brian Rocca 43 | URL: http://soundbible.com/1992-Pin-Dropping.html 44 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 45 | 46 | File: audio/bd50add0.mp3 47 | Author: Marianne Gagnon 48 | URL: http://soundbible.com/1682-Robot-Blip.html 49 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 50 | 51 | File: audio/36e93c27.mp3 52 | Author: Marianne Gagnon 53 | URL: http://soundbible.com/1669-Robot-Blip-2.html 54 | License: Creative Commons Attribution 3.0 (https://creativecommons.org/licenses/by/3.0/us/) 55 | 56 | File: audio/b38e515f.mp3 57 | Author: jason 58 | URL: http://soundbible.com/1766-Fire-Pager.html 59 | License: Public Domain 60 | 61 | File: audio/f62b45bc.mp3 62 | Author: His Self 63 | URL: http://soundbible.com/1815-A-Tone.html 64 | License: Public Domain 65 | 66 | File: audio/54b867f9.mp3 67 | Author: cdrk 68 | URL: https://www.freesound.org/people/cdrk/sounds/264594/ 69 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 70 | 71 | File: audio/1a5066bd.mp3 72 | Author: Aiwha 73 | URL: https://www.freesound.org/people/Aiwha/sounds/196106/ 74 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 75 | 76 | File: audio/a258e906.mp3 77 | Author: sethlind 78 | URL: https://www.freesound.org/people/sethlind/sounds/265012/ 79 | License: Creative Commons Public Domain Dedication (http://creativecommons.org/publicdomain/zero/1.0/) 80 | 81 | File: audio/72cb1b7f.mp3 82 | Author: Daenn 83 | URL: https://www.freesound.org/people/Daenn/sounds/159158/ 84 | License: Creative Commons Public Domain Dedication (http://creativecommons.org/publicdomain/zero/1.0/) 85 | 86 | File: audio/85cab25d.mp3 87 | Author: GowlerMusic 88 | URL: https://www.freesound.org/people/GowlerMusic/sounds/266566/ 89 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 90 | 91 | File: audio/88736c22.mp3 92 | Author: freefire66 93 | URL: https://www.freesound.org/people/freefire66/sounds/175946/ 94 | License: Creative Commons Public Domain Dedication (http://creativecommons.org/publicdomain/zero/1.0/) 95 | 96 | File: audio/5e122cee.mp3 97 | Author: Roentgine 98 | URL: https://www.freesound.org/people/Roentgine/sounds/74710/ 99 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 100 | 101 | File: audio/92ff2a8a.mp3 102 | Author: jacobsteel 103 | URL: https://www.freesound.org/people/jacobsteel/sounds/243607/ 104 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 105 | 106 | File: audio/0f034826.mp3 107 | Author: bone666138 108 | URL: http://freesound.org/people/bone666138/sounds/198841/ 109 | License: Creative Commons Attribution 3.0 Unported (http://creativecommons.org/licenses/by/3.0/) 110 | 111 | File: audio/be75f155.mp3 112 | Author: Koyber 113 | URL: http://freesound.org/people/Koyber/sounds/160484/ 114 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 115 | 116 | File: audio/fee369b7.mp3 117 | Author: AlaskaRobotics 118 | URL: http://freesound.org/people/AlaskaRobotics/sounds/221085/ 119 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 120 | 121 | Files: audio/4cf03078.mp3, audio/edab7b0d.mp3 122 | Author: FoolBoyMedia 123 | URL: http://freesound.org/people/FoolBoyMedia/sounds/264498/ 124 | License: Creative Commons Attribution-NonCommercial (https://creativecommons.org/licenses/by-nc/3.0/us/) 125 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 126 | 127 | Files: audio/af607ff1.mp3, audio/fd23aaf3.mp3 128 | Author: AntumDeluge 129 | URL: http://freesound.org/people/AntumDeluge/sounds/188033/ 130 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 131 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 132 | 133 | File: audio/ad6eac9e.mp3 134 | Author: Druminfected 135 | URL: http://freesound.org/people/Druminfected/sounds/250552/ 136 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 137 | Notes: The original audio was cropped and converted to mp3. 138 | 139 | File: fe5d2a62.mp3 140 | Author: Tobiasz 'unfa' Karoń 141 | URL: http://freesound.org/people/unfa/sounds/243749/ 142 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 143 | Notes: The original audio was converted to mp3. 144 | 145 | Files: audio/bc4e3db2.mp3, audio/f9efd11b.mp3 146 | Author: FlashTrauma 147 | URL: http://freesound.org/people/FlashTrauma/sounds/398275/ 148 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 149 | Notes: The original audio was denoised, split into separate tick/tock sounds, and converted to mp3. 150 | 151 | Files: audio/bced7c21.mp3, audio/9bd67f7e.mp3 152 | Author: HollowRiku 153 | URL: http://freesound.org/people/HollowRiku/sounds/146781/ 154 | License: Creative Commons Attribution 3.0 Unported (https://creativecommons.org/licenses/by/3.0/) 155 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 156 | 157 | Files: audio/875326f9.mp3, audio/cba5f173.mp3 158 | Author: differentieel 159 | URL: http://freesound.org/people/differentieel/sounds/245455/ 160 | License: Creative Commons Attribution-NonCommercial (https://creativecommons.org/licenses/by-nc/3.0/us/) 161 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 162 | 163 | Files: audio/6a981bfc.mp3, audio/fd64de98.mp3 164 | Author: InspectorJ 165 | URL: http://freesound.org/people/InspectorJ/sounds/343130/ 166 | License: Creative Commons Attribution 3.0 Unported (https://creativecommons.org/licenses/by/3.0/) 167 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 168 | 169 | Files: audio/8dc834f8.mp3, audio/831a5549.mp3 170 | Author: andychristen 171 | URL: http://freesound.org/people/andychristen/sounds/268185/ 172 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 173 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 174 | 175 | Files: audio/2122d2a4.mp3, audio/a273ba0c.mp3 176 | Author: Rollo145 177 | URL: http://freesound.org/people/Rollo145/sounds/256477/ 178 | License: Creative Commons Attribution 3.0 Unported (https://creativecommons.org/licenses/by/3.0/) 179 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 180 | 181 | Files: audio/6103cd58.mp3, audio/cad167ea.mp3 182 | Author: atka187 183 | URL: http://freesound.org/people/atka187/sounds/409091/ 184 | License: Creative Commons 0 1.0 Universal Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/) 185 | Notes: The original audio was split into separate tick/tock sounds and converted to mp3. 186 | 187 | File: images/settings.svg 188 | Author: Freepik 189 | URL: https://www.flaticon.com/free-icon/settings_263100 190 | License: Flaticon Basic License (https://file000.flaticon.com/downloads/license/license.pdf) 191 | 192 | File: images/history.svg 193 | Author: Lucy G 194 | URL: https://www.flaticon.com/free-icon/stats_118798 195 | License: Creative Commons Attribution 3.0 Unported (https://creativecommons.org/licenses/by/3.0/) 196 | 197 | File: images/pause.svg 198 | Author: Chanut 199 | URL: https://www.flaticon.com/free-icon/pause_151859 200 | License: Flaticon Basic License (https://file000.flaticon.com/downloads/license/license.pdf) 201 | 202 | File: images/play.svg 203 | Author: Chanut 204 | URL: https://www.flaticon.com/free-icon/play-button_151860 205 | License: Flaticon Basic License (https://file000.flaticon.com/downloads/license/license.pdf) 206 | 207 | File: images/restart.svg 208 | Author: Freepik 209 | URL: https://www.flaticon.com/free-icon/undo-circular-arrow_44426 210 | License: Flaticon Basic License (https://file000.flaticon.com/downloads/license/license.pdf) -------------------------------------------------------------------------------- /src/background/Timer.js: -------------------------------------------------------------------------------- 1 | import Enum from './Enum'; 2 | import EventEmitter from 'events'; 3 | 4 | const TimerState = new Enum({ 5 | Stopped: 0, 6 | Running: 1, 7 | Paused: 2 8 | }); 9 | 10 | class Timer extends EventEmitter 11 | { 12 | constructor(duration, tick) { 13 | super(); 14 | 15 | this.state = TimerState.Stopped; 16 | this.duration = duration; 17 | this.tick = tick; 18 | 19 | this.tickInterval = null; 20 | this.expireTimeout = null; 21 | 22 | this.checkpointStartAt = null; 23 | this.checkpointElapsed = 0; 24 | } 25 | 26 | observe(observer) { 27 | observer.onStart && this.on('start', (...args) => observer.onStart(...args)); 28 | observer.onStop && this.on('stop', (...args) => observer.onStop(...args)); 29 | observer.onPause && this.on('pause', (...args) => observer.onPause(...args)); 30 | observer.onResume && this.on('resume', (...args) => observer.onResume(...args)); 31 | observer.onTick && this.on('tick', (...args) => observer.onTick(...args)); 32 | observer.onExpire && this.on('expire', (...args) => observer.onExpire(...args)); 33 | } 34 | 35 | get isStopped() { 36 | return this.state === TimerState.Stopped; 37 | } 38 | 39 | get isRunning() { 40 | return this.state === TimerState.Running; 41 | } 42 | 43 | get isPaused() { 44 | return this.state === TimerState.Paused; 45 | } 46 | 47 | get remaining() { 48 | return this.duration - this.elapsed; 49 | } 50 | 51 | get elapsed() { 52 | let periodElapsed = 0; 53 | if (this.checkpointStartAt && this.isRunning) { 54 | periodElapsed = (Date.now() - this.checkpointStartAt) / 1000; 55 | } 56 | return this.checkpointElapsed + periodElapsed; 57 | } 58 | 59 | get status() { 60 | return { 61 | state: this.state, 62 | duration: this.duration, 63 | elapsed: this.elapsed, 64 | remaining: this.remaining, 65 | checkpointElapsed: this.checkpointElapsed, 66 | checkpointStartAt: this.checkpointStartAt 67 | }; 68 | } 69 | 70 | start() { 71 | if (!this.isStopped) { 72 | return; 73 | } 74 | 75 | this.setExpireTimeout(this.duration); 76 | this.setTickInterval(this.tick); 77 | 78 | this.state = TimerState.Running; 79 | this.checkpointStartAt = Date.now(); 80 | 81 | this.emit('start', this.status); 82 | } 83 | 84 | stop() { 85 | if (this.isStopped) { 86 | return; 87 | } 88 | 89 | clearInterval(this.tickInterval); 90 | clearTimeout(this.expireTimeout); 91 | 92 | this.tickInterval = null; 93 | this.expireTimeout = null; 94 | this.checkpointStartAt = null; 95 | this.checkpointElapsed = 0; 96 | 97 | this.state = TimerState.Stopped; 98 | 99 | this.emit('stop', this.status); 100 | } 101 | 102 | pause() { 103 | if (!this.isRunning) { 104 | return; 105 | } 106 | 107 | clearInterval(this.tickInterval); 108 | clearTimeout(this.expireTimeout); 109 | 110 | let periodElapsed = (Date.now() - this.checkpointStartAt) / 1000; 111 | this.checkpointElapsed += periodElapsed; 112 | 113 | this.state = TimerState.Paused; 114 | 115 | this.emit('pause', this.status); 116 | } 117 | 118 | resume() { 119 | if (!this.isPaused) { 120 | return; 121 | } 122 | 123 | this.setExpireTimeout(this.remaining); 124 | this.setTickInterval(this.tick); 125 | 126 | this.state = TimerState.Running; 127 | this.checkpointStartAt = Date.now(); 128 | 129 | this.emit('resume', this.status); 130 | } 131 | 132 | restart() { 133 | this.stop(); 134 | this.start(); 135 | } 136 | 137 | setExpireTimeout(seconds) { 138 | this.expireTimeout = setTimeout(() => { 139 | clearInterval(this.tickInterval); 140 | clearTimeout(this.expireTimeout); 141 | 142 | this.tickInterval = null; 143 | this.expireTimeout = null; 144 | this.checkpointStartAt = Date.now(); 145 | this.checkpointElapsed = this.duration; 146 | 147 | this.state = TimerState.Stopped; 148 | 149 | this.emit('expire', this.status); 150 | }, seconds * 1000); 151 | } 152 | 153 | setTickInterval(seconds) { 154 | this.tickInterval = setInterval(() => { 155 | this.emit('tick', this.status); 156 | }, seconds * 1000); 157 | } 158 | } 159 | 160 | const Phase = new Enum({ 161 | Focus: 0, 162 | ShortBreak: 1, 163 | LongBreak: 2 164 | }); 165 | 166 | class PomodoroTimer extends EventEmitter 167 | { 168 | constructor(settings, initialPhase = Phase.Focus, timerType = Timer) { 169 | super(); 170 | this.timerType = timerType; 171 | this.advanceTimer = false; 172 | this.pomodoros = 0; 173 | this.settings = settings; 174 | this.phase = initialPhase; 175 | } 176 | 177 | _updateTimer() { 178 | let { duration } = { 179 | [Phase.Focus]: this.settings.focus, 180 | [Phase.ShortBreak]: this.settings.shortBreak, 181 | [Phase.LongBreak]: this.settings.longBreak 182 | }[this.phase]; 183 | 184 | if (this.timer) { 185 | this.timer.stop(); 186 | this.timer.removeAllListeners(); 187 | } 188 | 189 | this.timer = new this.timerType(Math.floor(duration * 60), 60); 190 | this.timer.observe(this); 191 | } 192 | 193 | get phase() { 194 | return this._phase; 195 | } 196 | 197 | set phase(newPhase) { 198 | if (!this.hasLongBreak && newPhase === Phase.LongBreak) { 199 | throw new Error('No long break interval defined.'); 200 | } 201 | 202 | this._phase = newPhase; 203 | this._updateTimer(); 204 | this.advanceTimer = false; 205 | } 206 | 207 | get nextPhase() { 208 | if (this.phase === Phase.ShortBreak || this.phase === Phase.LongBreak) { 209 | return Phase.Focus; 210 | } 211 | 212 | if (!this.hasLongBreak) { 213 | return Phase.ShortBreak; 214 | } 215 | 216 | if (this.pomodorosUntilLongBreak === 0) { 217 | return Phase.LongBreak; 218 | } else { 219 | return Phase.ShortBreak; 220 | } 221 | } 222 | 223 | get hasLongBreak() { 224 | return this.settings.longBreak.interval > 0; 225 | } 226 | 227 | get pomodorosUntilLongBreak() { 228 | let { interval } = this.settings.longBreak; 229 | if (!interval) { 230 | return null; 231 | } 232 | 233 | return interval - ((this.pomodoros - 1) % interval) - 1; 234 | } 235 | 236 | get remaining() { 237 | return this.timer.remaining; 238 | } 239 | 240 | get elapsed() { 241 | return this.timer.elapsed; 242 | } 243 | 244 | get state() { 245 | return this.timer.state; 246 | } 247 | 248 | get isRunning() { 249 | return this.timer.isRunning; 250 | } 251 | 252 | get isStopped() { 253 | return this.timer.isStopped; 254 | } 255 | 256 | get isPaused() { 257 | return this.timer.isPaused; 258 | } 259 | 260 | get status() { 261 | return { 262 | phase: this.phase, 263 | nextPhase: this.nextPhase, 264 | ...this.timer.status 265 | }; 266 | } 267 | 268 | dispose() { 269 | this.timer.stop(); 270 | this.timer.removeAllListeners(); 271 | } 272 | 273 | startCycle() { 274 | this.pomodoros = 0; 275 | this.phase = Phase.Focus; 276 | this.start(); 277 | } 278 | 279 | startFocus() { 280 | this.phase = Phase.Focus; 281 | this.start(); 282 | } 283 | 284 | startShortBreak() { 285 | this.phase = Phase.ShortBreak; 286 | this.start(); 287 | } 288 | 289 | startLongBreak() { 290 | this.phase = Phase.LongBreak; 291 | this.start(); 292 | } 293 | 294 | start() { 295 | if (this.advanceTimer) { 296 | this._phase = this.nextPhase; 297 | this.advanceTimer = false; 298 | } 299 | 300 | this._updateTimer(); 301 | this.timer.start(); 302 | } 303 | 304 | pause() { 305 | return this.timer.pause(); 306 | } 307 | 308 | stop() { 309 | return this.timer.stop(); 310 | } 311 | 312 | resume() { 313 | return this.timer.resume(); 314 | } 315 | 316 | restart() { 317 | // Calling timer.restart directly would ignore any settings changes, so 318 | // so we call start then stop to ensure we pick up on the changes. 319 | this.stop(); 320 | return this.start(); 321 | } 322 | 323 | observe(observer) { 324 | observer.onStart && this.on('start', (...args) => observer.onStart(...args)); 325 | observer.onStop && this.on('stop', (...args) => observer.onStop(...args)); 326 | observer.onPause && this.on('pause', (...args) => observer.onPause(...args)); 327 | observer.onResume && this.on('resume', (...args) => observer.onResume(...args)); 328 | observer.onTick && this.on('tick', (...args) => observer.onTick(...args)); 329 | observer.onExpire && this.on('expire', (...args) => observer.onExpire(...args)); 330 | } 331 | 332 | onStart(status) { 333 | this.emit('start', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 334 | } 335 | 336 | onStop(status) { 337 | this.emit('stop', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 338 | } 339 | 340 | onPause(status) { 341 | this.emit('pause', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 342 | } 343 | 344 | onResume(status) { 345 | this.emit('resume', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 346 | } 347 | 348 | onTick(status) { 349 | this.emit('tick', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 350 | } 351 | 352 | onExpire(status) { 353 | if (this.phase === Phase.Focus) { 354 | this.pomodoros++; 355 | } 356 | 357 | this.advanceTimer = true; 358 | this.emit('expire', { phase: this.phase, nextPhase: this.nextPhase, ...status }); 359 | } 360 | } 361 | 362 | export { 363 | Timer, 364 | TimerState, 365 | Phase, 366 | PomodoroTimer 367 | }; --------------------------------------------------------------------------------