├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .nycrc.json ├── .prettierrc.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── img │ └── blobbycat │ ├── LICENSE │ ├── browseraction128.png │ ├── icon128.png │ ├── icon48.png │ └── logo.png ├── build_hook.sh ├── manifest.json ├── package-lock.json ├── package.json ├── scripts └── bundle.ts ├── src ├── app.html ├── app.ts ├── background.ts ├── components │ ├── app.tsx │ ├── group.tsx │ ├── group_row.tsx │ ├── options.tsx │ └── paginator.tsx ├── lib │ ├── archive.ts │ ├── brokers.ts │ ├── globals.ts │ ├── options.ts │ ├── tabs_store.ts │ ├── types.ts │ └── utils.ts └── style │ ├── app.scss │ ├── modal.scss │ └── montserrat.css ├── tests ├── archive.test.ts ├── options.test.ts ├── tabs.test.ts └── utils.ts ├── tsconfig.json └── webpack.config.js /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | pack/ 7 | 8 | .DS_Store 9 | mix-manifest.json 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/img/blobbycat/LICENSE: -------------------------------------------------------------------------------- 1 | Images in this directory copyright Kirstin Kajita, all rights reserved. 2 | -------------------------------------------------------------------------------- /assets/img/blobbycat/browseraction128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/browseraction128.png -------------------------------------------------------------------------------- /assets/img/blobbycat/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/icon128.png -------------------------------------------------------------------------------- /assets/img/blobbycat/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/icon48.png -------------------------------------------------------------------------------- /assets/img/blobbycat/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moribellamy/graytabby/406684a49ab73c17f410076fdf153a4db5ac00ca/assets/img/blobbycat/logo.png -------------------------------------------------------------------------------- /build_hook.sh: -------------------------------------------------------------------------------- 1 | LOGO=dist/assets/img/blobbycat/browseraction128.png 2 | 3 | if [[ "$NODE_ENV" == "development" ]]; then 4 | if [[ -f `which convert` ]]; then 5 | echo "development build; doing flippycat" 6 | convert -flip $LOGO $LOGO 7 | else 8 | echo "'convert' not found on path; skipping flippycat" 9 | fi 10 | else 11 | echo "production build; skipping flippycat" 12 | fi 13 | 14 | VERSION=`egrep -oe '.*"version":.*' manifest.json` 15 | VERSION=`echo "$VERSION" | egrep -oe '(\d+[.]?)+'` 16 | 17 | if [[ "$NODE_ENV" == "development" ]]; then 18 | VERSION="$VERSION-`date +"%T"`" 19 | fi 20 | 21 | # echo "stamping build with $VERSION" 22 | # sed -i.bak "s/!!VERSION!!/$VERSION/" dist/app.html 23 | # rm dist/app.html.bak 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/bundle.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'child_process'; 2 | import * as archiver from 'archiver'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import readdir from 'recursive-readdir'; 6 | import rimraf from 'rimraf'; 7 | 8 | async function rmrf(target: string): Promise { 9 | return new Promise((resolve, reject) => { 10 | rimraf(target, error => { 11 | if (error) reject(error); 12 | resolve(); 13 | }); 14 | }); 15 | } 16 | 17 | async function rename(src: string, dst: string): Promise { 18 | return new Promise((resolve, reject) => { 19 | fs.rename(src, dst, err => { 20 | if (err) reject(err); 21 | resolve(); 22 | }); 23 | }); 24 | } 25 | 26 | async function subcommand(cmd: string): Promise { 27 | return new Promise((resolve, reject) => { 28 | childProcess.exec(cmd, (err, stdout, stderr) => { 29 | if (err) reject(err); 30 | resolve([stdout, stderr]); 31 | }); 32 | }); 33 | } 34 | 35 | function abspath(...parts: string[]): string { 36 | return path.join(process.cwd(), ...parts); 37 | } 38 | 39 | async function bundle(): Promise { 40 | process.chdir(path.join(__dirname, '..')); 41 | await rmrf(abspath('dist')); 42 | const build = subcommand('npm run build'); 43 | 44 | await rmrf(abspath('pack')); 45 | fs.mkdirSync(abspath('pack')); 46 | const zip = archiver.default('zip'); 47 | const output = fs.createWriteStream(abspath('pack', 'chrome.zip')); 48 | zip.pipe(output); 49 | 50 | await build; 51 | zip.directory(abspath('dist'), false); 52 | await zip.finalize(); 53 | 54 | process.chdir('dist'); 55 | await subcommand('web-ext build'); 56 | process.chdir('..'); 57 | let basename = ''; 58 | for (const f of await readdir(abspath('dist'))) { 59 | if (f.endsWith('.zip') && f.includes('dist/web-ext-artifacts')) { 60 | basename = path.basename(f); 61 | await rename(f, abspath('pack', basename)); 62 | } 63 | } 64 | if (basename === '') { 65 | throw 'Could not find firefox bundle.'; 66 | } 67 | 68 | basename = basename.substring(0, basename.length - '.zip'.length); 69 | await rename(abspath('pack', 'chrome.zip'), abspath('pack', `${basename}-chrome.zip`)); 70 | 71 | for (const f of await readdir(abspath('pack'))) { 72 | console.log(f); 73 | } 74 | } 75 | 76 | function run(promise: Promise): void { 77 | promise.then( 78 | () => null, 79 | err => { 80 | console.error(err); 81 | process.exit(1); 82 | }, 83 | ); 84 | } 85 | 86 | run(bundle()); 87 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GrayTabby 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/components/app.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'tsx-dom'; 2 | import { ARCHIVAL } from '../lib/globals'; 3 | import { getOptions } from '../lib/options'; 4 | import { GrayTabGroup, loadAllTabGroups, saveTabGroup } from '../lib/tabs_store'; 5 | import { BrowserTab } from '../lib/types'; 6 | import { clamp, getOnlyChild, setOnlyChild } from '../lib/utils'; 7 | import { GroupComponent } from './group'; 8 | import { OptionsComponent, setVisible } from './options'; 9 | import { PaginatorComponent } from './paginator'; 10 | 11 | async function ingestTabs( 12 | tabSummaries: BrowserTab[], 13 | now = () => new Date().getTime(), 14 | ): Promise { 15 | if (tabSummaries.length == 0) return; 16 | let counter = 0; 17 | const group: GrayTabGroup = { 18 | tabs: tabSummaries.map(ts => { 19 | return { ...ts, key: counter++ }; 20 | }), 21 | date: now(), 22 | }; 23 | await saveTabGroup(group); 24 | } 25 | 26 | export interface AppElement extends HTMLDivElement { 27 | _signalWatcher: () => void; 28 | initialRender: Promise; 29 | } 30 | 31 | export function watchRender(app: AppElement): Promise { 32 | return new Promise(resolve => { 33 | app._signalWatcher = resolve; 34 | }); 35 | } 36 | 37 | export function App(): AppElement { 38 | const optionsWrapper = ; // Contains one modal. 39 | const paginatorWrapper = ; // Contains one paginator. 40 | const groupsWrapper = () as HTMLDivElement; // Contains N GroupComponents. 41 | 42 | const self = ( 43 | 44 | {optionsWrapper} 45 | 46 | Welcome to GrayTabby! 47 | {paginatorWrapper} 48 | 49 | {groupsWrapper} 50 | setVisible(getOnlyChild(optionsWrapper) as HTMLDivElement, true)} 54 | /> 55 | 56 | 57 | ) as AppElement; 58 | 59 | let lastRenderPage = 0; 60 | async function render(pageNum?: number /* 0 indexed */): Promise { 61 | if (pageNum == null) pageNum = lastRenderPage; 62 | const options = await getOptions(); 63 | setOnlyChild(optionsWrapper, ); 64 | groupsWrapper.innerHTML = ''; 65 | 66 | const loaded = await loadAllTabGroups(); 67 | 68 | const totalPages = Math.max(1, Math.ceil(loaded.length / options.groupsPerPage)); 69 | pageNum = clamp(pageNum, 0, totalPages - 1); 70 | const firstIdx = pageNum * options.groupsPerPage; 71 | const lastIdx = firstIdx + options.groupsPerPage; 72 | 73 | setOnlyChild( 74 | paginatorWrapper, 75 | totalPages > 1 ? ( 76 | render(clicked)} 80 | /> 81 | ) : ( 82 | 83 | ), 84 | ); 85 | for (const group of loaded.slice(firstIdx, lastIdx)) { 86 | groupsWrapper.appendChild(); 87 | } 88 | lastRenderPage = pageNum; 89 | if (self._signalWatcher != null) { 90 | self._signalWatcher(); 91 | self._signalWatcher = null; 92 | } 93 | } 94 | 95 | self.initialRender = render(0); 96 | 97 | ARCHIVAL.get().sub(async summaries => { 98 | await ingestTabs(summaries); 99 | await render(0); 100 | }); 101 | 102 | return self; 103 | } 104 | -------------------------------------------------------------------------------- /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 and tail is an 54 | target.removeChild(tail); 55 | const self = target.parentElement as GroupElement; 56 | const group = await syncGroupFromDOM(self); 57 | if (group.tabs.length == 0) { 58 | self.remove(); 59 | self.removeCallback(); 60 | } 61 | } 62 | 63 | interface GroupElement extends HTMLDivElement { 64 | removeCallback: () => void; 65 | } 66 | 67 | export function GroupComponent({ group, removeCallback }: GroupProps): GroupElement { 68 | const rows = ; 69 | const self = ( 70 | 71 | {new Date(group.date).toLocaleString()} 72 | {rows} 73 | 74 | ) as GroupElement; 75 | 76 | self.removeCallback = removeCallback; 77 | 78 | for (const tab of group.tabs) { 79 | rows.appendChild(); 80 | } 81 | 82 | return self; 83 | } 84 | -------------------------------------------------------------------------------- /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 | 33 | 34 | 35 | {tab.title} 36 | 37 | 38 | 39 | ) as HTMLLIElement; 40 | } 41 | -------------------------------------------------------------------------------- /src/components/options.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'tsx-dom'; 2 | import { Options, setOptions } from '../lib/options'; 3 | 4 | export function setVisible(self: HTMLDivElement, visible: boolean): void { 5 | self.style.display = visible ? 'block' : 'none'; 6 | } 7 | 8 | interface OptionsProps { 9 | options: Options; 10 | closeCallback: () => void; 11 | } 12 | 13 | export function OptionsComponent({ options, closeCallback }: OptionsProps): HTMLDivElement { 14 | const optionsDupesCheckbox = ( 15 | 16 | ) as HTMLInputElement; 17 | optionsDupesCheckbox.onchange = async () => { 18 | await setOptions({ 19 | archiveDupes: optionsDupesCheckbox.checked, 20 | }); 21 | }; 22 | 23 | const optionsLimitTextbox = ( 24 | 25 | ) as HTMLInputElement; 26 | optionsLimitTextbox.onkeydown = (e): boolean => { 27 | return ( 28 | e.ctrlKey || 29 | e.altKey || 30 | (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || 31 | (95 < e.keyCode && e.keyCode < 106) || 32 | e.keyCode == 8 || 33 | e.keyCode == 9 || 34 | (e.keyCode > 34 && e.keyCode < 40) || 35 | e.keyCode == 46 36 | ); 37 | }; 38 | optionsLimitTextbox.onkeyup = async () => { 39 | const newLimit = Number(optionsLimitTextbox.value); 40 | if (newLimit != NaN) { 41 | await setOptions({ 42 | tabLimit: newLimit, 43 | }); 44 | } 45 | }; 46 | 47 | // HACK 48 | const groupsPerPageTextbox = ( 49 | 50 | ) as HTMLInputElement; 51 | groupsPerPageTextbox.onkeydown = (e): boolean => { 52 | return ( 53 | e.ctrlKey || 54 | e.altKey || 55 | (47 < e.keyCode && e.keyCode < 58 && e.shiftKey == false) || 56 | (95 < e.keyCode && e.keyCode < 106) || 57 | e.keyCode == 8 || 58 | e.keyCode == 9 || 59 | (e.keyCode > 34 && e.keyCode < 40) || 60 | e.keyCode == 46 61 | ); 62 | }; 63 | groupsPerPageTextbox.onkeyup = async () => { 64 | const newLimit = Number(groupsPerPageTextbox.value); 65 | if (newLimit != NaN) { 66 | await setOptions({ 67 | groupsPerPage: newLimit, 68 | }); 69 | } 70 | }; 71 | 72 | const content = ( 73 | 74 | 75 | 76 | Options 77 | Tabs Limit 78 | {optionsLimitTextbox} 79 | 80 | How many tabs to keep. Older tab groups are removed to keep you under this limit. 81 | 82 | Groups Per Page 83 | {groupsPerPageTextbox} 84 | How many tab groups to load per page. 85 | 86 | 87 | {optionsDupesCheckbox} 88 | (optionsDupesCheckbox.checked = !optionsDupesCheckbox.checked)}> 89 | Keep duplicates. 90 | 91 | 92 | 93 | If checked, identical tabs will not de-duplicate on archival. 94 | 95 | 96 | Info 97 | 98 | Source Code (on GitHub) 99 | 100 | 101 | 102 | ) as HTMLDivElement; 103 | 104 | const modal = ( 105 | 106 | {content} 107 | 108 | ) as HTMLDivElement; 109 | 110 | modal.onclick = (event: MouseEvent) => { 111 | if (!content.contains(event.target as HTMLElement)) { 112 | setVisible(modal, false); 113 | closeCallback(); 114 | } 115 | }; 116 | return modal; 117 | } 118 | -------------------------------------------------------------------------------- /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/lib/archive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Any logic having to do with archival. 3 | */ 4 | 5 | import { Menus } from 'webextension-polyfill-ts/dist/generated/menus'; 6 | import { ARCHIVAL, BROWSER, PAGE_LOAD } from './globals'; 7 | import { getOptions, restoreFavorites, saveAsFavorites } from './options'; 8 | import { BrowserTab } from './types'; 9 | 10 | export type OnClickData = Menus.OnClickData; 11 | 12 | /** 13 | * @returns where the user should browse to for the main GrayTabby page. 14 | */ 15 | function appURL(): string { 16 | return BROWSER.get().extension.getURL('app.html'); 17 | } 18 | 19 | function shouldJustClose(url: string): boolean { 20 | const neverEqualList = ['chrome://newtab/', '']; 21 | const neverStartWithList = ['about:', 'data:']; 22 | for (const datum of neverEqualList) { 23 | if (datum === url) return true; 24 | } 25 | for (const datum of neverStartWithList) { 26 | if (url.startsWith(datum)) return true; 27 | } 28 | return false; 29 | } 30 | 31 | /** 32 | * Figures out which tabs get archived and which get closed. 33 | * 34 | * @param browserTabs All tabs which are candidates for archival. 35 | * @param homeURL The URL that indicates a tab is a GrayTabby home tab 36 | * @param archiveDupes If false, multiple instances of the same page are collapsed in 37 | * to one entry during the archive operation. 38 | * @returns a tuple. The first element is a list of tabs that need to be 39 | * archived. Second element is tabs that will just be closed and not archived. 40 | * None of the GrayTabby tabs are counted in either member. 41 | */ 42 | export function archivePlan( 43 | browserTabs: BrowserTab[], 44 | homeURL: string, 45 | archiveDupes: boolean, 46 | ): [BrowserTab[], BrowserTab[]] { 47 | const tabsToArchive: BrowserTab[] = []; 48 | const tabsToClose: BrowserTab[] = []; 49 | const seen: Set = new Set(); 50 | 51 | for (const tab of browserTabs) { 52 | if (tab.url === homeURL || tab.pinned) continue; 53 | else if (seen.has(tab.url) && !archiveDupes) tabsToClose.push(tab); 54 | else if (shouldJustClose(tab.url)) tabsToClose.push(tab); 55 | else { 56 | tabsToArchive.push(tab); 57 | seen.add(tab.url); 58 | } 59 | } 60 | return [tabsToArchive, tabsToClose]; 61 | } 62 | 63 | function numberCmp(a: number | undefined, b: number | undefined): number { 64 | if (a == b && b == undefined) return 0; // ...or one is truthy 65 | if (a == undefined) return 1; 66 | if (b == undefined) return -1; 67 | return a - b; 68 | } 69 | 70 | function tabCmp(a: BrowserTab, b: BrowserTab): number { 71 | if (a.pinned != b.pinned) return a.pinned ? -1 : 1; 72 | const winCmp = numberCmp(a.windowId, b.windowId); 73 | if (winCmp != 0) return winCmp; 74 | return numberCmp(a.id, b.id); 75 | } 76 | 77 | export async function ensureExactlyOneHomeTab(): Promise { 78 | const homeTabs = await BROWSER.get().tabs.query({ url: appURL() }); 79 | if (homeTabs.length > 0) { 80 | homeTabs.sort(tabCmp); 81 | const toClose: number[] = []; 82 | if (homeTabs.length > 1) { 83 | for (let i = 1; i < homeTabs.length; i++) { 84 | toClose.push(homeTabs[i].id); 85 | } 86 | } 87 | await BROWSER.get().tabs.remove(toClose); 88 | return homeTabs[0]; 89 | } else { 90 | const loaded = new Promise(resolve => { 91 | PAGE_LOAD.get().sub((_, sender, unsub) => { 92 | if (sender.tab && sender.tab.url === appURL()) { 93 | unsub(); 94 | resolve(); 95 | } 96 | }); 97 | }); 98 | const homeTab = await BROWSER.get().tabs.create({ active: true, url: appURL() }); 99 | await loaded; 100 | return homeTab; 101 | } 102 | } 103 | 104 | async function doArchive(func: (arg0: BrowserTab) => boolean): Promise { 105 | const [, options] = await Promise.all([ensureExactlyOneHomeTab(), getOptions()]); 106 | const tabs = await BROWSER.get().tabs.query({}); 107 | const [toArchiveTabs, toCloseTabs] = archivePlan( 108 | tabs.filter(func), 109 | appURL(), 110 | options.archiveDupes, 111 | ); 112 | await Promise.all([ 113 | BROWSER.get().tabs.remove(toArchiveTabs.map(t => t.id)), 114 | BROWSER.get().tabs.remove(toCloseTabs.map(t => t.id)), 115 | ARCHIVAL.get().pub(toArchiveTabs), 116 | ]); 117 | } 118 | 119 | export async function archiveHandler(): Promise { 120 | await doArchive(() => true); 121 | const homeTab = await ensureExactlyOneHomeTab(); 122 | await BROWSER.get().tabs.update(homeTab.id, { active: true }); 123 | } 124 | 125 | export async function archiveOthersHandler(_data: OnClickData, tab: BrowserTab): Promise { 126 | return doArchive(t => t.windowId == tab.windowId && t.id != tab.id); 127 | } 128 | 129 | export async function archiveLeftHandler(_data: OnClickData, tab: BrowserTab): Promise { 130 | return doArchive(t => t.windowId == tab.windowId && t.index < tab.index); 131 | } 132 | 133 | export async function archiveRightHandler(_data: OnClickData, tab: BrowserTab): Promise { 134 | return doArchive(t => t.windowId == tab.windowId && t.index > tab.index); 135 | } 136 | 137 | export async function archiveOnlyHandler(_data: OnClickData, tab: BrowserTab): Promise { 138 | return doArchive(t => t.id == tab.id); 139 | } 140 | 141 | export async function bindArchivalHandlers(): Promise { 142 | BROWSER.get().browserAction.onClicked.addListener(archiveHandler); 143 | let isFirefox = false; 144 | try { 145 | const info = await BROWSER.get().runtime.getBrowserInfo(); 146 | if (info.name.toLowerCase() === 'firefox') { 147 | isFirefox = true; 148 | } 149 | } catch { 150 | // let isFirefox stay false 151 | } 152 | 153 | const contexts: Menus.ContextType[] = isFirefox ? ['tab', 'browser_action'] : ['browser_action']; 154 | 155 | BROWSER.get().contextMenus.create({ 156 | contexts: contexts, 157 | title: 'Save current tabs as favorites', 158 | onclick: saveAsFavorites, 159 | }); 160 | 161 | BROWSER.get().contextMenus.create({ 162 | contexts: contexts, 163 | title: 'Restore favorite tabs', 164 | onclick: restoreFavorites, 165 | }); 166 | 167 | BROWSER.get().contextMenus.create({ 168 | contexts: contexts, 169 | title: 'Archive...', 170 | onclick: archiveOnlyHandler, 171 | }); 172 | 173 | BROWSER.get().contextMenus.create({ 174 | contexts: contexts, 175 | title: ' Left', 176 | onclick: archiveLeftHandler, 177 | }); 178 | 179 | BROWSER.get().contextMenus.create({ 180 | contexts: contexts, 181 | title: ' Right', 182 | onclick: archiveRightHandler, 183 | }); 184 | 185 | BROWSER.get().contextMenus.create({ 186 | contexts: contexts, 187 | title: ' Others', 188 | onclick: archiveOthersHandler, 189 | }); 190 | } 191 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /src/lib/tabs_store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tab types and logic. 3 | */ 4 | 5 | import { BrowserTab } from './types'; 6 | import { erase, fieldKeeper, load, loadBatch, save } from './utils'; 7 | 8 | export type GrayTab = Pick & { key: number }; 9 | 10 | export type GrayTabGroup = { 11 | tabs: GrayTab[]; 12 | date: number; 13 | }; 14 | 15 | export const INDEX_V1_KEY = 'tabGroups'; 16 | export const INDEX_V2_KEY = 'g'; 17 | 18 | /** 19 | * Deletes the first matching element from 20 | * @param arr An array 21 | * @param func A criterion for deletion. 22 | * @returns 23 | */ 24 | function snip(arr: T[], func: (arg: T) => boolean): T[] { 25 | const idx = arr.findIndex(func); 26 | arr.splice(idx, 1); 27 | return arr; 28 | } 29 | 30 | export function keyFromDate(date: number): string { 31 | return `${INDEX_V2_KEY}${date}`; 32 | } 33 | 34 | export function dateFromKey(key: string): number { 35 | key = key.substr(INDEX_V2_KEY.length); 36 | return Number(key); 37 | } 38 | 39 | export function keyFromGroup(group: GrayTabGroup): string { 40 | return keyFromDate(group.date); 41 | } 42 | 43 | function reindexTabGroup(group: GrayTabGroup): void { 44 | let counter = 0; 45 | for (const tab of group.tabs) tab.key = counter++; 46 | } 47 | 48 | /** 49 | * Switch V1 to V2 schema. Clears V1_KEY after populating per the V2 schema. 50 | * @param v1value The value stored in V1_KEY. 51 | */ 52 | async function migrateV1(v1value: string): Promise { 53 | let groups: GrayTabGroup[] = JSON.parse(v1value); 54 | const promises: Promise[] = []; 55 | const keys: string[] = []; 56 | await save(INDEX_V2_KEY, []); // init index 57 | groups = groups.map(g => fieldKeeper(g, 'date', 'tabs')); 58 | for (const group of groups) { 59 | group.date *= 1000; 60 | reindexTabGroup(group); 61 | const key = keyFromGroup(group); 62 | group.tabs = group.tabs.map(t => fieldKeeper(t, 'key', 'title', 'url')); 63 | promises.push(save(key, group)); 64 | keys.push(key); 65 | } 66 | promises.push(erase(INDEX_V1_KEY)); 67 | promises.push(save(INDEX_V2_KEY, keys)); 68 | await Promise.all(promises); 69 | return groups; 70 | } 71 | 72 | export async function loadAllTabGroups(): Promise { 73 | const legacy = await load(INDEX_V1_KEY); 74 | if (legacy) return migrateV1(legacy); 75 | 76 | let result = await load(INDEX_V2_KEY); 77 | if (!result) { 78 | result = []; 79 | await save(INDEX_V2_KEY, result); 80 | } 81 | const groupIds: string[] = result; 82 | return loadBatch(groupIds); 83 | } 84 | 85 | export async function saveTabGroup(group: GrayTabGroup): Promise { 86 | group.tabs = group.tabs.map(t => fieldKeeper(t, 'key', 'title', 'url')); 87 | const index: string[] = (await load(INDEX_V2_KEY)) || []; 88 | const key: string = keyFromGroup(group); 89 | if (index.indexOf(key) == -1) { 90 | index.unshift(key); 91 | } 92 | await Promise.all([save(key, group), save(INDEX_V2_KEY, index)]); 93 | } 94 | 95 | export async function eraseTabGroup(date: number): Promise { 96 | const key: string = keyFromDate(date); 97 | const index: string[] = await load(INDEX_V2_KEY); 98 | snip(index, i => i == key); 99 | await Promise.all([erase(key), save(INDEX_V2_KEY, index)]); 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Tabs as WebextTabs } from 'webextension-polyfill-ts/dist/generated/tabs'; 2 | 3 | export type BrowserTab = WebextTabs.Tab; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/style/montserrat.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Montserrat 3 | https://fonts.google.com/specimen/Montserrat 4 | 5 | Licensed under the Open Font License 6 | */ 7 | @font-face { 8 | font-family: 'Montserrat'; 9 | font-style: normal; 10 | font-weight: 400; 11 | src: local('Montserrat Regular'), local('Montserrat-Regular'), url("data:application/x-font-woff2;base64,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") format('woff2'); 12 | } 13 | -------------------------------------------------------------------------------- /tests/archive.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { App, watchRender } from '../src/components/app'; 3 | import { archivePlan } from '../src/lib/archive'; 4 | import { Broker, BrokerConsumer } from '../src/lib/brokers'; 5 | import { ARCHIVAL, DOCUMENT } from '../src/lib/globals'; 6 | import { BrowserTab } from '../src/lib/types'; 7 | import { assertElement, stubGlobalsForTesting, testTab, unstubGlobals } from './utils'; 8 | 9 | describe('archive operation', function() { 10 | beforeEach(async function() { 11 | await stubGlobalsForTesting(); 12 | }); 13 | 14 | afterEach(function() { 15 | unstubGlobals(); 16 | }); 17 | 18 | // TODO more granular tests. 19 | it('should work', async function() { 20 | const archival = new Broker('moreTabs'); 21 | // Capture callback for tab archival so we can inject tabs in test. 22 | let consumer: BrokerConsumer; 23 | archival.sub = func => { 24 | consumer = func; 25 | }; 26 | ARCHIVAL.set(archival); 27 | 28 | const app = DOCUMENT.get().body.appendChild(App()); 29 | await app.initialRender; 30 | 31 | assertElement('#groups > div', DOCUMENT.get(), 0); 32 | 33 | let render = watchRender(app); 34 | consumer([testTab({ url: 'http://example.com' })], {}, () => null); 35 | await render; 36 | 37 | const group = assertElement('#groups > div', DOCUMENT.get()); 38 | const a = assertElement('a', group) as HTMLAnchorElement; 39 | 40 | render = watchRender(app); 41 | a.click(); 42 | await render; 43 | 44 | assertElement('a', group, 0); 45 | assertElement('#groups > div', DOCUMENT.get(), 0); 46 | }); 47 | }); 48 | 49 | describe('archivePlan', () => { 50 | it('should be noop when no tabs are open', () => { 51 | const [tabsToArchive, tabsToClose] = archivePlan([], '', true); 52 | expect(tabsToArchive).to.be.empty; 53 | expect(tabsToClose).to.be.empty; 54 | }); 55 | 56 | it('should archive non-home tabs and skip home tab', () => { 57 | const [tabsToArchive, tabsToClose] = archivePlan( 58 | [ 59 | testTab({ url: 'foo', pinned: false, windowId: 1, title: '', id: 1 }), 60 | testTab({ url: 'home', pinned: false, windowId: 1, title: '', id: 2 }), 61 | testTab({ url: 'bar', pinned: false, windowId: 1, title: '', id: 3 }), 62 | ], 63 | 'home', 64 | true, 65 | ); 66 | expect(tabsToArchive.map(x => x.id)).to.have.members([1, 3]); 67 | expect(tabsToClose).to.be.empty; 68 | }); 69 | 70 | it('should keep pinned tabs', () => { 71 | const [tabsToArchive, tabsToClose] = archivePlan( 72 | [ 73 | testTab({ url: 'home', pinned: false, windowId: 1, title: '', id: 1 }), 74 | testTab({ url: 'foo', pinned: false, windowId: 1, title: '', id: 2 }), 75 | testTab({ url: 'bar', pinned: true, windowId: 2, title: '', id: 2 }), 76 | ], 77 | 'home', 78 | true, 79 | ); 80 | expect(tabsToArchive.map(x => [x.windowId, x.id])).deep.equal([[1, 2]]); 81 | expect(tabsToClose).to.be.empty; 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { JSDOM } from 'jsdom'; 3 | import SinonChrome, * as mockBrowser from 'sinon-chrome'; 4 | import { BROWSER, DOCUMENT } from '../src/lib/globals'; 5 | import { BrowserTab } from '../src/lib/types'; 6 | 7 | class FakeStorageArea implements browser.storage.StorageArea { 8 | map: Map; 9 | 10 | constructor() { 11 | this.map = new Map(); 12 | } 13 | 14 | async get( 15 | getRequest?: string | string[] | { [key: string]: any }, 16 | ): Promise<{ [key: string]: any }> { 17 | let keys: string[]; 18 | const defaults = new Map(); 19 | if (typeof getRequest === 'string') keys = [getRequest]; 20 | else if (Array.isArray(getRequest)) keys = getRequest; 21 | else if (getRequest == undefined) keys = Array.from(this.map.keys()); 22 | else { 23 | for (const prop in getRequest) { 24 | keys.push(prop); 25 | defaults.set(prop, getRequest[prop]); 26 | } 27 | } 28 | const retval: { [key: string]: any } = {}; 29 | for (const key of keys) { 30 | const val: any = this.map.get(key) || defaults.get(key); 31 | if (val !== null) retval[key] = val; 32 | } 33 | return retval; 34 | } 35 | async set(items: { [key: string]: any }): Promise { 36 | for (const prop in items) { 37 | this.map.set(prop, items[prop]); 38 | } 39 | } 40 | async remove(removeRequest: string | string[]): Promise { 41 | let keys: string[]; 42 | if (typeof removeRequest === 'string') keys = [removeRequest]; 43 | else keys = removeRequest; 44 | for (const key in keys) { 45 | this.map.delete(key); 46 | } 47 | } 48 | async clear(): Promise { 49 | this.map.clear(); 50 | } 51 | } 52 | 53 | function stubTSXDomForTesting(window: Window): void { 54 | // @ts-ignore 55 | global.document = window.document; 56 | // @ts-ignore 57 | global.HTMLElement = window.HTMLElement; 58 | } 59 | 60 | export async function stubGlobalsForTesting(): Promise { 61 | BROWSER.set(mockBrowser as any); 62 | const jsdom = await JSDOM.fromFile('src/app.html'); 63 | DOCUMENT.set(jsdom.window.document); 64 | mockBrowser.tabs.query.returns([]); 65 | 66 | stubTSXDomForTesting(jsdom.window); 67 | 68 | const fakeStorage = new FakeStorageArea(); 69 | (mockBrowser['storage'] as any) = { 70 | local: { 71 | get: fakeStorage.get.bind(fakeStorage), 72 | set: fakeStorage.set.bind(fakeStorage), 73 | remove: fakeStorage.get.bind(fakeStorage), 74 | clear: fakeStorage.clear.bind(fakeStorage), 75 | }, 76 | }; 77 | } 78 | 79 | export function unstubGlobals(): void { 80 | BROWSER.set(null); 81 | DOCUMENT.set(null); 82 | } 83 | 84 | export function mockedBrowser(): typeof SinonChrome { 85 | return (BROWSER.get() as any) as typeof SinonChrome; 86 | } 87 | 88 | export function testTab(args: Partial): BrowserTab { 89 | return { 90 | index: 1, 91 | highlighted: true, 92 | active: true, 93 | pinned: true, 94 | incognito: false, 95 | ...args, 96 | }; 97 | } 98 | 99 | export function getElements(query: string, parent: ParentNode): Element[] { 100 | const nodes = parent.querySelectorAll(query); 101 | return Array.from(nodes); 102 | } 103 | 104 | export function assertElement( 105 | query: string, 106 | parent: ParentNode = null, 107 | total = 1, 108 | idx = 1, 109 | ): Element { 110 | const nodes = getElements(query, parent); 111 | expect(nodes.length).to.equal(total); 112 | if (total != 0) return nodes[idx - 1]; 113 | return null; 114 | } 115 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const path = require('path'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const WebpackShellPlugin = require('webpack-shell-plugin-next'); 7 | 8 | // see https://webpack.js.org/configuration/ 9 | module.exports = { 10 | mode: process.env.NODE_ENV || 'production', 11 | devtool: process.env.NODE_ENV === 'development' ? 'inline-source-map' : '', 12 | entry: { 13 | background: './src/background.ts', 14 | app: './src/app.ts', 15 | }, 16 | performance: { 17 | maxEntrypointSize: 512000, 18 | maxAssetSize: 512000, 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | loader: 'ts-loader', 25 | exclude: /node_modules/, 26 | }, 27 | { 28 | test: /\.(css|scss)$/, 29 | use: [ 30 | { 31 | loader: MiniCssExtractPlugin.loader, 32 | options: { 33 | hmr: process.env.NODE_ENV === 'development', 34 | }, 35 | }, 36 | { 37 | // Interprets `@import` and `url()` like `import/require()` and will resolve them 38 | loader: 'css-loader', 39 | }, 40 | { 41 | // Loader for webpack to process CSS with PostCSS. Currently only used for autoprefixer. 42 | loader: 'postcss-loader', 43 | options: { 44 | plugins: function() { 45 | return [require('autoprefixer')]; 46 | }, 47 | }, 48 | }, 49 | { 50 | // Loads a SASS/SCSS file and compiles it to CSS 51 | loader: 'sass-loader', 52 | }, 53 | ], 54 | }, 55 | ], 56 | }, 57 | resolve: { 58 | extensions: ['.ts', '.js', '.tsx'], 59 | }, 60 | output: { 61 | path: path.resolve(__dirname, 'dist'), 62 | filename: '[name].js', 63 | publicPath: '/', 64 | }, 65 | plugins: [ 66 | new CopyPlugin([ 67 | { from: 'manifest.json', to: 'manifest.json' }, 68 | { from: 'assets', to: 'assets' }, 69 | { from: 'src/*.html', to: '', flatten: true }, 70 | ]), 71 | new MiniCssExtractPlugin(), 72 | new WebpackShellPlugin({ 73 | onBuildExit: 'bash build_hook.sh', 74 | }), 75 | // { // anonymous plugin to print actual config 76 | // apply(compiler) { 77 | // compiler.hooks.beforeRun.tapAsync('PrintConfigPlugin', function(compiler, callback) { 78 | // console.dir(compiler.options) 79 | // callback() 80 | // }) 81 | // }, 82 | // } 83 | ], 84 | }; 85 | --------------------------------------------------------------------------------