├── 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 | [![Build Status](https://travis-ci.com/moribellamy/graytabby.svg?branch=master)](https://travis-ci.com/moribellamy/graytabby) 4 | [![Coverage Status](https://coveralls.io/repos/github/moribellamy/graytabby/badge.svg?branch=master)](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 | [![GrayTabby demo](https://img.youtube.com/vi/24_mo9sSyjo/0.jpg)](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 = (