├── .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 | [](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 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/default-popup/default-popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
40 |
41 |
42 |
47 |
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 |
129 |
--------------------------------------------------------------------------------