├── .eslintignore ├── .babelrc ├── .gitignore ├── src ├── options │ ├── index.js │ ├── options.css │ ├── __snapshots__ │ │ └── options.test.js.snap │ ├── options.test.js │ ├── options.html │ └── options.js ├── default-popup │ ├── index.js │ ├── default-popup.css │ ├── default-popup.html │ └── default-popup.js ├── background │ ├── index.js │ ├── main.js │ └── tabs-sync.js └── helpers │ ├── ui.js │ ├── __snapshots__ │ └── import.test.js.snap │ ├── groups.js │ ├── import.js │ ├── tabs.js │ ├── import.test.js │ ├── bookmarks.js │ └── tabs.test.js ├── .travis.yml ├── .editorconfig ├── .eslintrc.json ├── rollup.config.js ├── CHANGELOG.md ├── manifest.json ├── LICENSE ├── README.md ├── package.json └── assets └── icons └── tabmarks.svg /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /web-ext-artifacts 4 | *.log 5 | -------------------------------------------------------------------------------- /src/options/index.js: -------------------------------------------------------------------------------- 1 | import options from './options'; 2 | 3 | options.init(); 4 | -------------------------------------------------------------------------------- /src/default-popup/index.js: -------------------------------------------------------------------------------- 1 | import defaultPopup from './default-popup'; 2 | 3 | defaultPopup.init(); 4 | -------------------------------------------------------------------------------- /src/background/index.js: -------------------------------------------------------------------------------- 1 | import main from './main'; 2 | import tabsSync from './tabs-sync'; 3 | 4 | main.init(); 5 | tabsSync.init(); 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "node" 3 | jobs: 4 | include: 5 | - stage: lint 6 | script: yarn lint 7 | - stage: test 8 | script: yarn test 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "webextensions": true 6 | }, 7 | "extends": [ 8 | "airbnb-base" 9 | ], 10 | "rules": {}, 11 | "globals": { 12 | "jest": true, 13 | "describe": true, 14 | "beforeEach": true, 15 | "test": true, 16 | "expect": true, 17 | "tm": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/default-popup/default-popup.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | overflow-x: hidden; 4 | } 5 | 6 | .icon-section-header { 7 | background-image: url(../../assets/icons/tabmarks.svg); 8 | background-size: contain; 9 | } 10 | 11 | .panel-list-item { 12 | white-space: nowrap; 13 | } 14 | 15 | .tabmarks-create-empty-warning { 16 | color: #d92015; 17 | } 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | 5 | const inputs = { 6 | background: 'src/background/index.js', 7 | defaultPopup: 'src/default-popup/index.js', 8 | options: 'src/options/index.js', 9 | }; 10 | 11 | const outputDir = path.resolve(__dirname, 'dist'); 12 | 13 | export default Object.keys(inputs).map(name => ({ 14 | name, 15 | input: inputs[name], 16 | output: { 17 | file: path.resolve(outputDir, inputs[name]), 18 | format: 'iife', 19 | }, 20 | plugins: [ 21 | resolve(), 22 | commonjs(), 23 | ], 24 | })); 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0beta4 4 | 5 | * Fix issue with blank popups 6 | 7 | ## 1.0.0beta3 8 | 9 | * Use Rollup.js for bundling 10 | 11 | ## 1.0.0beta2 12 | 13 | * More robust tabs to bookmarks sync 14 | * Tab Group add-on import 15 | * Improved preferences styling 16 | 17 | ## 1.0.0beta1 18 | 19 | Initial version including the following features: 20 | 21 | * Create empty group 22 | * Save tabs of current window as new group 23 | * Switch between groups 24 | * Close the currently open group 25 | * Changes made to tabs are persisted (tab created, url/title changed, tab closed, tab moved within window, tab moved between windows) 26 | * Option panel to change the name of the root folder 27 | -------------------------------------------------------------------------------- /src/options/options.css: -------------------------------------------------------------------------------- 1 | .options-section { 2 | padding: 0; 3 | } 4 | 5 | .options-item { 6 | display: flex; 7 | align-items: start; 8 | margin: 0; 9 | border-top: 1px solid #c1c1c1; 10 | } 11 | 12 | .options-label { 13 | flex: 1; 14 | padding: 6px; 15 | text-align: left; 16 | } 17 | 18 | .options-content { 19 | flex: 2; 20 | display: flex; 21 | padding: 6px; 22 | } 23 | 24 | .options-content-field { 25 | flex: auto; 26 | } 27 | 28 | .options-content-field > * { 29 | width: 100%; 30 | } 31 | 32 | .options-content-actions { 33 | flex: none; 34 | padding-left: 6px; 35 | } 36 | 37 | .spinner { 38 | width: 1em; 39 | height: 1em; 40 | margin-right: 3px; 41 | background-image: url(../../assets/icons/tabmarks.svg); 42 | background-size: contain; 43 | animation: spin 3000ms linear infinite; 44 | } 45 | 46 | @keyframes spin { 47 | from { 48 | transform: rotate(0deg); 49 | } 50 | to { 51 | transform: rotate(360deg); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/ui.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from './bookmarks'; 2 | import groupsHelper from './groups'; 3 | import tabsHelper from './tabs'; 4 | 5 | export default { 6 | 7 | updateWindowBrowserActions(windowId, groupId) { 8 | return Promise.all([bookmarksHelper.getFolder(groupId), tabsHelper.getOfWindow(windowId, {})]) 9 | .then(([folder, tabs]) => 10 | tabs.forEach(tab => this.updateTabBrowserActionForFolder(tab, folder))); 11 | }, 12 | 13 | updateTabBrowserAction(tab) { 14 | return groupsHelper.getSelectedGroupFolder(tab.windowId) 15 | .then(folder => this.updateTabBrowserActionForFolder(tab, folder)); 16 | }, 17 | 18 | updateTabBrowserActionForFolder(tab, folder) { 19 | browser.browserAction.setBadgeBackgroundColor({ color: '#666' }); 20 | browser.browserAction.setTitle({ 21 | title: folder ? `Tabmarks (${folder.title})` : 'Tabmarks', 22 | tabId: tab.id, 23 | }); 24 | browser.browserAction.setBadgeText({ 25 | text: folder ? folder.title : '', 26 | tabId: tab.id, 27 | }); 28 | }, 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "applications": { 3 | "gecko": { 4 | "id": "tabmarks@bitgarten.ch", 5 | "strict_min_version": "52.0" 6 | } 7 | }, 8 | "manifest_version": 2, 9 | "name": "Tabmarks", 10 | "version": "1.0.0beta4", 11 | "description": "Web Extension for handling groups of tabs persisted as bookmarks", 12 | "homepage_url": "https://github.com/hupf/tabmarks", 13 | "author": "Mathis Hofer", 14 | "icons": { 15 | "48": "assets/icons/tabmarks.svg" 16 | }, 17 | "permissions": [ 18 | "bookmarks", 19 | "storage", 20 | "tabs" 21 | ], 22 | "background": { 23 | "scripts": [ 24 | "src/background/index.js" 25 | ] 26 | }, 27 | "browser_action": { 28 | "browser_style": true, 29 | "default_icon": "assets/icons/tabmarks.svg", 30 | "default_title": "Tabmarks", 31 | "default_popup": "src/default-popup/default-popup.html" 32 | }, 33 | "options_ui": { 34 | "browser_style": true, 35 | "page": "src/options/options.html" 36 | }, 37 | "web_accessible_resources": [ 38 | "assets/icons/tabmarks.svg" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mathis Hofer 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tabmarks 2 | 3 | Web Extension for handling groups of tabs persisted as bookmarks. 4 | 5 | Available on AMO here: https://addons.mozilla.org/firefox/addon/tabmarks/ 6 | 7 | [![Build Status](https://travis-ci.org/hupf/tabmarks.svg?branch=master)](https://travis-ci.org/hupf/tabmarks) 8 | 9 | 10 | ## Known issues 11 | 12 | * Switching to a group loads all tabs (which is slow and clutters browser history), see #9 13 | * No ability to rename, move or delete groups without browser restart, see #6 14 | * No i18n support (currently English only) 15 | * Only tested with Firefox 16 | 17 | 18 | ## Development (for Firefox) 19 | 20 | Install dependencies: 21 | 22 | yarn install 23 | 24 | Open Firefox and load the extension temporarily in the browser: 25 | 26 | yarn start 27 | 28 | Linting: 29 | 30 | yarn lint 31 | 32 | Testing: 33 | 34 | yarn test 35 | 36 | Creating a ZIP file (will be put in `web-ext-artifacts/`): 37 | 38 | yarn build 39 | 40 | 41 | ## Author 42 | 43 | Mathis Hofer (thanks to [Puzzle ITC](https://puzzle.ch) for letting me start this project) 44 | 45 | 46 | ## License 47 | 48 | Distributed under the [MIT License](LICENSE). 49 | -------------------------------------------------------------------------------- /src/options/__snapshots__/options.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`options #import displays error on complete import failure 1`] = ` 4 | Array [ 5 | Array [ 6 | "{ \\"foo\\": \\"bar\\" }", 7 | [Function], 8 | ], 9 | ] 10 | `; 11 | 12 | exports[`options #import displays error on complete import failure 2`] = ` 13 | Array [ 14 | Array [ 15 | "An error occurred: Bad", 16 | ], 17 | ] 18 | `; 19 | 20 | exports[`options #import imports JSON data and displays failed folder/bookmark creations 1`] = ` 21 | Array [ 22 | Array [ 23 | "{ \\"foo\\": \\"bar\\" }", 24 | [Function], 25 | ], 26 | ] 27 | `; 28 | 29 | exports[`options #import imports JSON data and displays failed folder/bookmark creations 2`] = ` 30 | Array [ 31 | Array [ 32 | "Import finished. (failed to create: folder Group A, bookmark https://developer.mozilla.org/en-US/, bookmark https://github.com/)", 33 | ], 34 | ] 35 | `; 36 | 37 | exports[`options #import imports JSON data and displays success message 1`] = ` 38 | Array [ 39 | Array [ 40 | "{ \\"foo\\": \\"bar\\" }", 41 | [Function], 42 | ], 43 | ] 44 | `; 45 | 46 | exports[`options #import imports JSON data and displays success message 2`] = ` 47 | Array [ 48 | Array [ 49 | "Import finished.", 50 | ], 51 | ] 52 | `; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tabmarks", 3 | "version": "1.0.0-beta.4", 4 | "description": "Web Extension for handling groups of tabs persisted as bookmarks", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "clean": "rm -rf dist", 10 | "start": "npm-run-all clean build:assets --parallel watch:rollup watch:web-ext", 11 | "watch:rollup": "rollup -c -w", 12 | "watch:web-ext": "web-ext run --source-dir=dist --keep-profile-changes -p /Users/hupf/Library/Application\\ Support/Firefox/Profiles/mpxk0czw.tabmarks-dev -f /Applications/FirefoxNightly.app/Contents/MacOS/firefox", 13 | "lint": "eslint .", 14 | "build": "npm-run-all clean build:rollup build:assets build:web-ext", 15 | "build:rollup": "rollup -c", 16 | "build:assets": "copy {*.md,LICENSE,manifest.json,assets/**/*,src/**/*.html,src/**/*.css} dist", 17 | "build:web-ext": "web-ext build --source-dir=dist --ignore-files=package.json yarn.lock \"**/*.test.js\" --overwrite-dest" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/hupf/tabmarks.git" 22 | }, 23 | "author": "Mathis Hofer", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/hupf/tabmarks/issues" 27 | }, 28 | "homepage": "https://github.com/hupf/tabmarks#readme", 29 | "dependencies": { 30 | "copy": "^0.3.1", 31 | "rollup": "^0.50.0", 32 | "rollup-plugin-commonjs": "^8.2.1", 33 | "rollup-plugin-node-resolve": "^3.0.0", 34 | "rxjs": "^5.4.3" 35 | }, 36 | "devDependencies": { 37 | "babel-preset-es2015": "^6.24.1", 38 | "eslint": "^4.7.2", 39 | "eslint-config-airbnb-base": "^11.3.2", 40 | "eslint-plugin-import": "^2.7.0", 41 | "jest": "^21.2.0", 42 | "npm-run-all": "^4.1.1", 43 | "web-ext": "^2.0.0" 44 | }, 45 | "jest": { 46 | "clearMocks": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/helpers/__snapshots__/import.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`importHelper #importTabGroupsJson creates bookmarks from JSON data 1`] = ` 4 | Array [ 5 | Array [ 6 | "Group A", 7 | ], 8 | Array [ 9 | "Group B", 10 | ], 11 | ] 12 | `; 13 | 14 | exports[`importHelper #importTabGroupsJson creates bookmarks from JSON data 2`] = ` 15 | Array [ 16 | Array [ 17 | "MDN Web Docs", 18 | "https://developer.mozilla.org/en-US/", 19 | 1, 20 | ], 21 | Array [ 22 | "A better, faster, private browser for today | Firefox", 23 | "https://www.mozilla.org/en-US/firefox/?utm_medium=referral&utm_source=getfirefox-com", 24 | 1, 25 | ], 26 | Array [ 27 | "The world's leading software development platform · GitHub", 28 | "https://github.com/", 29 | 2, 30 | ], 31 | ] 32 | `; 33 | 34 | exports[`importHelper #importTabGroupsJson creation failures errorCallback is called for each folder or bookmark creation failure 1`] = ` 35 | Array [ 36 | Array [ 37 | "Group A", 38 | ], 39 | Array [ 40 | "Group B", 41 | ], 42 | ] 43 | `; 44 | 45 | exports[`importHelper #importTabGroupsJson creation failures errorCallback is called for each folder or bookmark creation failure 2`] = ` 46 | Array [ 47 | Array [ 48 | "MDN Web Docs", 49 | "https://developer.mozilla.org/en-US/", 50 | 1, 51 | ], 52 | Array [ 53 | "A better, faster, private browser for today | Firefox", 54 | "https://www.mozilla.org/en-US/firefox/?utm_medium=referral&utm_source=getfirefox-com", 55 | 1, 56 | ], 57 | ] 58 | `; 59 | 60 | exports[`importHelper #importTabGroupsJson creation failures errorCallback is called for each folder or bookmark creation failure 3`] = ` 61 | Array [ 62 | Array [ 63 | Object { 64 | "error": "Bookmark creation failed", 65 | "name": "https://developer.mozilla.org/en-US/", 66 | "type": "bookmark", 67 | }, 68 | ], 69 | Array [ 70 | Object { 71 | "error": "Folder creation failed", 72 | "name": "Group B", 73 | "type": "folder", 74 | }, 75 | ], 76 | ] 77 | `; 78 | -------------------------------------------------------------------------------- /src/options/options.test.js: -------------------------------------------------------------------------------- 1 | import options from './options'; 2 | import importHelper from '../helpers/import'; 3 | 4 | jest.mock('../helpers/import', () => ({ 5 | importTabGroupsJson: jest.fn().mockImplementation(() => Promise.resolve()), 6 | })); 7 | 8 | describe('options', () => { 9 | describe('#import', () => { 10 | beforeEach(() => { 11 | options.showImportProgress = jest.fn(); 12 | options.showImportMessage = jest.fn(); 13 | delete options.importField; 14 | options.importField = { value: '{ "foo": "bar" }' }; 15 | options.port = { postMessage: jest.fn() }; 16 | }); 17 | 18 | test('imports JSON data and displays success message', () => 19 | options.import().then(() => { 20 | expect(importHelper.importTabGroupsJson.mock.calls).toMatchSnapshot(); 21 | expect(options.showImportProgress).toBeCalled(); 22 | expect(options.showImportMessage.mock.calls).toMatchSnapshot(); 23 | })); 24 | 25 | test('imports JSON data and displays failed folder/bookmark creations', () => { 26 | importHelper.importTabGroupsJson = jest.fn().mockImplementation((json, errorCallback) => { 27 | errorCallback({ type: 'folder', name: 'Group A', error: 'Folder creation failed' }); 28 | errorCallback({ type: 'bookmark', name: 'https://developer.mozilla.org/en-US/', error: 'Bookmark creation failed' }); 29 | errorCallback({ type: 'bookmark', name: 'https://github.com/', error: 'Bookmark creation failed' }); 30 | return Promise.resolve(); 31 | }); 32 | return options.import().then(() => { 33 | expect(importHelper.importTabGroupsJson.mock.calls).toMatchSnapshot(); 34 | expect(options.showImportProgress).toBeCalled(); 35 | expect(options.showImportMessage.mock.calls).toMatchSnapshot(); 36 | }); 37 | }); 38 | 39 | test('displays error on complete import failure', () => { 40 | importHelper.importTabGroupsJson = jest.fn().mockImplementation(() => Promise.reject('Bad')); 41 | return options.import().then(() => { 42 | expect(importHelper.importTabGroupsJson.mock.calls).toMatchSnapshot(); 43 | expect(options.showImportProgress).toBeCalled(); 44 | expect(options.showImportMessage.mock.calls).toMatchSnapshot(); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/helpers/groups.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from './bookmarks'; 2 | 3 | export default { 4 | selectedGroupIds: null, 5 | 6 | getAll() { 7 | return bookmarksHelper.getRootFolder() 8 | .then(rootFolder => rootFolder && browser.bookmarks.getChildren(rootFolder.id)) 9 | .then((groupFolders) => { 10 | if (groupFolders) { 11 | return groupFolders 12 | .filter(f => !f.url) 13 | .map(f => ({ id: f.id, name: f.title })); 14 | } 15 | return null; 16 | }); 17 | }, 18 | 19 | getSelectedGroupId(windowId) { 20 | return this.getSelectedGroupIds() 21 | .then(groupIds => Object.prototype.hasOwnProperty.call(groupIds, windowId) && 22 | groupIds[windowId]); 23 | }, 24 | 25 | saveSelectedGroupId(windowId, groupId) { 26 | return this.getSelectedGroupIds().then((groupIds) => { 27 | this.selectedGroupIds = Object.assign({}, groupIds); 28 | this.selectedGroupIds[windowId] = groupId; 29 | return browser.storage.local.set({ selectedGroupIds: this.selectedGroupIds }); 30 | }); 31 | }, 32 | 33 | getSelectedGroupIds() { 34 | if (this.selectedGroupIds) { 35 | return Promise.resolve(this.selectedGroupIds); 36 | } 37 | return browser.storage.local.get('selectedGroupIds') 38 | .then((result) => { 39 | this.selectedGroupIds = result.selectedGroupIds; 40 | return result.selectedGroupIds || {}; 41 | }); 42 | }, 43 | 44 | getSelectedGroupFolder(windowId) { 45 | return this.getSelectedGroupId(windowId) 46 | .then(groupId => bookmarksHelper.getFolder(groupId)); 47 | }, 48 | 49 | // TODO: test & use instead of OnWindowRemoved after browser start 50 | cleanupSelectedGroupIds() { 51 | Promise.all([ 52 | browser.windows.getAll({ windowTypes: ['normal'] }), 53 | this.getSelectedGroupIds(), 54 | ]).then(([windows, groupIds]) => { 55 | const selectedGroupIds = Object.assign({}, groupIds); 56 | const usedWindowIds = windows.map(w => w.id); 57 | const storedWindowIds = Object.keys(groupIds); 58 | 59 | storedWindowIds.forEach((id) => { 60 | if (!usedWindowIds.contains(id)) { 61 | delete selectedGroupIds[id]; 62 | } 63 | }); 64 | 65 | return browser.storage.local.set({ selectedGroupIds }); 66 | }); 67 | }, 68 | 69 | }; 70 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 |
16 | 18 |
19 |
20 | 22 | 24 | 26 |
27 |
28 |
29 | 30 |
31 | 32 |
33 |
34 | 37 | To import the settings from the Tab Groups Add-On, go to it's preferences panel, choose "Create Backup File" and paste the file's content above. 38 |
39 |
40 | 41 |
42 |
43 | 46 | 52 |
53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/default-popup/default-popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
Tabmarks
14 |
15 |
16 |
17 |
18 | 23 | 28 | 33 | 38 |
39 |
40 | 41 | 48 | 49 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /src/helpers/import.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from './bookmarks'; 2 | 3 | export default { 4 | 5 | importTabGroupsJson(json, errorCallback) { 6 | let data; 7 | try { 8 | data = JSON.parse(json); 9 | if (Array.isArray(data.windows)) { 10 | return data.windows.reduce((result, windowData) => 11 | result.then(() => this.importWindows(windowData, errorCallback)), 12 | Promise.resolve()); 13 | } 14 | return Promise.reject('Invalid JSON structure'); 15 | } catch (e) { 16 | return Promise.reject('Unable to parse JSON', e); 17 | } 18 | }, 19 | 20 | importWindows(data, errorCallback) { 21 | const groups = this.getGroupsData(data); 22 | const tabs = this.getTabsData(data); 23 | 24 | if (groups) { 25 | groups.forEach((group, i) => { 26 | groups[i].tabs = tabs.filter(t => t.groupId === group.id); 27 | }); 28 | return this.importGroups(groups, errorCallback); 29 | } 30 | 31 | return Promise.resolve(); 32 | }, 33 | 34 | importGroups(groups, errorCallback) { 35 | return groups.reduce((result, group) => 36 | result.then(() => bookmarksHelper.createFolder(group.title) 37 | .then(folder => this.importTabs(group.tabs, folder, errorCallback))) 38 | .catch((error) => { 39 | if (errorCallback) { 40 | errorCallback({ type: 'folder', name: group.title, error }); 41 | } 42 | }), 43 | Promise.resolve()); 44 | }, 45 | 46 | importTabs(tabs, folder, errorCallback) { 47 | return tabs.reduce((result, tab) => 48 | result.then(() => bookmarksHelper.create(tab.title, tab.url, folder.id)) 49 | .catch((error) => { 50 | if (errorCallback) { 51 | errorCallback({ type: 'bookmark', name: tab.url, error }); 52 | } 53 | }), 54 | Promise.resolve()); 55 | }, 56 | 57 | getGroupsData(data) { 58 | if (data && data.extData && data.extData['tabview-group']) { 59 | try { 60 | const groups = JSON.parse(data.extData['tabview-group']); 61 | return Object.keys(groups).map(id => ({ id: parseInt(id, 10), title: groups[id].title })); 62 | } catch (e) { 63 | return null; 64 | } 65 | } 66 | return null; 67 | }, 68 | 69 | getTabsData(data) { 70 | if (Array.isArray(data.tabs)) { 71 | return data.tabs 72 | .map(this.getTabData) 73 | .filter(t => t && t.url && t.url.indexOf('about:') !== 0); 74 | } 75 | return []; 76 | }, 77 | 78 | getTabData(data) { 79 | if (Array.isArray(data.entries) && data.entries.length > 0) { 80 | const entry = data.entries[0]; 81 | if (data && data.extData && data.extData['tabview-tab']) { 82 | try { 83 | const groupData = JSON.parse(data.extData['tabview-tab']); 84 | if (groupData && groupData.groupID != null) { 85 | return { 86 | title: entry.title, 87 | url: entry.url, 88 | groupId: parseInt(groupData.groupID, 10), 89 | }; 90 | } 91 | } catch (e) { 92 | return null; 93 | } 94 | } 95 | } 96 | return null; 97 | }, 98 | 99 | }; 100 | -------------------------------------------------------------------------------- /src/helpers/tabs.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from './bookmarks'; 2 | import tabsSync from '../background/tabs-sync'; 3 | 4 | export default { 5 | 6 | get(tabId) { 7 | return browser.tabs.get(tabId); 8 | }, 9 | 10 | getOfWindow(windowId, filter = { pinned: false }) { 11 | return new Promise((resolve) => { 12 | browser.tabs.query(Object.assign({ windowId }, filter), resolve); 13 | }); 14 | }, 15 | 16 | getRelevantOfWindow(windowId) { 17 | return this.getOfWindow(windowId).then(tabs => tabs.filter(t => t.url.indexOf('about:') !== 0)); 18 | }, 19 | 20 | transformIndex(indexOrIndices, windowId) { 21 | // The tab.index contains pinned and priviledged tabs, 22 | // exclude them to be able to compare with bookmark indices 23 | return this.getOfWindow(windowId, {}) 24 | .then(tabs => tabs.filter(t => t.pinned || t.url.indexOf('about:') === 0)) 25 | .then(ignoredTabs => ignoredTabs.map(t => t.index)) 26 | .then((ignoredIndices) => { 27 | if (Array.isArray(indexOrIndices)) { 28 | return indexOrIndices.map(i => this.adjustIndexForIgnored(i, ignoredIndices)); 29 | } 30 | return this.adjustIndexForIgnored(indexOrIndices, ignoredIndices); 31 | }); 32 | }, 33 | 34 | adjustIndexForIgnored(index, ignoredIndices) { 35 | if (ignoredIndices.includes(index)) return null; 36 | let offset = ignoredIndices.findIndex(i => i > index); 37 | if (offset === -1) { 38 | offset = ignoredIndices.length; 39 | } 40 | return index - offset; 41 | }, 42 | 43 | openGroup(windowId, groupId) { 44 | return this.getOfWindow(windowId) 45 | .then(tabs => tabs.map(t => t.id)) 46 | .then(previousTabIds => 47 | bookmarksHelper.getChildren(groupId) 48 | .then(bookmarks => this.withTabSyncDisabled(() => { 49 | let promise; 50 | if (bookmarks.length === 0) { 51 | // For empty groups, make sure at least one tab is open, 52 | // to not accidentially close the window 53 | promise = this.open(null, true); 54 | } 55 | promise = Promise.all(bookmarks.map((bookmark, i) => this.open(bookmark, i === 0))); 56 | return promise.then(() => this.close(previousTabIds), () => this.close(previousTabIds)); 57 | }))); 58 | }, 59 | 60 | openEmptyGroup(windowId) { 61 | return this.getOfWindow(windowId) 62 | .then(tabs => tabs.map(t => t.id)) 63 | .then(previousTabIds => this.withTabSyncDisabled(() => 64 | this.open(null, true) 65 | .then(() => this.close(previousTabIds)))); 66 | }, 67 | 68 | open(bookmark, active) { 69 | return browser.tabs.create({ 70 | url: bookmark ? bookmark.url : 'about:blank', 71 | active, 72 | }); 73 | }, 74 | 75 | close(tabIds) { 76 | return browser.tabs.remove(tabIds); 77 | }, 78 | 79 | withTabSyncDisabled(promiseCallback) { 80 | tabsSync.disabled = true; 81 | return promiseCallback().then( 82 | (result) => { 83 | setTimeout(() => { 84 | tabsSync.disabled = false; 85 | }); 86 | return result; 87 | }, 88 | (error) => { 89 | setTimeout(() => { 90 | tabsSync.disabled = false; 91 | }); 92 | return Promise.reject(error); 93 | }); 94 | }, 95 | 96 | }; 97 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from '../helpers/bookmarks'; 2 | import importHelper from '../helpers/import'; 3 | 4 | export default { 5 | 6 | rootFolderName: null, 7 | 8 | init() { 9 | document.addEventListener('click', this.onClick.bind(this)); 10 | document.addEventListener('submit', this.onSubmit.bind(this)); 11 | 12 | this.port = browser.runtime.connect({ name: 'options' }); 13 | this.port.onMessage.addListener(this.onMessage.bind(this)); 14 | }, 15 | 16 | onClick(e) { 17 | if (e.target.id === 'nameEditButton') { 18 | this.editName(true); 19 | this.nameInput.focus(); 20 | } else if (e.target.id === 'nameCancelButton') { 21 | this.editName(false); 22 | this.nameInput.value = this.rootFolderName; 23 | } else if (e.target.id === 'importMessageButton') { 24 | this.showImportContent(); 25 | } 26 | }, 27 | 28 | onSubmit(e) { 29 | e.preventDefault(); 30 | 31 | if (e.target.id === 'nameForm') { 32 | this.saveRootFolderName(); 33 | this.editName(false); 34 | } else if (e.target.id === 'importForm') { 35 | this.import(); 36 | } 37 | }, 38 | 39 | onMessage(message) { 40 | switch (message.message) { 41 | case 'updateRootFolderName': 42 | this.updateRootFolderName(message.rootFolderName); 43 | break; 44 | default: 45 | // Unknown message 46 | } 47 | }, 48 | 49 | updateRootFolderName(rootFolderName) { 50 | this.rootFolderName = rootFolderName; 51 | this.nameInput.value = rootFolderName; 52 | this.editName(false); 53 | }, 54 | 55 | saveRootFolderName() { 56 | const name = this.nameInput.value; 57 | if (name.trim() === this.rootFolderName) { 58 | this.nameInput.value = this.rootFolderName; 59 | return; 60 | } 61 | bookmarksHelper.renameRootFolder(name); 62 | }, 63 | 64 | editName(enabled) { 65 | this.nameInput.disabled = !enabled; 66 | this.nameEditButton.style.display = enabled ? 'none' : ''; 67 | this.nameCancelButton.style.display = enabled ? '' : 'none'; 68 | this.nameSaveButton.style.display = enabled ? '' : 'none'; 69 | }, 70 | 71 | import() { 72 | this.showImportProgress(); 73 | const errors = []; 74 | return importHelper.importTabGroupsJson(this.importField.value, error => errors.push(error)) 75 | .then(() => { 76 | this.importField.value = ''; 77 | 78 | let message = 'Import finished.'; 79 | if (errors.length > 0) { 80 | message += ` (failed to create: ${errors.map(e => `${e.type} ${e.name}`).join(', ')})`; 81 | } 82 | this.showImportMessage(message); 83 | 84 | this.port.postMessage({ message: 'refreshGroups' }); 85 | }, (error) => { 86 | this.showImportMessage(`An error occurred: ${error}`); 87 | }); 88 | }, 89 | 90 | showImportContent() { 91 | this.importContent.style.display = ''; 92 | this.importProgress.style.display = 'none'; 93 | this.importMessage.style.display = 'none'; 94 | }, 95 | 96 | showImportProgress() { 97 | this.importContent.style.display = 'none'; 98 | this.importProgress.style.display = ''; 99 | this.importMessage.style.display = 'none'; 100 | }, 101 | 102 | showImportMessage(message) { 103 | this.importContent.style.display = 'none'; 104 | this.importProgress.style.display = 'none'; 105 | this.importMessage.querySelector('.text').textContent = message; 106 | this.importMessage.style.display = ''; 107 | }, 108 | 109 | get nameInput() { 110 | return document.getElementById('rootFolderName'); 111 | }, 112 | 113 | get nameEditButton() { 114 | return document.getElementById('nameEditButton'); 115 | }, 116 | 117 | get nameCancelButton() { 118 | return document.getElementById('nameCancelButton'); 119 | }, 120 | 121 | get nameSaveButton() { 122 | return document.getElementById('nameSaveButton'); 123 | }, 124 | 125 | get importButton() { 126 | return document.getElementById('importButton'); 127 | }, 128 | 129 | get importField() { 130 | return document.getElementById('importField'); 131 | }, 132 | 133 | get importContent() { 134 | return document.getElementById('importContent'); 135 | }, 136 | 137 | get importProgress() { 138 | return document.getElementById('importProgress'); 139 | }, 140 | 141 | get importMessage() { 142 | return document.getElementById('importMessage'); 143 | }, 144 | 145 | }; 146 | -------------------------------------------------------------------------------- /src/helpers/import.test.js: -------------------------------------------------------------------------------- 1 | import importHelper from './import'; 2 | import bookmarksHelper from './bookmarks'; 3 | 4 | jest.mock('./bookmarks', () => ({ 5 | createFolder: jest.fn().mockImplementation((title) => { 6 | if (title === 'Group A') { 7 | return Promise.resolve({ id: 1 }); 8 | } else if (title === 'Group B') { 9 | return Promise.resolve({ id: 2 }); 10 | } 11 | return Promise.reject('Unexpected folder title'); 12 | }), 13 | create: jest.fn().mockImplementation(() => Promise.resolve()), 14 | })); 15 | 16 | const json = '{"version":["tabGroups",1],"session":{"lastUpdate":1503346557406,"startTime":1503346353157,"recentCrashes":0},"windows":[{"tabs":[{"entries":[{"url":"about:addons","title":"Add-ons Manager","charset":"","ID":2,"persist":true}],"lastAccessed":1503346438393,"hidden":false,"attributes":{},"extData":{"tabview-tab":"{\\"groupID\\":1}"},"index":1,"image":"chrome://mozapps/skin/extensions/extensionGeneric-16.png"},{"entries":[{"url":"https://developer.mozilla.org/en-US/","title":"MDN Web Docs","charset":"UTF-8","ID":1,"persist":true}],"lastAccessed":1503346449124,"hidden":false,"attributes":{},"extData":{"tabview-tab":"{\\"groupID\\":1}"},"index":1,"image":"https://developer.cdn.mozilla.net/static/img/favicon32.e1ca6d9bb933.png"},{"entries":[{"url":"https://www.mozilla.org/en-US/firefox/?utm_medium=referral&utm_source=getfirefox-com","title":"A better, faster, private browser for today | Firefox","charset":"UTF-8","ID":1,"persist":true}],"lastAccessed":1503346523593,"hidden":false,"attributes":{},"extData":{"tabview-tab":"{\\"groupID\\":1}"},"index":1,"image":"https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico"},{"entries":[{"url":"https://github.com/","title":"The world\'s leading software development platform · GitHub","charset":"UTF-8","ID":1,"persist":true}],"lastAccessed":1503346521778,"hidden":true,"attributes":{},"extData":{"tabview-tab":"{\\"groupID\\":3,\\"active\\":true}"},"index":1,"image":"https://assets-cdn.github.com/favicon.ico"},{"entries":[{"url":"about:tabgroups#session","title":"Tab Groups Options","charset":"","ID":9,"persist":true}],"lastAccessed":1503346557405,"hidden":false,"attributes":{},"extData":{"tabview-tab":"{\\"groupID\\":1}"},"index":1}],"extData":{"tabview-group":"{\\"1\\":{\\"bounds\\":{\\"left\\":15,\\"top\\":5,\\"width\\":1237.285,\\"height\\":658.996},\\"slot\\":1,\\"userSize\\":null,\\"stackTabs\\":true,\\"showThumbs\\":true,\\"showUrls\\":true,\\"tileIcons\\":true,\\"catchOnce\\":true,\\"catchRules\\":\\"\\",\\"title\\":\\"Group A\\",\\"id\\":1},\\"3\\":{\\"bounds\\":{\\"left\\":20,\\"top\\":20,\\"width\\":250,\\"height\\":200},\\"slot\\":2,\\"userSize\\":null,\\"stackTabs\\":true,\\"showThumbs\\":true,\\"showUrls\\":true,\\"tileIcons\\":true,\\"catchOnce\\":true,\\"catchRules\\":\\"\\",\\"title\\":\\"Group B\\",\\"id\\":3}}","tabview-groups":"{\\"nextID\\":4,\\"activeGroupId\\":1,\\"activeGroupName\\":\\"Group A\\",\\"totalNumber\\":2}"}}]}'; 17 | 18 | describe('importHelper', () => { 19 | describe('#importTabGroupsJson', () => { 20 | test('rejects if invalid JSON data', (done) => { 21 | importHelper.importTabGroupsJson('foo').catch((error) => { 22 | expect(error).toEqual('Unable to parse JSON'); 23 | done(); 24 | }); 25 | }); 26 | 27 | test('creates bookmarks from JSON data', () => { 28 | const errorCallback = jest.fn(); 29 | return importHelper.importTabGroupsJson(json, errorCallback).then(() => { 30 | expect(bookmarksHelper.createFolder.mock.calls).toMatchSnapshot(); 31 | expect(bookmarksHelper.create.mock.calls).toMatchSnapshot(); 32 | expect(errorCallback).not.toBeCalled(); 33 | }); 34 | }); 35 | 36 | describe('creation failures', () => { 37 | beforeEach(() => { 38 | bookmarksHelper.createFolder = jest.fn().mockImplementation((title) => { 39 | if (title === 'Group A') { 40 | return Promise.resolve({ id: 1 }); 41 | } else if (title === 'Group B') { 42 | return Promise.reject('Folder creation failed'); 43 | } 44 | return Promise.reject('Unexpected folder title'); 45 | }); 46 | 47 | bookmarksHelper.create = jest.fn().mockImplementation((name) => { 48 | if (name === 'MDN Web Docs') { 49 | return Promise.reject('Bookmark creation failed'); 50 | } 51 | return Promise.resolve(); 52 | }); 53 | }); 54 | 55 | test('errorCallback is called for each folder or bookmark creation failure', () => { 56 | const errorCallback = jest.fn(); 57 | return importHelper.importTabGroupsJson(json, errorCallback).then(() => { 58 | expect(bookmarksHelper.createFolder.mock.calls).toMatchSnapshot(); 59 | expect(bookmarksHelper.create.mock.calls).toMatchSnapshot(); 60 | expect(errorCallback.mock.calls).toMatchSnapshot(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/background/main.js: -------------------------------------------------------------------------------- 1 | import bookmarksHelper from '../helpers/bookmarks'; 2 | import groupsHelper from '../helpers/groups'; 3 | import tabsHelper from '../helpers/tabs'; 4 | import uiHelper from '../helpers/ui'; 5 | 6 | export default { 7 | 8 | defaultPopupPort: null, 9 | optionsPort: null, 10 | 11 | init() { 12 | browser.runtime.onConnect.addListener(this.onConnect.bind(this)); 13 | browser.runtime.onMessage.addListener(this.onMessage.bind(this)); 14 | 15 | browser.windows.onCreated.addListener(w => this.initWindow(w.id)); 16 | 17 | this.initWindows() 18 | .then(() => this.loadGroups()); 19 | }, 20 | 21 | onConnect(port) { 22 | port.onMessage.addListener(this.onMessage.bind(this)); 23 | switch (port.name) { 24 | case 'defaultPopup': 25 | this.defaultPopupPort = port; 26 | this.updateDefaultPopupSelectedGroup(); 27 | this.updateDefaultPopupGroupList(); 28 | break; 29 | case 'options': 30 | this.optionsPort = port; 31 | this.updateOptionsRootFolderName(); 32 | break; 33 | default: 34 | break; 35 | } 36 | port.onDisconnect.addListener(this.onDisconnect.bind(this)); 37 | }, 38 | 39 | onDisconnect(port) { 40 | if (port.name === 'defaultPopup') { 41 | this.defaultPopupPort = null; 42 | } 43 | }, 44 | 45 | onMessage(message) { 46 | switch (message.message) { 47 | case 'createGroup': 48 | this.createGroup(message.windowId, message.groupName, message.saveTabs); 49 | break; 50 | case 'selectGroup': 51 | this.selectGroup(message.windowId, message.groupId); 52 | break; 53 | case 'refreshGroups': 54 | this.loadGroups(); 55 | break; 56 | default: 57 | // Unknown message 58 | } 59 | }, 60 | 61 | initWindows() { 62 | return browser.windows.getAll() 63 | .then(windows => windows.map(w => w.id)) 64 | .then(windowIds => Promise.all(windowIds.map(windowId => 65 | this.initWindow(windowId)))); 66 | }, 67 | 68 | initWindow(windowId) { 69 | return Promise.all([groupsHelper.getSelectedGroupId(windowId), 70 | tabsHelper.getRelevantOfWindow(windowId)]) 71 | .then(([groupId, tabs]) => { 72 | if (!groupId) { return null; } 73 | 74 | if (tabs.length === 0) { 75 | // Browser opens new tab on startup 76 | return this.selectGroup(windowId, groupId); 77 | } 78 | // Browser is set up to open tabs from last session 79 | return this.updateSelectedGroup(windowId, groupId); 80 | }); 81 | }, 82 | 83 | loadGroups() { 84 | return groupsHelper.getAll().then((groups) => { 85 | if (groups) { 86 | this.groups = groups; 87 | this.updateDefaultPopupGroupList(); 88 | } 89 | }); 90 | }, 91 | 92 | createGroup(windowId, name, saveTabs) { 93 | bookmarksHelper.createFolder(name).then((folder) => { 94 | let promise; 95 | if (saveTabs) { 96 | // Create group from currently open tabs 97 | promise = bookmarksHelper.saveTabsOfWindow(windowId, folder); 98 | } else { 99 | // Create empty group with new tab (and close currently open tabs) 100 | promise = tabsHelper.openEmptyGroup(windowId); 101 | } 102 | promise.then(() => this.updateSelectedGroup(windowId, folder.id)); 103 | 104 | this.loadGroups(); 105 | }); 106 | }, 107 | 108 | selectGroup(windowId, groupId = null) { 109 | if (!groupId) { 110 | tabsHelper.openEmptyGroup(windowId) 111 | .then(() => this.updateSelectedGroup(windowId, null)); 112 | return; 113 | } 114 | 115 | tabsHelper.openGroup(windowId, groupId) 116 | .then(() => this.updateSelectedGroup(windowId, groupId)); 117 | }, 118 | 119 | updateSelectedGroup(windowId, groupId) { 120 | groupsHelper.saveSelectedGroupId(windowId, groupId); 121 | uiHelper.updateWindowBrowserActions(windowId, groupId); 122 | }, 123 | 124 | updateDefaultPopupSelectedGroup() { 125 | if (!this.defaultPopupPort) return; 126 | 127 | browser.windows.getCurrent().then(w => w.id) 128 | .then(windowId => groupsHelper.getSelectedGroupId(windowId)) 129 | .then((groupId) => { 130 | this.defaultPopupPort.postMessage({ 131 | message: 'updateSelectedGroup', 132 | groupId, 133 | }); 134 | }); 135 | }, 136 | 137 | 138 | updateDefaultPopupGroupList() { 139 | if (!this.defaultPopupPort) return; 140 | 141 | this.defaultPopupPort.postMessage({ message: 'updateGroupList', groups: this.groups }); 142 | }, 143 | 144 | updateOptionsRootFolderName() { 145 | if (!this.optionsPort) return; 146 | 147 | bookmarksHelper.getRootFolderName().then(rootFolderName => 148 | this.optionsPort.postMessage({ message: 'updateRootFolderName', rootFolderName })); 149 | }, 150 | 151 | }; 152 | -------------------------------------------------------------------------------- /src/helpers/bookmarks.js: -------------------------------------------------------------------------------- 1 | import groupsHelper from './groups'; 2 | import tabsHelper from './tabs'; 3 | 4 | const DEFAULT_ROOT_FOLDER_NAME = 'Tabmarks Groups'; 5 | 6 | export default { 7 | rootFolderName: null, 8 | 9 | getRootFolderName() { 10 | if (this.rootFolderName) { 11 | return Promise.resolve(this.rootFolderName); 12 | } 13 | 14 | return browser.storage.local.get('rootFolderName') 15 | .then(result => result.rootFolderName) 16 | .then((rootFolderName) => { 17 | if (!rootFolderName) { 18 | this.rootFolderName = DEFAULT_ROOT_FOLDER_NAME; 19 | return browser.storage.local.set({ rootFolderName: DEFAULT_ROOT_FOLDER_NAME }) 20 | .then(() => DEFAULT_ROOT_FOLDER_NAME); 21 | } 22 | this.rootFolderName = rootFolderName; 23 | return rootFolderName; 24 | }); 25 | }, 26 | 27 | renameRootFolder(name) { 28 | return this.getRootFolder() 29 | .then(folder => browser.bookmarks.update(folder.id, { title: name })) 30 | .then((folder) => { 31 | this.rootFolderName = name; 32 | browser.storage.local.set({ rootFolderName: name }); 33 | return folder; 34 | }); 35 | }, 36 | 37 | getRootFolder() { 38 | return this.getRootFolderName().then(rootFolderName => 39 | browser.bookmarks.search({ title: rootFolderName }) 40 | .then((result) => { 41 | if (result && result.length) { 42 | return result[0]; 43 | } 44 | return browser.bookmarks.create({ title: rootFolderName }); 45 | })); 46 | }, 47 | 48 | getFolder(folderId) { 49 | if (!folderId) { 50 | return Promise.resolve(null); 51 | } 52 | 53 | return browser.bookmarks.get(folderId) 54 | .then((result) => { 55 | if (result && result.length) { 56 | return result[0]; 57 | } 58 | return null; 59 | }, () => null); 60 | }, 61 | 62 | getChildren(folderId) { 63 | return browser.bookmarks.getChildren(folderId); 64 | }, 65 | 66 | getOfWindow(windowId) { 67 | return groupsHelper.getSelectedGroupId(windowId) 68 | .then((groupId) => { 69 | if (groupId) { 70 | return this.getChildren(groupId); 71 | } 72 | return null; 73 | }); 74 | }, 75 | 76 | createFolder(name) { 77 | return this.getRootFolder() 78 | .then(root => root.id) 79 | .then(parentId => browser.bookmarks.create({ 80 | parentId, 81 | title: name, 82 | })); 83 | }, 84 | 85 | emptyFolder(folderId) { 86 | return this.getChildren(folderId) 87 | .then(children => Promise.all(children.map(c => browser.bookmarks.remove(c.id)))); 88 | }, 89 | 90 | create(name, url, parentId) { 91 | return browser.bookmarks.create({ 92 | parentId, 93 | title: name, 94 | url, 95 | }); 96 | }, 97 | 98 | createFromTab(tab, index) { 99 | return groupsHelper.getSelectedGroupId(tab.windowId) 100 | .then(parentId => 101 | browser.bookmarks.create({ 102 | parentId, 103 | title: tab.title, 104 | url: tab.url, 105 | index, 106 | })); 107 | }, 108 | 109 | updateFromTab(tab, index) { 110 | return this.getOfWindow(tab.windowId) 111 | .then(bookmarks => bookmarks[index]) 112 | .then(bookmark => browser.bookmarks.update(bookmark.id, { 113 | title: tab.title, 114 | url: tab.url, 115 | })); 116 | }, 117 | 118 | removeAtIndex(folderId, index) { 119 | return this.getChildren(folderId) 120 | .then(children => children[index].id) 121 | .then(childId => browser.bookmarks.remove(childId)); 122 | }, 123 | 124 | moveInSelectedGroup(windowId, fromIndex, toIndex) { 125 | if (fromIndex == null || toIndex == null) return Promise.resolve(false); 126 | return groupsHelper.getSelectedGroupId(windowId) 127 | .then(parentId => 128 | parentId && this.getChildren(parentId) 129 | .then(bookmarks => bookmarks[fromIndex]) 130 | .then(bookmark => browser.bookmarks.move(bookmark.id, { parentId, index: toIndex }))); 131 | }, 132 | 133 | saveTabsOfWindow(windowId, folder, excludeTabId = null) { 134 | return tabsHelper.getRelevantOfWindow(windowId) 135 | .then(tabs => tabs.filter(t => t.id !== excludeTabId)) 136 | // Save bookmarks on-by-one due to problems with index 137 | .then(tabs => tabs.reduce((result, tab, index) => 138 | result.then(() => browser.bookmarks.create({ 139 | parentId: folder.id, 140 | title: tab.title, 141 | url: tab.url, 142 | index, 143 | })), Promise.resolve())); 144 | }, 145 | 146 | replaceWithTabsOfWindow(windowId, folder, excludeTabId) { 147 | return this.emptyFolder(folder.id) 148 | .then(() => this.saveTabsOfWindow(windowId, folder, excludeTabId)); 149 | }, 150 | 151 | }; 152 | -------------------------------------------------------------------------------- /src/helpers/tabs.test.js: -------------------------------------------------------------------------------- 1 | import tabsHelper from './tabs'; 2 | import tabsSync from '../background/tabs-sync'; 3 | 4 | jest.mock('../background/tabs-sync', () => ({ 5 | disabled: false, 6 | })); 7 | 8 | describe('tabsHelper', () => { 9 | describe('#get', () => { 10 | beforeEach(() => { 11 | global.browser = { 12 | tabs: { 13 | get: jest.fn().mockImplementation(id => Promise.resolve({ id })), 14 | }, 15 | }; 16 | }); 17 | 18 | test('returns tab with given id', () => 19 | tabsHelper.get(5).then((result) => { 20 | expect(browser.tabs.get).toBeCalledWith(5); 21 | expect(result.id).toEqual(5); 22 | })); 23 | }); 24 | 25 | describe('#getOfWindow', () => { 26 | beforeEach(() => { 27 | global.browser = { 28 | tabs: { 29 | query: jest.fn().mockImplementation((filter, cb) => { 30 | cb([ 31 | { url: 'http://example.org' }, 32 | { url: 'about:blank' }, 33 | ]); 34 | }), 35 | }, 36 | }; 37 | }); 38 | 39 | test('returns tabs with given windowId excluding pinned ones', () => 40 | tabsHelper.getOfWindow(5).then((result) => { 41 | expect(browser.tabs.query.mock.calls[0][0]).toEqual({ windowId: 5, pinned: false }); 42 | expect(result).toEqual([ 43 | { url: 'http://example.org' }, 44 | { url: 'about:blank' }, 45 | ]); 46 | })); 47 | }); 48 | 49 | describe('#getRelevantOfWindow', () => { 50 | beforeEach(() => { 51 | global.browser = { 52 | tabs: { 53 | query: jest.fn().mockImplementation((filter, cb) => { 54 | cb([ 55 | { url: 'http://example.org' }, 56 | { url: 'about:blank' }, 57 | ]); 58 | }), 59 | }, 60 | }; 61 | }); 62 | 63 | test('returns tabs with given windowId excluding pinned ones and about:*', () => 64 | tabsHelper.getRelevantOfWindow(5).then((result) => { 65 | expect(browser.tabs.query.mock.calls[0][0]).toEqual({ windowId: 5, pinned: false }); 66 | expect(result).toEqual([ 67 | { url: 'http://example.org' }, 68 | ]); 69 | })); 70 | }); 71 | 72 | describe('#transformIndex', () => { 73 | beforeEach(() => { 74 | global.browser = { 75 | tabs: { 76 | query: jest.fn().mockImplementation((filter, cb) => { 77 | cb([ 78 | { index: 0, pinned: true, url: 'https://01.example.org' }, 79 | { index: 1, pinned: true, url: 'https://02.example.org' }, 80 | { index: 2, pinned: false, url: 'https://03.example.org' }, 81 | { index: 3, pinned: false, url: 'about:blank' }, 82 | { index: 4, pinned: false, url: 'https://04.example.org' }, 83 | { index: 5, pinned: false, url: 'about:addons' }, 84 | { index: 6, pinned: false, url: 'https://05.example.org' }, 85 | ]); 86 | }), 87 | }, 88 | }; 89 | }); 90 | 91 | test('adjusts single index ignoring pinned and about:* tabs', () => 92 | tabsHelper.transformIndex(6, 1).then((result) => { 93 | expect(result).toEqual(2); 94 | expect(browser.tabs.query.mock.calls[0][0]).toEqual({ windowId: 1 }); 95 | })); 96 | 97 | test('adjusts multiple indices ignoring pinned and about:* tabs', () => 98 | tabsHelper.transformIndex([4, 2, 6], 1).then((result) => { 99 | expect(result).toEqual([1, 0, 2]); 100 | })); 101 | 102 | test('returns null for pinned and about:* tabs', () => 103 | tabsHelper.transformIndex([1, 3], 1).then((result) => { 104 | expect(result).toEqual([null, null]); 105 | })); 106 | }); 107 | 108 | describe('#withTabSyncDisabled', () => { 109 | test('disables sync before calling promise callback', () => { 110 | expect(tabsSync.disabled).toBeFalsy(); 111 | const func = () => { 112 | expect(tabsSync.disabled).toBeTruthy(); 113 | return Promise.resolve('success'); 114 | }; 115 | 116 | return tabsHelper.withTabSyncDisabled(func) 117 | .then((result) => { 118 | expect(result).toEqual('success'); 119 | 120 | return new Promise((resolve) => { 121 | setTimeout(() => { 122 | expect(tabsSync.disabled).toBeFalsy(); 123 | resolve(); 124 | }); 125 | }); 126 | }); 127 | }); 128 | 129 | test('also re-enables sync on error', () => 130 | tabsHelper.withTabSyncDisabled(() => Promise.reject('failure')) 131 | .then(() => {}, (error) => { 132 | expect(error).toEqual('failure'); 133 | 134 | return new Promise((resolve) => { 135 | setTimeout(() => { 136 | expect(tabsSync.disabled).toBeFalsy(); 137 | resolve(); 138 | }); 139 | }); 140 | })); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/default-popup/default-popup.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | port: null, 4 | selectedGroupId: null, 5 | saveTabs: null, 6 | 7 | init() { 8 | document.addEventListener('click', this.onClick.bind(this)); 9 | document.addEventListener('submit', this.onSubmit.bind(this)); 10 | 11 | this.port = browser.runtime.connect({ name: 'defaultPopup' }); 12 | this.port.onMessage.addListener(this.onMessage.bind(this)); 13 | }, 14 | 15 | onClick(e) { 16 | if (e.target.closest('.tabmarks-close-group-link')) { 17 | this.selectGroup(null); 18 | } else if (e.target.closest('.tabmarks-create-from-tabs-link')) { 19 | this.showCreateForm(true); 20 | } else if (e.target.closest('.tabmarks-create-empty-link')) { 21 | this.showCreateForm(); 22 | } else if (e.target.closest('.tabmarks-preferences-link')) { 23 | this.showPreferences(); 24 | } else if (e.target.closest('.tabmarks-group-item')) { 25 | const groupId = e.target.closest('.tabmarks-group-item').dataset.groupId; 26 | this.selectGroup(groupId); 27 | } else if (e.target.classList.contains('tabmarks-create-form-cancel')) { 28 | this.closePopup(); 29 | } else if (e.target.classList.contains('tabmarks-create-form-create')) { 30 | this.createGroup(); 31 | } 32 | }, 33 | 34 | onSubmit(e) { 35 | e.preventDefault(); 36 | if (e.target.classList.contains('tabmarks-create-form')) { 37 | this.createGroup(); 38 | } 39 | }, 40 | 41 | onMessage(message) { 42 | switch (message.message) { 43 | case 'updateSelectedGroup': 44 | this.updateSelectedGroup(message.groupId); 45 | break; 46 | case 'updateGroupList': 47 | this.updateGroupList(message.groups); 48 | break; 49 | default: 50 | // Unknown message 51 | } 52 | }, 53 | 54 | closePopup() { 55 | window.close(); 56 | }, 57 | 58 | showPreferences() { 59 | browser.runtime.openOptionsPage(); 60 | this.closePopup(); 61 | }, 62 | 63 | showCreateForm(saveTabs = false) { 64 | this.saveTabs = saveTabs; 65 | this.createEmptyWarningNode.style.display = saveTabs || this.selectedGroupId ? 'none' : ''; 66 | 67 | this.defaultPopupNode.style.display = 'none'; 68 | this.createFormNode.style.display = ''; 69 | 70 | this.nameInput.value = ''; 71 | this.nameInput.focus(); 72 | }, 73 | 74 | createGroup() { 75 | this.getCurrentWindowId().then((windowId) => { 76 | browser.runtime.sendMessage({ 77 | message: 'createGroup', 78 | windowId, 79 | groupName: this.nameInput.value, 80 | saveTabs: this.saveTabs, 81 | }); 82 | this.closePopup(); 83 | }); 84 | }, 85 | 86 | selectGroup(groupId) { 87 | if (groupId === this.selectedGroupId) return; 88 | 89 | this.getCurrentWindowId().then((windowId) => { 90 | this.port.postMessage({ 91 | message: 'selectGroup', 92 | windowId, 93 | groupId, 94 | }); 95 | this.closePopup(); 96 | }); 97 | }, 98 | 99 | updateSelectedGroup(groupId) { 100 | this.selectedGroupId = groupId; 101 | 102 | this.closeGroupLink.style.display = groupId ? '' : 'none'; 103 | this.createFromTabsLink.style.display = groupId ? 'none' : ''; 104 | this.disableSelectedGroup(); 105 | }, 106 | 107 | updateGroupList(groups) { 108 | const groupList = document.querySelector('#group-list'); 109 | while (groupList.firstChild) { groupList.removeChild(groupList.firstChild); } 110 | 111 | if (!groups || groups.length === 0) { 112 | groupList.appendChild(this.renderGroup('No groups available', undefined, true)); 113 | } else { 114 | groups.map(g => this.renderGroup(g.name, g.id)) 115 | .forEach(i => groupList.appendChild(i)); 116 | } 117 | }, 118 | 119 | renderGroup(name, groupId, disabled) { 120 | const t = document.querySelector('#group'); 121 | const item = t.content.querySelectorAll('.panel-list-item'); 122 | item[0].dataset.groupId = groupId; 123 | item[0].classList[disabled && !item[0].classList.contains('disabled') ? 'add' : 'remove']('disabled'); 124 | const text = t.content.querySelectorAll('.text'); 125 | text[0].textContent = name; 126 | return document.importNode(t.content, true); 127 | }, 128 | 129 | disableSelectedGroup() { 130 | this.groupNodes.forEach(n => n.classList.remove('disabled')); 131 | 132 | if (this.selectedGroupId && this.activeGroupNode) { 133 | this.activeGroupNode.classList.add('disabled'); 134 | } 135 | }, 136 | 137 | getCurrentWindowId() { 138 | return browser.windows.getCurrent().then(w => w.id); 139 | }, 140 | 141 | get groupNodes() { 142 | return document.querySelectorAll('.tabmarks-group-item'); 143 | }, 144 | 145 | get activeGroupNode() { 146 | if (!this.selectedGroupId) return null; 147 | 148 | return document.querySelector(`.tabmarks-group-item[data-group-id="${this.selectedGroupId}"]`); 149 | }, 150 | 151 | get closeGroupLink() { 152 | return document.querySelector('.tabmarks-close-group-link'); 153 | }, 154 | 155 | get createFromTabsLink() { 156 | return document.querySelector('.tabmarks-create-from-tabs-link'); 157 | }, 158 | 159 | get defaultPopupNode() { 160 | return document.querySelector('.tabmarks-default-popup'); 161 | }, 162 | 163 | get createFormNode() { 164 | return document.querySelector('.tabmarks-create-form'); 165 | }, 166 | 167 | get nameInput() { 168 | return document.querySelector('.tabmarks-create-form-group-name'); 169 | }, 170 | 171 | get createEmptyWarningNode() { 172 | return document.querySelector('.tabmarks-create-empty-warning'); 173 | }, 174 | 175 | }; 176 | -------------------------------------------------------------------------------- /src/background/tabs-sync.js: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs/Subject'; 2 | import 'rxjs/add/operator/filter'; 3 | import 'rxjs/add/operator/concatMap'; 4 | 5 | import bookmarksHelper from '../helpers/bookmarks'; 6 | import groupsHelper from '../helpers/groups'; 7 | import tabsHelper from '../helpers/tabs'; 8 | import uiHelper from '../helpers/ui'; 9 | 10 | export default { 11 | disabled: false, 12 | 13 | init() { 14 | // TODO: two update events fire between detach/attach, how to make sure 15 | // attach is no creatin another bookmark? Workaround: ignore onAttached 16 | // this.bindAttached(); 17 | this.bindDetached(); 18 | this.bindCreated(); 19 | this.bindMoved(); 20 | this.bindRemoved(); 21 | this.bindUpdated(); 22 | }, 23 | 24 | bindAttached() { 25 | const attached$ = new Subject(); 26 | browser.tabs.onAttached.addListener((tabId, attachInfo) => { 27 | attached$.next({ tabId, attachInfo }); 28 | }); 29 | attached$ 30 | .filter(() => !this.disabled) 31 | .concatMap(event => this.onAttached(event.tabId, event.attachInfo)) 32 | .subscribe(); 33 | }, 34 | 35 | onAttached(tabId, attachInfo) { 36 | return groupsHelper.getSelectedGroupId(attachInfo.newWindowId).then((groupId) => { 37 | if (!groupId) return false; 38 | 39 | return tabsHelper.transformIndex(attachInfo.newPosition, attachInfo.newWindowId) 40 | .then(newPosition => tabsHelper.get(tabId).then(tab => Promise.all([ 41 | bookmarksHelper.createFromTab(tab, newPosition), 42 | uiHelper.updateTabBrowserAction(tab), 43 | ]))); 44 | }); 45 | }, 46 | 47 | bindDetached() { 48 | const detached$ = new Subject(); 49 | browser.tabs.onDetached.addListener((tabId, detachInfo) => { 50 | detached$.next({ tabId, detachInfo }); 51 | }); 52 | detached$ 53 | .filter(() => !this.disabled) 54 | .concatMap(event => this.onDetached(event.tabId, event.detachInfo)) 55 | .subscribe(); 56 | }, 57 | 58 | onDetached(tabId, detachInfo) { 59 | return groupsHelper.getSelectedGroupId(detachInfo.oldWindowId).then((groupId) => { 60 | if (!groupId) return false; 61 | 62 | return tabsHelper.transformIndex(detachInfo.oldPosition, detachInfo.oldWindowId) 63 | .then(oldPosition => bookmarksHelper.removeAtIndex(groupId, oldPosition)); 64 | }); 65 | }, 66 | 67 | bindCreated() { 68 | const created$ = new Subject(); 69 | browser.tabs.onCreated.addListener((tab) => { 70 | created$.next({ tab }); 71 | }); 72 | created$ 73 | .filter(() => !this.disabled) 74 | .concatMap(event => this.onCreated(event.tab)) 75 | .subscribe(); 76 | }, 77 | 78 | onCreated(tab) { 79 | return uiHelper.updateTabBrowserAction(tab); 80 | }, 81 | 82 | bindMoved() { 83 | const moved$ = new Subject(); 84 | browser.tabs.onMoved.addListener((tabId, moveInfo) => { 85 | moved$.next({ tabId, moveInfo }); 86 | }); 87 | moved$ 88 | .filter(() => !this.disabled) 89 | .concatMap(event => this.onMoved(event.tabId, event.moveInfo)) 90 | .subscribe(); 91 | }, 92 | 93 | onMoved(tabId, moveInfo) { 94 | return tabsHelper.transformIndex([moveInfo.fromIndex, moveInfo.toIndex], moveInfo.windowId) 95 | .then(([fromIndex, toIndex]) => 96 | bookmarksHelper.moveInSelectedGroup(moveInfo.windowId, fromIndex, toIndex)); 97 | }, 98 | 99 | bindRemoved() { 100 | const removed$ = new Subject(); 101 | browser.tabs.onRemoved.addListener((tabId, removeInfo) => { 102 | removed$.next({ tabId, removeInfo }); 103 | }); 104 | removed$ 105 | .filter((tabId, removeInfo) => !this.disabled && !removeInfo.isWindowClosing) 106 | .concatMap(event => this.onRemoved(event.tabId, event.removeInfo)) 107 | .subscribe(); 108 | }, 109 | 110 | onRemoved(tabId, removeInfo) { 111 | return this.replaceAll(removeInfo.windowId, tabId); 112 | }, 113 | 114 | bindUpdated() { 115 | const updated$ = new Subject(); 116 | browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 117 | updated$.next({ tabId, changeInfo, tab }); 118 | }); 119 | updated$ 120 | .filter(() => !this.disabled) 121 | .concatMap(event => this.onUpdated(event.tabId, event.changeInfo, event.tab)) 122 | .subscribe(); 123 | }, 124 | 125 | onUpdated(tabId, changeInfo, tab) { 126 | if (Object.prototype.hasOwnProperty.call(changeInfo, 'url') || 127 | Object.prototype.hasOwnProperty.call(changeInfo, 'title')) { 128 | return this.onUrlChange(tab); 129 | } else if (Object.prototype.hasOwnProperty.call(changeInfo, 'pinned')) { 130 | return this.onPinnedChange(tab); 131 | } 132 | return Promise.resolve(); 133 | }, 134 | 135 | onUrlChange(tab) { 136 | if (tab.url && tab.url.indexOf('about:') === 0) return Promise.resolve(); 137 | 138 | return groupsHelper.getSelectedGroupId(tab.windowId).then((groupId) => { 139 | if (!groupId) return false; 140 | 141 | return Promise.all([tabsHelper.getRelevantOfWindow(tab.windowId), 142 | bookmarksHelper.getOfWindow(tab.windowId)]) 143 | .then(([tabs, bookmarks]) => this.createOrUpdate(tab, tabs, bookmarks)); 144 | }); 145 | }, 146 | 147 | onPinnedChange(tab) { 148 | return groupsHelper.getSelectedGroupId(tab.windowId).then((groupId) => { 149 | if (!groupId) return false; 150 | 151 | if (tab.pinned) { 152 | return this.replaceAll(tab.windowId, tab.id); 153 | } 154 | return tabsHelper.transformIndex(tab.index, tab.windowId) 155 | .then(index => bookmarksHelper.createFromTab(tab, index)); 156 | }); 157 | }, 158 | 159 | createOrUpdate(tab, tabs, bookmarks) { 160 | if (bookmarks == null) return Promise.resolve(); 161 | 162 | return tabsHelper.transformIndex(tab.index, tab.windowId) 163 | .then((index) => { 164 | if (tabs.length > bookmarks.length) { 165 | return bookmarksHelper.createFromTab(tab, index); 166 | } else if (!this.equals(tab, bookmarks[index])) { 167 | return bookmarksHelper.updateFromTab(tab, index); 168 | } 169 | return false; 170 | }); 171 | }, 172 | 173 | replaceAll(windowId, excludeTabId) { 174 | return groupsHelper.getSelectedGroupFolder(windowId) 175 | .then((folder) => { 176 | if (!folder) return false; 177 | 178 | return bookmarksHelper.replaceWithTabsOfWindow(windowId, folder, excludeTabId); 179 | }); 180 | }, 181 | 182 | equals(tab, bookmark) { 183 | return tab.title === bookmark.title && tab.url === bookmark.url; 184 | }, 185 | 186 | }; 187 | -------------------------------------------------------------------------------- /assets/icons/tabmarks.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 38 | 40 | 42 | 46 | 50 | 51 | 53 | 57 | 61 | 62 | 67 | 68 | 69 | 76 | 77 | 87 | 97 | 107 | 108 | 110 | 111 | 113 | image/svg+xml 114 | 116 | 117 | 118 | 119 | 120 | 123 | 127 | 128 | 129 | --------------------------------------------------------------------------------