├── assets
└── img
│ └── blobbycat
│ ├── LICENSE
│ ├── logo.png
│ ├── icon128.png
│ ├── icon48.png
│ └── browseraction128.png
├── .gitignore
├── src
├── lib
│ ├── types.ts
│ ├── globals.ts
│ ├── brokers.ts
│ ├── utils.ts
│ ├── options.ts
│ ├── tabs_store.ts
│ └── archive.ts
├── app.html
├── background.ts
├── app.ts
├── style
│ ├── modal.scss
│ ├── app.scss
│ └── montserrat.css
└── components
│ ├── group_row.tsx
│ ├── paginator.tsx
│ ├── group.tsx
│ ├── app.tsx
│ └── options.tsx
├── .prettierrc.js
├── .vscode
└── settings.json
├── .editorconfig
├── .nycrc.json
├── tsconfig.json
├── .travis.yml
├── manifest.json
├── tests
├── options.test.ts
├── tabs.test.ts
├── archive.test.ts
└── utils.ts
├── LICENSE
├── .eslintrc.js
├── README.md
├── package.json
├── scripts
└── bundle.ts
└── webpack.config.js
/assets/img/blobbycat/LICENSE:
--------------------------------------------------------------------------------
1 | Images in this directory copyright Kirstin Kajita, all rights reserved.
2 |
--------------------------------------------------------------------------------
/assets/img/blobbycat/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moribellamy/graytabby/HEAD/assets/img/blobbycat/logo.png
--------------------------------------------------------------------------------
/assets/img/blobbycat/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moribellamy/graytabby/HEAD/assets/img/blobbycat/icon128.png
--------------------------------------------------------------------------------
/assets/img/blobbycat/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moribellamy/graytabby/HEAD/assets/img/blobbycat/icon48.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .nyc_output/
3 | coverage/
4 | dist/
5 | node_modules/
6 | pack/
7 |
8 | .DS_Store
9 | mix-manifest.json
10 |
--------------------------------------------------------------------------------
/assets/img/blobbycat/browseraction128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moribellamy/graytabby/HEAD/assets/img/blobbycat/browseraction128.png
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { Tabs as WebextTabs } from 'webextension-polyfill-ts/dist/generated/tabs';
2 |
3 | export type BrowserTab = WebextTabs.Tab;
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'all',
4 | singleQuote: true,
5 | printWidth: 100,
6 | tabWidth: 2,
7 | useTabs: false,
8 | };
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.preferences.quoteStyle": "single",
3 | "eslint.validate": [
4 | "javascript",
5 | "javascriptreact",
6 | "typescript"
7 | ],
8 | "eslint.enable": true
9 | }
10 |
--------------------------------------------------------------------------------
/.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 | trim_trailing_whitespace = true
11 | insert_final_newline = true
12 |
--------------------------------------------------------------------------------
/.nycrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension": [
3 | ".ts",
4 | ".tsx"
5 | ],
6 | "exclude": [
7 | "src/app.ts",
8 | "src/background.ts",
9 | "src/tsxdom.ts"
10 | ],
11 | "reporter": [
12 | "text",
13 | "lcov"
14 | ],
15 | "include": [
16 | "src/**/*.ts",
17 | "src/**/*.tsx"
18 | ],
19 | "all": true,
20 | "cache": false
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "jsxFactory": "h",
5 | "target": "es2017",
6 | "strict": true,
7 | "strictNullChecks": false,
8 | "esModuleInterop": true,
9 | "moduleResolution": "node",
10 | "inlineSourceMap": true,
11 | "lib": [
12 | "es2017",
13 | "dom"
14 | ],
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | GrayTabby
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: bionic
2 | language: node_js
3 | node_js:
4 | - 10.16.0
5 | before_install:
6 | - npm i -g npm@6.10.0
7 | # see https://docs.travis-ci.com/user/build-stages/
8 | jobs:
9 | include:
10 | - stage: test
11 | - name: Test and Upload Coverage
12 | script: npm run test:coveralls
13 | - name: Bundle Extension
14 | script: npx ts-node scripts/bundle.ts
15 | - name: Lint
16 | script: npm run lint
17 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "graytabby",
4 | "version": "20.7.30",
5 | "icons": {
6 | "128": "assets/img/blobbycat/browseraction128.png"
7 | },
8 | "browser_action": {
9 | "default_icon": "assets/img/blobbycat/browseraction128.png",
10 | "default_title": "Archive into GrayTabby"
11 | },
12 | "background": {
13 | "scripts": [
14 | "background.js"
15 | ]
16 | },
17 | "permissions": [
18 | "tabs",
19 | "storage",
20 | "contextMenus"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/src/background.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Background script. See
3 | * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Anatomy_of_a_WebExtension
4 | *
5 | * No "long running logic" is implemented here. This is just the best place to register handlers.
6 | *
7 | * Not included in coverage reports, so don't put non-trivial logic here.
8 | */
9 |
10 | import { bindArchivalHandlers } from './lib/archive';
11 |
12 | bindArchivalHandlers().then(
13 | () => {
14 | console.log('loaded graytabby backend');
15 | },
16 | err => {
17 | console.error('could not load graytabby backend', err);
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/lib/globals.ts:
--------------------------------------------------------------------------------
1 | import { browser } from 'webextension-polyfill-ts';
2 | import { Broker } from './brokers';
3 | import { BrowserTab } from './types';
4 |
5 | class Wrapper {
6 | wrapped: T;
7 |
8 | constructor(init: () => T) {
9 | try {
10 | this.set(init());
11 | } catch (err) {
12 | // Reference error will happen in tests, e.g. `document` and `window`.
13 | }
14 | }
15 |
16 | get(): T {
17 | return this.wrapped;
18 | }
19 |
20 | set(t: T): void {
21 | this.wrapped = t;
22 | }
23 | }
24 |
25 | export const DOCUMENT = new Wrapper(() => document);
26 | export const BROWSER = new Wrapper(() => browser);
27 | export const ARCHIVAL = new Wrapper(() => new Broker('moreTabs'));
28 | export const PAGE_LOAD = new Wrapper(() => new Broker('pageLoad'));
29 |
--------------------------------------------------------------------------------
/src/app.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * GT entry point. Not included in coverage reports, so don't put
3 | * non-trivial logic here.
4 | */
5 |
6 | import { App } from './components/app';
7 | import { DOCUMENT, PAGE_LOAD } from './lib/globals';
8 | import './style/app.scss';
9 |
10 | /**
11 | * The main entry point for GrayTabby.
12 | */
13 | export async function graytabby(): Promise {
14 | const app = DOCUMENT.get().body.appendChild(App());
15 | await app.initialRender;
16 | }
17 |
18 | graytabby().then(
19 | () => {
20 | PAGE_LOAD.get()
21 | .pub()
22 | .catch(() => {
23 | console.log('no listeners for page load');
24 | })
25 | .finally(() => {
26 | console.log('loaded graytabby frontend');
27 | });
28 | },
29 | err => {
30 | console.error(err);
31 | },
32 | );
33 |
--------------------------------------------------------------------------------
/tests/options.test.ts:
--------------------------------------------------------------------------------
1 | import { getOptions, Options, OPTIONS_KEY } from '../src/lib/options';
2 | import { expect } from 'chai';
3 | import { unstubGlobals, stubGlobalsForTesting } from './utils';
4 | import { save } from '../src/lib/utils';
5 |
6 | describe('options', function() {
7 | beforeEach(async function() {
8 | await stubGlobalsForTesting();
9 | });
10 |
11 | afterEach(function() {
12 | unstubGlobals();
13 | });
14 |
15 | it('should load old string format', async function() {
16 | const oldOptions: Options = {
17 | tabLimit: 10000,
18 | archiveDupes: false,
19 | homeGroup: [],
20 | groupsPerPage: 10000,
21 | };
22 | await save(OPTIONS_KEY, JSON.stringify(oldOptions));
23 | const options = await getOptions();
24 | expect(oldOptions).to.deep.equal(options);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/style/modal.scss:
--------------------------------------------------------------------------------
1 | /* The Modal (background). Cribbed from W3Schools. */
2 | .modal {
3 | display: none; /* Hidden by default */
4 | position: fixed; /* Stay in place */
5 | z-index: 1; /* Sit on top */
6 | left: 0;
7 | top: 0;
8 | width: 100%; /* Full width */
9 | height: 100%; /* Full height */
10 | overflow: auto; /* Enable scroll if needed */
11 | background-color: rgb(0, 0, 0); /* Fallback color */
12 | background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
13 |
14 | .content {
15 | background-color: #fefefe;
16 | margin: 10% auto; /* 15% from the top and centered */
17 | padding: 20px;
18 | border: 1px solid #888;
19 | width: 70%; /* Could be more or less, depending on screen size */
20 | }
21 |
22 | // .close {
23 | // color: #aaa;
24 | // float: right;
25 | // font-size: 28px;
26 | // font-weight: bold;
27 |
28 | // :hover :focus {
29 | // color: black;
30 | // text-decoration: none;
31 | // cursor: pointer;
32 | // }
33 | // }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Mori Bellamy
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 |
--------------------------------------------------------------------------------
/src/components/group_row.tsx:
--------------------------------------------------------------------------------
1 | import { BaseProps, h } from 'tsx-dom';
2 | import { BROWSER } from '../lib/globals';
3 | import { GrayTab } from '../lib/tabs_store';
4 |
5 | interface FaviconProps {
6 | url: string;
7 | }
8 |
9 | function FaviconComponent({ url }: FaviconProps): HTMLImageElement {
10 | const domain = new URL(url).hostname;
11 | let location = '';
12 | if (domain) location = `https://www.google.com/s2/favicons?domain=${domain}`;
13 | return (
) as HTMLImageElement;
14 | }
15 |
16 | interface GroupRowProps extends BaseProps {
17 | tab: GrayTab;
18 | clickCallback: (event: MouseEvent) => Promise;
19 | }
20 |
21 | export function GroupRowComponent({ tab, clickCallback }: GroupRowProps): HTMLLIElement {
22 | const removal = async (event: MouseEvent): Promise => {
23 | event.preventDefault();
24 | await Promise.all([
25 | clickCallback(event),
26 | BROWSER.get().tabs.create({ url: tab.url, active: false }),
27 | ]);
28 | };
29 |
30 | return (
31 |
32 |
38 |
39 | ) as HTMLLIElement;
40 | }
41 |
--------------------------------------------------------------------------------
/tests/tabs.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { GrayTabGroup, INDEX_V1_KEY, loadAllTabGroups } from '../src/lib/tabs_store';
3 | import { save } from '../src/lib/utils';
4 | import { stubGlobalsForTesting, unstubGlobals } from './utils';
5 |
6 | describe('tabs', function() {
7 | beforeEach(async function() {
8 | await stubGlobalsForTesting();
9 | });
10 |
11 | afterEach(function() {
12 | unstubGlobals();
13 | });
14 |
15 | it('should load old string format', async function() {
16 | const oldGroups: GrayTabGroup[] = [
17 | {
18 | tabs: [
19 | {
20 | url: '1',
21 | title: 'one',
22 | key: 0,
23 | },
24 | {
25 | url: '2',
26 | title: 'two',
27 | key: 1,
28 | },
29 | ],
30 | date: 1589760256, // May 17 2020 in Linux Timestamp.
31 | },
32 | {
33 | tabs: [
34 | {
35 | url: '3',
36 | title: 'three',
37 | key: 0,
38 | },
39 | ],
40 | date: 1589760257,
41 | },
42 | ];
43 | await save(INDEX_V1_KEY, JSON.stringify(oldGroups));
44 | const groups = await loadAllTabGroups();
45 | for (const oldGroup of oldGroups) {
46 | oldGroup.date *= 1000; // Seconds to millis conversion.
47 | }
48 | expect(groups).to.deep.equal(oldGroups);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // Cribbed from https://dev.to/robertcoopercode/using-eslint-and-prettier-in-a-typescript-project-53jb
2 |
3 | module.exports = {
4 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser
5 | extends: [
6 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
7 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
8 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
9 | ],
10 | parserOptions: {
11 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
12 | sourceType: 'module', // Allows for the use of imports
13 | indent: 2,
14 | project: './tsconfig.json',
15 | },
16 | rules: {
17 | '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'as' }],
18 | '@typescript-eslint/no-floating-promises': ['error'],
19 | '@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
20 | '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '(^h$|Component$)' }],
21 | // Use sparingly ;).
22 | '@typescript-eslint/no-explicit-any': false,
23 | '@typescript-eslint/ban-ts-ignore': false,
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/src/lib/brokers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A simple pub/sub message passing scheme.
3 | */
4 |
5 | import { BROWSER } from './globals';
6 |
7 | interface Payload {
8 | type: string;
9 | message: T;
10 | }
11 |
12 | export type BrokerConsumer = (msg: MessageT, sender: any, unsubFunc: () => void) => void;
13 | type MessageHandler = (payload: Payload, sender: any) => void;
14 |
15 | /**
16 | * Message passing between extension pages and background page.
17 | */
18 | export class Broker {
19 | protected key: string;
20 | protected done: boolean;
21 |
22 | constructor(key: string) {
23 | this.key = key;
24 | }
25 |
26 | /**
27 | * Publish to all subscribers of this broker
28 | */
29 | public async pub(message: MessageT): Promise {
30 | const payload: Payload = {
31 | type: this.key,
32 | message: message,
33 | };
34 | return BROWSER.get().runtime.sendMessage(payload);
35 | }
36 |
37 | /**
38 | * Register for consumption of messages
39 | */
40 | public sub(func: BrokerConsumer): void {
41 | const handler: MessageHandler = (payload, sender) => {
42 | if (payload.type === this.key) {
43 | func(payload.message, sender, () => this.unsub(handler));
44 | }
45 | };
46 | BROWSER.get().runtime.onMessage.addListener(handler);
47 | }
48 |
49 | unsub(handler: MessageHandler): void {
50 | BROWSER.get().runtime.onMessage.removeListener(handler);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/style/app.scss:
--------------------------------------------------------------------------------
1 | // Change bootstrap parameters through SCSS. These options must come before the initial
2 | // bootstrap import.
3 | // $font-size-base: 13;
4 | //$font-family-base: "Montserrat", "sans-serif";
5 | // @import "~bootstrap/scss/bootstrap";
6 |
7 | @import "purecss";
8 | @import "modal.scss";
9 | @import "montserrat.css";
10 |
11 | $app-padding: 10px;
12 | $app-fontsize: 16px;
13 | $button-horiz-padding: 16px;
14 | $button-vert-padding: 8px;
15 |
16 | body {
17 | font-family: "Montserrat", "sans-serif";
18 | font-size: $app-fontsize;
19 | }
20 |
21 | #app {
22 | padding: $app-padding;
23 | h1 {
24 | margin: 0;
25 | margin-bottom: 0.67em;
26 | }
27 | ul {
28 | list-style-type: none;
29 | padding-left: 20px;
30 | }
31 | a {
32 | padding-left: 5px;
33 | }
34 | #logo {
35 | position: fixed;
36 | right: $app-padding;
37 | bottom: $app-padding;
38 | width: 250px;
39 | height: 208px;
40 | }
41 | .pagination {
42 | display: inline-block;
43 | a {
44 | color: black;
45 | float: left;
46 | padding: $button-vert-padding $button-horiz-padding;
47 | text-decoration: none;
48 | width: $app-fontsize;
49 | text-align: center;
50 | }
51 | a:first-child {
52 | padding-left: 0px;
53 | }
54 | a:last-child {
55 | padding-right: 0px;
56 | }
57 | a.active {
58 | background-color: #4caf50;
59 | color: white;
60 | border-radius: 5px;
61 | }
62 | a:hover:not(.active) {
63 | background-color: #ddd;
64 | border-radius: 5px;
65 | }
66 | }
67 | }
68 |
69 | #optionsModal {
70 | form {
71 | label {
72 | input[type="checkbox"] {
73 | margin-right: 5px;
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { BROWSER } from './globals';
2 |
3 | export function clamp(num: number, min: number, max: number): number {
4 | return num <= min ? min : num >= max ? max : num;
5 | }
6 |
7 | export function setOnlyChild(elem: HTMLElement, child: HTMLElement): void {
8 | elem.innerHTML = '';
9 | elem.appendChild(child);
10 | }
11 |
12 | export function getOnlyChild(elem: HTMLElement): Element {
13 | return elem.children.item(0);
14 | }
15 |
16 | // Cribbed from https://stackoverflow.com/questions/44203045/remove-fields-from-typescript-interface-object
17 | export function fieldKeeper(obj: T, ...keys: K[]): Pick {
18 | const copy = {} as Pick;
19 | keys.forEach(key => (copy[key] = obj[key]));
20 | return copy;
21 | }
22 |
23 | export function dictOf(...args: any[]): { [key: string]: any } {
24 | const ret: { [key: string]: any } = {};
25 | for (let i = 0; i < args.length; i += 2) {
26 | ret[args[i] as string] = args[i + 1];
27 | }
28 | return ret;
29 | }
30 |
31 | export async function save(key: string, value: any): Promise {
32 | const record: any = {};
33 | record[key] = value;
34 | await BROWSER.get().storage.local.set(record);
35 | }
36 |
37 | export async function load(key: string): Promise {
38 | const results = await BROWSER.get().storage.local.get(key);
39 | return results[key];
40 | }
41 |
42 | export async function loadBatch(keys: string[]): Promise {
43 | const retval = [];
44 | const results = await BROWSER.get().storage.local.get(keys);
45 | for (const key of keys) {
46 | if (key in results) retval.push(results[key]);
47 | }
48 | return retval;
49 | }
50 |
51 | export async function erase(key: string): Promise {
52 | return BROWSER.get().storage.local.remove(key);
53 | }
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GrayTabby
2 |
3 | [](https://travis-ci.com/moribellamy/graytabby)
4 | [](https://coveralls.io/github/moribellamy/graytabby?branch=master)
5 |
6 | GrayTabby is a tab archiver for Chrome, Brave, Firefox, and Opera.
7 |
8 | # Download
9 |
10 |
11 |
12 |
13 |
14 | # Why GrayTabby
15 | * GrayTabby is _simple_. It focuses around one core flow: archival. Most users will get all the value they need out of this tab archiver by clicking one button.
16 | * GrayTabby is _open source_. You can read for yourself that it doesn't share your data. You can contribute to it for everyone's benefit.
17 | * GrayTabby is _focused_. It only asks for permission to view and modify your tabs, because that is its purpose in life.
18 |
19 | # Demo
20 | [](https://youtu.be/24_mo9sSyjo)
21 |
22 | # Licensing
23 | The source code is [MIT](LICENSE). Some images are [only allowed for distribution with GrayTabby](assets/img/blobbycat/LICENSE), so derivative works may not use them.
24 |
--------------------------------------------------------------------------------
/src/lib/options.ts:
--------------------------------------------------------------------------------
1 | import { BROWSER } from './globals';
2 | import { fieldKeeper } from './utils';
3 |
4 | export type SavedPage = {
5 | url: string;
6 | pinned: boolean;
7 | };
8 |
9 | export type Options = {
10 | tabLimit: number;
11 | groupsPerPage: number;
12 | archiveDupes: boolean;
13 | homeGroup: SavedPage[];
14 | };
15 |
16 | export const OPTIONS_DEFAULT: Options = {
17 | tabLimit: 10000,
18 | groupsPerPage: 10,
19 | archiveDupes: false,
20 | homeGroup: [],
21 | };
22 |
23 | export const OPTIONS_KEY = 'options';
24 |
25 | export async function getOptions(): Promise {
26 | const results = await BROWSER.get().storage.local.get(OPTIONS_KEY);
27 | let options = results[OPTIONS_KEY];
28 | if (typeof options == 'string') {
29 | // Legacy
30 | options = JSON.parse(options);
31 | }
32 | return { ...OPTIONS_DEFAULT, ...options };
33 | }
34 |
35 | export async function setOptions(value: Partial): Promise {
36 | const previous = await getOptions();
37 | const record: { [key: string]: Options } = {};
38 | const next = fieldKeeper(
39 | { ...previous, ...value },
40 | 'archiveDupes',
41 | 'homeGroup',
42 | 'tabLimit',
43 | 'groupsPerPage',
44 | );
45 | record[OPTIONS_KEY] = next;
46 | return BROWSER.get().storage.local.set(record);
47 | }
48 |
49 | export async function restoreFavorites(): Promise {
50 | const homeGroup = (await getOptions()).homeGroup;
51 | if (homeGroup.length === 0) return;
52 |
53 | const createdPromises = Promise.all(
54 | homeGroup.map(saved => BROWSER.get().tabs.create({ pinned: saved.pinned, url: saved.url })),
55 | );
56 | const newTabs = new Set((await createdPromises).map(t => t.id));
57 |
58 | const tabs = await BROWSER.get().tabs.query({});
59 | const toRemove = tabs.filter(t => !newTabs.has(t.id)).map(t => t.id);
60 | await BROWSER.get().tabs.remove(toRemove);
61 | }
62 |
63 | export async function saveAsFavorites(): Promise {
64 | const tabs = await BROWSER.get().tabs.query({});
65 | const saved: SavedPage[] = [];
66 | for (const tab of tabs) {
67 | saved.push({
68 | pinned: tab.pinned,
69 | url: tab.url,
70 | });
71 | }
72 | await setOptions({
73 | homeGroup: saved,
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "test": "mocha --require ts-node/register/transpile-only --recursive tests/**/*.test.ts",
4 | "test:coverage": "nyc npm run test",
5 | "test:coveralls": "npm run test:coverage && cat ./coverage/lcov.info | coveralls",
6 | "develop": "NODE_ENV=development webpack --watch",
7 | "develop:once": "NODE_ENV=development webpack",
8 | "build": "NODE_ENV=production webpack",
9 | "clean": "rm -rf dist coverage pack",
10 | "deps": "rm -rf node_modules; npm i",
11 | "bundle": "ts-node scripts/bundle.ts",
12 | "lint": "eslint src/**/*.ts tests/**/*.ts"
13 | },
14 | "author": "Mori Bellamy",
15 | "license": "MIT",
16 | "devDependencies": {
17 | "@types/archiver": "^3.1.0",
18 | "@types/chai": "^4.2.11",
19 | "@types/chrome": "0.0.86",
20 | "@types/firefox-webext-browser": "^67.0.2",
21 | "@types/jquery": "^3.3.38",
22 | "@types/jsdom": "^12.2.4",
23 | "@types/mocha": "^5.2.7",
24 | "@types/recursive-readdir": "^2.2.0",
25 | "@types/rimraf": "^3.0.0",
26 | "@types/sinon-chrome": "^2.2.8",
27 | "@typescript-eslint/eslint-plugin": "^2.31.0",
28 | "@typescript-eslint/parser": "^2.31.0",
29 | "archiver": "^4.0.1",
30 | "autoprefixer": "^9.7.6",
31 | "chai": "^4.2.0",
32 | "copy-webpack-plugin": "^5.1.1",
33 | "coveralls": "^3.1.0",
34 | "css-loader": "^3.5.3",
35 | "eslint-config-prettier": "^6.11.0",
36 | "eslint-plugin-prettier": "^3.1.3",
37 | "file-loader": "^4.3.0",
38 | "jquery": "^3.5.1",
39 | "jsdom": "^15.2.1",
40 | "mini-css-extract-plugin": "^0.8.2",
41 | "mocha": "^6.2.3",
42 | "node-sass": "^4.14.1",
43 | "nyc": "^14.1.1",
44 | "object-sizeof": "^1.6.0",
45 | "postcss-loader": "^3.0.0",
46 | "prettier": "^1.19.1",
47 | "purecss": "^2.0.3",
48 | "recursive-readdir": "^2.2.2",
49 | "rimraf": "^3.0.2",
50 | "sass-loader": "^7.3.1",
51 | "sinon-chrome": "^3.0.1",
52 | "ts-loader": "^6.2.2",
53 | "ts-node": "^8.10.1",
54 | "ts-sinon": "^1.2.0",
55 | "tsx-dom": "^0.8.3",
56 | "typescript": "^3.8.3",
57 | "web-ext": "^4.2.0",
58 | "webextension-polyfill-ts": "^0.9.1",
59 | "webpack": "^4.43.0",
60 | "webpack-cli": "^3.3.11",
61 | "webpack-shell-plugin-next": "^1.1.9"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/paginator.tsx:
--------------------------------------------------------------------------------
1 | import { h } from 'tsx-dom';
2 | import { clamp } from '../lib/utils';
3 |
4 | interface PaginatorButtonComponentProps {
5 | clickCallback: () => void;
6 | active: boolean;
7 | innerText: string;
8 | }
9 |
10 | function PaginatorButtonComponent({
11 | clickCallback,
12 | active,
13 | innerText,
14 | }: PaginatorButtonComponentProps): HTMLAnchorElement {
15 | const click = (event: MouseEvent): void => {
16 | event.preventDefault();
17 | clickCallback();
18 | };
19 | return (
20 |
21 | {innerText}
22 |
23 | ) as HTMLAnchorElement;
24 | }
25 |
26 | interface PaginatorProps {
27 | pages: number;
28 | currentPage: number; // 0 indexed
29 | selectCallback: (page: number) => void;
30 | }
31 |
32 | export function PaginatorComponent({
33 | pages,
34 | currentPage,
35 | selectCallback,
36 | }: PaginatorProps): HTMLDivElement {
37 | currentPage = clamp(currentPage, 0, pages - 1);
38 | const container = () as HTMLDivElement;
39 |
40 | const windowSize = 7;
41 | let nodes: number[];
42 | if (pages <= windowSize) {
43 | nodes = [...Array(pages).keys()];
44 | } else {
45 | nodes = [currentPage];
46 | let onLeft = true;
47 | while (nodes.length < 7) {
48 | if (onLeft) {
49 | const leftVal = nodes[0];
50 | if (leftVal > 0) nodes = [leftVal - 1, ...nodes];
51 | } else {
52 | const rightVal = nodes[nodes.length - 1];
53 | if (rightVal < pages - 1) nodes = [...nodes, rightVal + 1];
54 | }
55 | onLeft = !onLeft;
56 | }
57 | }
58 |
59 | container.appendChild(
60 | selectCallback(0)}
62 | active={false}
63 | innerText="«"
64 | />,
65 | );
66 | for (const i of nodes) {
67 | container.appendChild(
68 | selectCallback(i)}
70 | active={i == currentPage}
71 | innerText={Number(i + 1).toString()}
72 | />,
73 | );
74 | }
75 | container.appendChild(
76 | selectCallback(pages - 1)}
78 | active={false}
79 | innerText="»"
80 | />,
81 | );
82 |
83 | return container;
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/group.tsx:
--------------------------------------------------------------------------------
1 | import { BaseProps, h } from 'tsx-dom';
2 | import {
3 | dateFromKey,
4 | eraseTabGroup,
5 | GrayTab,
6 | GrayTabGroup,
7 | keyFromGroup,
8 | saveTabGroup,
9 | } from '../lib/tabs_store';
10 | import { GroupRowComponent } from './group_row';
11 |
12 | interface GroupProps extends BaseProps {
13 | group: GrayTabGroup;
14 | removeCallback: () => void;
15 | }
16 |
17 | function groupFromDiv(self: GroupElement): GrayTabGroup {
18 | const date = dateFromKey(self.id);
19 | const group: GrayTabGroup = {
20 | date: date,
21 | tabs: [],
22 | };
23 | const lis = self.querySelectorAll('li');
24 | lis.forEach(li => {
25 | const a: HTMLAnchorElement = li.querySelector('a');
26 | const tab: GrayTab = {
27 | key: Number(a.attributes.getNamedItem('data-key').value),
28 | url: a.href,
29 | title: a.innerText,
30 | };
31 | group.tabs.push(tab);
32 | });
33 | return group;
34 | }
35 |
36 | async function syncGroupFromDOM(self: GroupElement): Promise {
37 | const group = groupFromDiv(self);
38 | if (group.tabs.length == 0) {
39 | await eraseTabGroup(group.date);
40 | } else {
41 | await saveTabGroup(group);
42 | }
43 | return group;
44 | }
45 |
46 | async function childClickCallback(event: MouseEvent): Promise {
47 | let target = event.target as HTMLElement;
48 | let tail: HTMLElement = null;
49 | while (!target.classList.contains('tabGroup')) {
50 | tail = target;
51 | target = target.parentElement;
52 | }
53 | // now target is a