Version 0.2.0, fix all the things.
This commit is contained in:
parent
596487a835
commit
f7c66feb76
|
@ -4,6 +4,10 @@ logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
|
@ -16,11 +20,12 @@ lib-cov
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
# nyc test coverage
|
# nyc test coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
.grunt
|
.grunt
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
@ -39,12 +44,21 @@ jspm_packages/
|
||||||
# TypeScript v1 declaration files
|
# TypeScript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Optional npm cache directory
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
|
@ -56,17 +70,43 @@ typings/
|
||||||
|
|
||||||
# dotenv environment variables file
|
# dotenv environment variables file
|
||||||
.env
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
# next.js build output
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
.next
|
.next
|
||||||
|
|
||||||
# Profile directories
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Browser profile directories
|
||||||
chromium/
|
chromium/
|
||||||
firefox/
|
firefox/
|
||||||
|
|
||||||
# Parcel cache
|
# Build output directories
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Output directory
|
|
||||||
build/
|
build/
|
||||||
web-ext-artifacts/
|
web-ext-artifacts/
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"stylelint-config-standard-scss"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-descending-specificity": null,
|
||||||
|
"string-quotes": "single"
|
||||||
|
}
|
||||||
|
}
|
38
README.md
38
README.md
|
@ -4,25 +4,45 @@
|
||||||
|
|
||||||
[![Queue on AMO](https://img.shields.io/amo/v/holllo-queue)](https://addons.mozilla.org/firefox/addon/holllo-queue)
|
[![Queue on AMO](https://img.shields.io/amo/v/holllo-queue)](https://addons.mozilla.org/firefox/addon/holllo-queue)
|
||||||
|
|
||||||
![Queue 0.1.5](./screenshots/queue-version-0-1-5.png)
|
![Queue 0.2.0](./screenshots/queue-version-0-2-0.png)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
* Queue is available [through Mozilla Addons](https://addons.mozilla.org/firefox/addon/holllo-queue/).
|
You can install Queue through [Mozilla Addons], [installing from a file] (see [the Releases page] for a prebuilt version) or building [from source](#development).
|
||||||
* Or via manual installation by either building from source yourself or using a prebuilt version available in the [Releases page](https://github.com/Holllo/queue/releases).
|
|
||||||
|
[installing from a file]: https://support.mozilla.org/en-US/kb/find-and-install-add-ons-add-features-to-firefox#w_how-do-i-find-and-install-add-ons
|
||||||
|
[Mozilla Addons]: https://addons.mozilla.org/firefox/addon/holllo-queue/
|
||||||
|
[the Releases page]: https://github.com/Holllo/queue/releases
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
[NodeJS](https://nodejs.org) and [Yarn](https://yarnpkg.com) are required to build and develop the extension. As well as a relatively recent version of [Firefox](https://www.mozilla.org/firefox/).
|
To build Queue you will need [git], [NodeJS] and [pnpm]. Then from a terminal, run the following commands.
|
||||||
|
|
||||||
To test the extension, run `yarn start:firefox`.
|
[git]: https://git-scm.com
|
||||||
|
[NodeJS]: https://nodejs.org
|
||||||
|
[pnpm]: https://pnpm.io
|
||||||
|
|
||||||
To develop and reload the extension on changes, run `yarn watch` in one terminal and `yarn start:firefox` in another.
|
```sh
|
||||||
|
# Step 1. Download the repository with Git.
|
||||||
|
git clone https://github.com/Holllo/queue
|
||||||
|
cd queue
|
||||||
|
|
||||||
## Changelog
|
# Step 2. Install the dependencies.
|
||||||
|
pnpm install
|
||||||
|
|
||||||
All changes made can be found for every version in [Releases page](https://github.com/Holllo/queue/releases).
|
# Step 3. Start an auto-reloading browser instance for development.
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Step 4. Lint the code and run tests.
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Step 5. Build the WebExtension for production.
|
||||||
|
# See the web-ext-artifacts directory for output.
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under [AGPL-3.0-or-later](https://github.com/Holllo/queue/blob/main/LICENSE).
|
Queue is open-sourced with the [AGPL-3.0-or-later] license.
|
||||||
|
|
||||||
|
[AGPL-3.0-or-later]: https://github.com/Holllo/queue/blob/main/LICENSE
|
||||||
|
|
105
package.json
105
package.json
|
@ -1,70 +1,79 @@
|
||||||
{
|
{
|
||||||
"name": "queue",
|
"name": "queue",
|
||||||
"description": "A WebExtension for queueing links.",
|
"description": "A WebExtension for queueing links.",
|
||||||
"author": "Holllo <helllo@holllo.cc>",
|
|
||||||
"repository": "https://github.com/Holllo/queue",
|
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Holllo <helllo@holllo.cc>",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Holllo/queue"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "NODE_ENV=development parcel 'source/manifest.json' -d 'build/' --no-hmr",
|
"start": "vite build -m development --watch",
|
||||||
"start": "web-ext run --source-dir 'build/' --bc",
|
"clean": "trash build web-ext-artifacts",
|
||||||
"start:chromium": "mkdir -p 'chromium/' && yarn start --chromium-profile 'chromium/' --keep-profile-changes --target chromium --start-url \"chrome://extensions\"",
|
"build": "pnpm clean && vite build && web-ext build --source-dir build && pnpm zip-source",
|
||||||
"start:firefox": "mkdir -p 'firefox/' && yarn start --firefox-profile 'firefox/' --keep-profile-changes --target firefox-desktop --start-url \"about:debugging#/runtime/this-firefox\"",
|
"zip-source": "git archive --format zip --output web-ext-artifacts/queue-source.zip HEAD",
|
||||||
"clean": "trash '.cache/' 'build/' 'web-ext-artifacts/'",
|
"test": "xo && stylelint 'source/**/*.scss' && tsc && c8 ava"
|
||||||
"build": "yarn clean && parcel build 'source/manifest.json' -d 'build/' && web-ext build --source-dir 'build/' && yarn zip-source",
|
|
||||||
"zip-source": "zip -r 'web-ext-artifacts/queue-source.zip' 'README.md' 'yarn.lock' 'tsconfig.json' 'package.json' '.gitignore' 'LICENSE' 'source/'",
|
|
||||||
"test": "xo && stylelint 'source/scss/**/*.scss' && tsc"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htm": "^3.1.0",
|
"htm": "^3.1.0",
|
||||||
|
"migration-helper": "^0.1.2",
|
||||||
"modern-normalize": "^1.1.0",
|
"modern-normalize": "^1.1.0",
|
||||||
"preact": "^10.5.14",
|
"preact": "^10.6.6",
|
||||||
"webextension-polyfill": "^0.8.0"
|
"webextension-polyfill": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/webextension-polyfill": "^0.8.0",
|
"@preact/preset-vite": "^2.1.7",
|
||||||
"parcel-bundler": "^1.12.5",
|
"@types/webextension-polyfill": "^0.8.2",
|
||||||
"parcel-plugin-web-extension": "^1.6.1",
|
"ava": "^4.0.1",
|
||||||
"sass": "^1.38.2",
|
"c8": "^7.11.0",
|
||||||
"stylelint": "^13.13.1",
|
"postcss": "^8.4.7",
|
||||||
"stylelint-config-xo-scss": "^0.14.0",
|
"sass": "^1.49.9",
|
||||||
"stylelint-config-xo-space": "^0.15.1",
|
"stylelint": "^14.5.3",
|
||||||
"trash-cli": "^4.0.0",
|
"stylelint-config-standard-scss": "^3.0.0",
|
||||||
"typescript": "^4.4.2",
|
"trash-cli": "^5.0.0",
|
||||||
"web-ext": "^6.3.0",
|
"ts-node": "^10.6.0",
|
||||||
"xo": "^0.44.0"
|
"typescript": "^4.5.5",
|
||||||
|
"vite": "^2.8.4",
|
||||||
|
"vite-plugin-web-extension": "^1.1.2",
|
||||||
|
"web-ext": "^6.7.0",
|
||||||
|
"xo": "^0.48.0"
|
||||||
},
|
},
|
||||||
"stylelint": {
|
"ava": {
|
||||||
"extends": [
|
"extensions": [
|
||||||
"stylelint-config-xo-scss",
|
"ts"
|
||||||
"stylelint-config-xo-space"
|
|
||||||
],
|
],
|
||||||
"ignoreFiles": [
|
"files": [
|
||||||
"source/**/*.ts",
|
"tests/**/*.test.ts"
|
||||||
"build/**"
|
|
||||||
],
|
],
|
||||||
"rules": {
|
"require": [
|
||||||
"scss/at-rule-no-unknown": null,
|
"ts-node/register"
|
||||||
"at-rule-empty-line-before": null,
|
],
|
||||||
"at-rule-no-unknown": null,
|
"snapshotDir": "tests/snapshots"
|
||||||
"block-no-empty": null,
|
},
|
||||||
"no-descending-specificity": null
|
"c8": {
|
||||||
}
|
"include": [
|
||||||
|
"source",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"reportDir": "coverage",
|
||||||
|
"reporter": [
|
||||||
|
"text",
|
||||||
|
"html"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"xo": {
|
"xo": {
|
||||||
"globals": [
|
"overrides": [
|
||||||
"document",
|
{
|
||||||
"window"
|
"files": "tests/**/*.test.ts",
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/triple-slash-reference": "off",
|
||||||
|
"import/extensions": "off",
|
||||||
|
"no-await-in-loop": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"prettier": true,
|
"prettier": true,
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-implicit-any-catch": "off",
|
|
||||||
"@typescript-eslint/no-loop-func": "off",
|
|
||||||
"no-await-in-loop": "off"
|
|
||||||
},
|
|
||||||
"space": true
|
"space": true
|
||||||
},
|
}
|
||||||
"browserslist": [
|
|
||||||
"last 2 Chrome versions"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
Binary file not shown.
After Width: | Height: | Size: 119 KiB |
|
@ -0,0 +1,48 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
import {Settings} from '../settings/settings.js';
|
||||||
|
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
export async function browserActionClicked(): Promise<void> {
|
||||||
|
const settings = await Settings.fromSyncStorage();
|
||||||
|
|
||||||
|
// When the extension icon is initially clicked, create a timeout for 500ms
|
||||||
|
// that will open the next queue item when it expires.
|
||||||
|
// If the icon is clicked again in those 500ms, open the options page instead.
|
||||||
|
if (timeoutId === undefined) {
|
||||||
|
timeoutId = window.setTimeout(async () => {
|
||||||
|
timeoutId = undefined;
|
||||||
|
|
||||||
|
const nextItem = settings.nextQueueItem();
|
||||||
|
|
||||||
|
if (nextItem === undefined) {
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = await browser.tabs.query({
|
||||||
|
active: true,
|
||||||
|
currentWindow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const message: Queue.Message<Queue.Item> = {
|
||||||
|
action: 'queue open url',
|
||||||
|
data: nextItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.tabs.sendMessage(tabs[0].id!, message);
|
||||||
|
} catch {
|
||||||
|
await browser.tabs.create({active: true, url: nextItem.url});
|
||||||
|
}
|
||||||
|
|
||||||
|
await settings.removeQueueItem(nextItem.id);
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
timeoutId = undefined;
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
import {Settings} from '../settings/settings.js';
|
||||||
|
import {updateBadge} from '../utilities/badge.js';
|
||||||
|
|
||||||
|
const contextMenus: browser.Menus.CreateCreatePropertiesType[] = [
|
||||||
|
{
|
||||||
|
id: 'queue-add-new-link',
|
||||||
|
title: 'Add to Queue',
|
||||||
|
contexts: ['link'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'queue-add-new-link-tab',
|
||||||
|
title: 'Add to Queue',
|
||||||
|
contexts: ['tab'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'queue-open-next-link-in-new-tab',
|
||||||
|
title: 'Open next link in new tab',
|
||||||
|
contexts: ['browser_action'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'queue-open-options-page',
|
||||||
|
title: 'Open the extension page',
|
||||||
|
contexts: ['browser_action'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const contextMenuIds = new Set<string>(
|
||||||
|
contextMenus.map(({id}) => id ?? 'queue-unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function initializeContextMenus(): void {
|
||||||
|
for (const contextMenu of contextMenus) {
|
||||||
|
browser.contextMenus.create(contextMenu, contextCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.contextMenus.onClicked.addListener(contextClicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextCreated(): void {
|
||||||
|
const error = browser.runtime.lastError;
|
||||||
|
|
||||||
|
if (error !== null && error !== undefined) {
|
||||||
|
console.error('Queue', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contextClicked(
|
||||||
|
info: browser.Menus.OnClickData,
|
||||||
|
tab?: browser.Tabs.Tab,
|
||||||
|
): Promise<void> {
|
||||||
|
const id = info.menuItemId.toString();
|
||||||
|
if (!contextMenuIds.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await Settings.fromSyncStorage();
|
||||||
|
|
||||||
|
if (id.startsWith('queue-add-new-link')) {
|
||||||
|
let text: string | undefined;
|
||||||
|
let url: string | undefined;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 'queue-add-new-link':
|
||||||
|
text = info.linkText;
|
||||||
|
url = info.linkUrl;
|
||||||
|
break;
|
||||||
|
case 'queue-add-new-link-tab':
|
||||||
|
text = tab?.title;
|
||||||
|
url = info.pageUrl;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`Encountered unknown context menu ID: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === undefined) {
|
||||||
|
console.warn('Cannot add a new item without a URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await settings.insertQueueItem(text ?? url, url);
|
||||||
|
await updateBadge(settings);
|
||||||
|
} else if (id === 'queue-open-next-link-in-new-tab') {
|
||||||
|
const nextItem = settings.nextQueueItem();
|
||||||
|
if (nextItem === undefined) {
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
} else {
|
||||||
|
await browser.tabs.create({active: true, url: nextItem.url});
|
||||||
|
await settings.removeQueueItem(nextItem.id);
|
||||||
|
await settings.save();
|
||||||
|
}
|
||||||
|
} else if (id === 'queue-open-options-page') {
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
import {Settings} from '../settings/settings.js';
|
||||||
|
import {updateBadge} from '../utilities/badge.js';
|
||||||
|
import {History} from '../utilities/history.js';
|
||||||
|
import {browserActionClicked} from './browser-action.js';
|
||||||
|
import {initializeContextMenus} from './context-menus.js';
|
||||||
|
|
||||||
|
browser.runtime.onStartup.addListener(async () => {
|
||||||
|
console.debug('Clearing history.');
|
||||||
|
await History.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.browserAction.onClicked.addListener(browserActionClicked);
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(async () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
await browser.runtime.openOptionsPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener(
|
||||||
|
async (request: Queue.Message<unknown>) => {
|
||||||
|
if (request.action === 'queue update badge') {
|
||||||
|
const settings = await Settings.fromSyncStorage();
|
||||||
|
await updateBadge(settings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
initializeContextMenus();
|
|
@ -1,181 +0,0 @@
|
||||||
import browser, {Menus} from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {debug, error} from './utilities/log';
|
|
||||||
import {getManifest, updateBadge} from './utilities/browser';
|
|
||||||
import {versionAsNumber} from './utilities/version';
|
|
||||||
import Settings from './utilities/settings';
|
|
||||||
import {migrate} from './utilities/migrations';
|
|
||||||
import {Queue} from './types.d';
|
|
||||||
|
|
||||||
let timeoutID: number | null = null;
|
|
||||||
|
|
||||||
browser.runtime.onStartup.addListener(async () => {
|
|
||||||
debug('Removing history from local storage');
|
|
||||||
await browser.storage.local.remove('history');
|
|
||||||
});
|
|
||||||
|
|
||||||
browser.browserAction.onClicked.addListener(async () => {
|
|
||||||
const settings = await Settings.get();
|
|
||||||
|
|
||||||
// When the extension icon is initially clicked, create a timeout for 500ms
|
|
||||||
// that will open the next queue item when it expires.
|
|
||||||
// If the icon is clicked again in those 500ms, open the options page instead.
|
|
||||||
if (timeoutID === null) {
|
|
||||||
timeoutID = window.setTimeout(async () => {
|
|
||||||
timeoutID = null;
|
|
||||||
const nextItem = settings.nextItem();
|
|
||||||
|
|
||||||
if (nextItem === undefined) {
|
|
||||||
await openOptionsPage();
|
|
||||||
} else {
|
|
||||||
const tabs = await browser.tabs.query({
|
|
||||||
currentWindow: true,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const message: Queue.Message<Queue.Item> = {
|
|
||||||
action: 'queue open url',
|
|
||||||
data: nextItem,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await browser.tabs.sendMessage(tabs[0].id!, message);
|
|
||||||
} catch {
|
|
||||||
await browser.tabs.create({active: true, url: nextItem.url});
|
|
||||||
}
|
|
||||||
|
|
||||||
await settings.removeItem(nextItem.id);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
window.clearTimeout(timeoutID);
|
|
||||||
timeoutID = null;
|
|
||||||
await openOptionsPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(
|
|
||||||
async (request: Queue.Message<unknown>) => {
|
|
||||||
if (request.action === 'queue update badge') {
|
|
||||||
await updateBadge(await Settings.get());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
browser.runtime.onInstalled.addListener(async () => {
|
|
||||||
const manifest = getManifest();
|
|
||||||
const settings = await Settings.get();
|
|
||||||
const versionGotUpdated =
|
|
||||||
versionAsNumber(manifest.version) > versionAsNumber(settings.latestVersion);
|
|
||||||
|
|
||||||
if (versionGotUpdated) {
|
|
||||||
// Set the previous sync storage data in the local storage as a backup.
|
|
||||||
const previous = await browser.storage.sync.get();
|
|
||||||
await browser.storage.local.clear();
|
|
||||||
await browser.storage.local.set(previous);
|
|
||||||
|
|
||||||
// Then migrate the sync storage data and update it.
|
|
||||||
const next = migrate(settings.latestVersion, previous);
|
|
||||||
next.latestVersion = manifest.version;
|
|
||||||
next.versionGotUpdated = versionGotUpdated;
|
|
||||||
|
|
||||||
await browser.storage.sync.clear();
|
|
||||||
await browser.storage.sync.set(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the options page when:
|
|
||||||
// * The extension is first installed or is updated.
|
|
||||||
// * In development, for convenience.
|
|
||||||
if (versionGotUpdated || manifest.nodeEnv === 'development') {
|
|
||||||
await openOptionsPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openOptionsPage() {
|
|
||||||
return browser.runtime.openOptionsPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The callback function for custom context menu entries. */
|
|
||||||
function contextCreated() {
|
|
||||||
if (
|
|
||||||
browser.runtime.lastError !== null &&
|
|
||||||
browser.runtime.lastError !== undefined
|
|
||||||
) {
|
|
||||||
error(browser.runtime.lastError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextMenus: Menus.CreateCreatePropertiesType[] = [
|
|
||||||
{
|
|
||||||
id: 'queue-add-new-link',
|
|
||||||
title: 'Add to Queue',
|
|
||||||
contexts: ['link'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-add-new-link-tab',
|
|
||||||
title: 'Add to Queue',
|
|
||||||
contexts: ['tab'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-open-next-link-in-new-tab',
|
|
||||||
title: 'Open next link in new tab',
|
|
||||||
contexts: ['browser_action'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-open-options-page',
|
|
||||||
title: 'Open the extension page',
|
|
||||||
contexts: ['browser_action'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const contextMenuIDs: Set<string> = new Set(
|
|
||||||
contextMenus.map((value) => value.id!),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const menu of contextMenus) {
|
|
||||||
browser.contextMenus.create(menu, contextCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
||||||
const id = info.menuItemId.toString();
|
|
||||||
if (!contextMenuIDs.has(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await Settings.get();
|
|
||||||
|
|
||||||
if (id.includes('queue-add-new-link')) {
|
|
||||||
let text: string | undefined;
|
|
||||||
let url: string;
|
|
||||||
if (info.menuItemId === 'queue-add-new-link') {
|
|
||||||
text = info.linkText;
|
|
||||||
url = info.linkUrl!;
|
|
||||||
} else if (info.menuItemId === 'queue-add-new-link-tab') {
|
|
||||||
text = tab?.title;
|
|
||||||
url = info.pageUrl!;
|
|
||||||
} else {
|
|
||||||
error(`Unknown menuItemId: ${info.menuItemId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.queue.push({
|
|
||||||
added: new Date(),
|
|
||||||
id: settings.newItemId(),
|
|
||||||
text: text ?? url,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
await settings.save();
|
|
||||||
await updateBadge(settings);
|
|
||||||
} else if (id === 'queue-open-next-link-in-new-tab') {
|
|
||||||
const nextItem = settings.nextItem();
|
|
||||||
if (nextItem === undefined) {
|
|
||||||
await openOptionsPage();
|
|
||||||
} else {
|
|
||||||
await browser.tabs.create({active: true, url: nextItem.url});
|
|
||||||
await settings.removeItem(nextItem.id);
|
|
||||||
}
|
|
||||||
} else if (id === 'queue-open-options-page') {
|
|
||||||
await openOptionsPage();
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,8 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {backgroundHandler} from './utilities/browser';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
backgroundHandler();
|
|
||||||
await browser.runtime.sendMessage({action: 'queue update badge'});
|
|
||||||
})();
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {initializeMessaging, sendMessage} from '../utilities/messaging.js';
|
||||||
|
|
||||||
|
async function initializeScripts() {
|
||||||
|
initializeMessaging();
|
||||||
|
await sendMessage('queue update badge');
|
||||||
|
}
|
||||||
|
|
||||||
|
void initializeScripts();
|
|
@ -1,21 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Queue</title>
|
|
||||||
<link rel="shortcut icon" href="./assets/queue.png" type="image/png">
|
|
||||||
<link rel="stylesheet" href="./scss/modern-normalize.scss">
|
|
||||||
<link rel="stylesheet" href="./scss/index.scss">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="love">
|
|
||||||
<noscript>
|
|
||||||
This web extension does not work without JavaScript, sorry. :(
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<script src="./settings-page.ts"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"nodeEnv": "development"
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "Queue",
|
"name": "Queue",
|
||||||
"description": "A WebExtension for queueing links.",
|
"description": "A WebExtension for queueing links.",
|
||||||
"version": "0.1.9",
|
"version": "0.2.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"contextMenus",
|
"contextMenus",
|
||||||
"storage",
|
"storage",
|
||||||
|
@ -12,25 +12,25 @@
|
||||||
],
|
],
|
||||||
"content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'",
|
"content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'",
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
"./assets/**"
|
"assets/**"
|
||||||
],
|
],
|
||||||
"icons": {
|
"icons": {
|
||||||
"128": "./assets/queue.png"
|
"128": "assets/queue.png"
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"scripts": [
|
|
||||||
"./background.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
"128": "./assets/queue.png"
|
"128": "assets/queue.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
"page": "./index.html",
|
"page": "options/index.html",
|
||||||
"open_in_tab": true
|
"open_in_tab": true
|
||||||
},
|
},
|
||||||
|
"background": {
|
||||||
|
"scripts": [
|
||||||
|
"background-scripts/initialize.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
],
|
],
|
||||||
"run_at": "document_end",
|
"run_at": "document_end",
|
||||||
"js": [
|
"js": [
|
||||||
"./content-scripts.ts"
|
"content-scripts/initialize.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './confirm-button.js';
|
||||||
|
export * from './link.js';
|
||||||
|
export * from './page-footer.js';
|
||||||
|
export * from './page-header.js';
|
||||||
|
export * from './page-main.js';
|
|
@ -0,0 +1,72 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
// Extra classes to add to the button.
|
||||||
|
cssClass: string;
|
||||||
|
// The click handler to call when confirmed.
|
||||||
|
clickHandler: (event: MouseEvent) => void;
|
||||||
|
// The class to add when in the confirm state.
|
||||||
|
confirmClass: string;
|
||||||
|
// The text to use when in the confirm state.
|
||||||
|
confirmText: string;
|
||||||
|
// The text to use when in the default state.
|
||||||
|
text: string;
|
||||||
|
// The timeout for the confirm state to return back to default.
|
||||||
|
timeout: number;
|
||||||
|
// The title to add to the element.
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
isConfirmed: boolean;
|
||||||
|
timeoutHandle?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConfirmButton extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
isConfirmed: false,
|
||||||
|
timeoutHandle: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
onClick = (event: MouseEvent) => {
|
||||||
|
const {clickHandler, timeout} = this.props;
|
||||||
|
const {isConfirmed, timeoutHandle} = this.state;
|
||||||
|
|
||||||
|
if (isConfirmed) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
clickHandler(event);
|
||||||
|
this.setState({
|
||||||
|
isConfirmed: false,
|
||||||
|
timeoutHandle: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isConfirmed: true,
|
||||||
|
timeoutHandle: window.setTimeout(() => {
|
||||||
|
this.setState({isConfirmed: false});
|
||||||
|
}, timeout),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {confirmClass, confirmText, cssClass, text, title} = this.props;
|
||||||
|
const {isConfirmed} = this.state;
|
||||||
|
|
||||||
|
const confirmedClass = isConfirmed ? confirmClass : '';
|
||||||
|
const buttonText = isConfirmed ? confirmText : text;
|
||||||
|
const buttonTitle = isConfirmed ? `Confirm ${title}` : title;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="${cssClass} ${confirmedClass}"
|
||||||
|
onClick=${this.onClick}
|
||||||
|
title="${buttonTitle}"
|
||||||
|
>
|
||||||
|
${buttonText}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cssClass: string;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Link extends Component<Props> {
|
||||||
|
render() {
|
||||||
|
const {cssClass, text, url} = this.props;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<a class=${cssClass} href=${url} target="_blank" rel="noopener">
|
||||||
|
${text}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
import {Settings} from '../../settings/settings.js';
|
||||||
|
import {Link} from './link.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PageFooter extends Component<Props> {
|
||||||
|
render() {
|
||||||
|
const {settings} = this.props;
|
||||||
|
const version = settings.manifest.version;
|
||||||
|
|
||||||
|
const donateLink = html`
|
||||||
|
<${Link} text="Donate" url="https://github.com/sponsors/Bauke" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
const versionLink = html`
|
||||||
|
<${Link}
|
||||||
|
text="v${version}"
|
||||||
|
url="https://github.com/Holllo/queue/releases/tag/${version}"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<footer class="page-footer">
|
||||||
|
<p>
|
||||||
|
${donateLink} 💖 ${versionLink} © Holllo — Free and open-source,
|
||||||
|
forever.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
export class PageHeader extends Component {
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>
|
||||||
|
<span class="icon">⇥</span>
|
||||||
|
Queue
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,56 +1,62 @@
|
||||||
import {Component, html} from 'htm/preact';
|
import {Component, html} from 'htm/preact';
|
||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
import Settings from '../settings';
|
import {Settings} from '../../settings/settings.js';
|
||||||
import {Queue} from '../../types.d';
|
import {History} from '../../utilities/history.js';
|
||||||
import {Link} from './link';
|
import {ConfirmButton} from './confirm-button.js';
|
||||||
import {ConfirmButton} from './confirm-button';
|
import {Link} from './link.js';
|
||||||
|
|
||||||
type MainProps = {
|
type Props = {
|
||||||
history: Queue.Item[];
|
history: History;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MainState = {
|
type State = {
|
||||||
queue: Queue.Item[];
|
queue: Queue.Item[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PageMain extends Component<MainProps, MainState> {
|
export class PageMain extends Component<Props, State> {
|
||||||
constructor(props: MainProps) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
queue: props.settings.queue,
|
queue: props.settings.queue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
removeItem = async (id: number) => {
|
removeItem = async (id: number) => {
|
||||||
await this.props.settings.removeItem(id);
|
const {settings} = this.props;
|
||||||
this.setState({queue: this.props.settings.queue});
|
await settings.removeQueueItem(id);
|
||||||
await browser.runtime.sendMessage({action: 'queue update badge'});
|
await browser.runtime.sendMessage({action: 'queue update badge'});
|
||||||
|
this.setState({queue: this.props.settings.queue});
|
||||||
};
|
};
|
||||||
|
|
||||||
render(): Queue.Component {
|
render() {
|
||||||
const queueItems: Queue.Component[] = this.state.queue
|
const queueItems = this.state.queue
|
||||||
.sort((a, b) => a.added.getTime() - b.added.getTime())
|
.sort((a, b) => a.added.getTime() - b.added.getTime())
|
||||||
.map((item) => html`<${Item} item=${item} remove=${this.removeItem} />`);
|
.map(
|
||||||
|
(item) => html`<${queueItem} item=${item} remove=${this.removeItem} />`,
|
||||||
|
);
|
||||||
|
|
||||||
if (queueItems.length === 0) {
|
if (queueItems.length === 0) {
|
||||||
queueItems.push(html`<li>No items queued. 🤷</li>`);
|
queueItems.push(html`<li>No items queued. 🤷</li>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyItems: Queue.Component[] = this.props.history
|
const historyItems = this.props.history.queue
|
||||||
.sort((a, b) => b.added.getTime() - a.added.getTime())
|
.sort((a, b) => b.added.getTime() - a.added.getTime())
|
||||||
.map((item) => html`<${Item} item=${item} />`);
|
.map((item) => html`<${queueItem} item=${item} />`);
|
||||||
|
|
||||||
let history: Queue.Component | undefined;
|
let history: HtmComponent | undefined;
|
||||||
if (historyItems.length > 0) {
|
if (historyItems.length > 0) {
|
||||||
history = html`<details class="history">
|
history = html`
|
||||||
|
<details class="history">
|
||||||
<summary>Queue history</summary>
|
<summary>Queue history</summary>
|
||||||
|
|
||||||
<ul class="q-list">
|
<ul class="q-list">
|
||||||
${historyItems}
|
${historyItems}
|
||||||
</ul>
|
</ul>
|
||||||
</details>`;
|
</details>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
@ -108,20 +114,22 @@ type ItemProps = {
|
||||||
remove?: (id: number) => Promise<void>;
|
remove?: (id: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Item(props: ItemProps): Queue.Component {
|
function queueItem(props: ItemProps): HtmComponent {
|
||||||
const added = props.item.added.toLocaleString();
|
const added = props.item.added.toLocaleString();
|
||||||
const {id, text, url} = props.item;
|
const {id, text, url} = props.item;
|
||||||
let remove;
|
let remove;
|
||||||
if (props.remove !== undefined) {
|
if (props.remove !== undefined) {
|
||||||
remove = html`<${ConfirmButton}
|
remove = html`
|
||||||
class="confirm-button"
|
<${ConfirmButton}
|
||||||
|
cssClass="confirm-button"
|
||||||
clickHandler=${async () => props.remove!(id)}
|
clickHandler=${async () => props.remove!(id)}
|
||||||
confirmClass="confirm"
|
confirmClass="confirm"
|
||||||
confirmText="✓"
|
confirmText="✓"
|
||||||
text="✗"
|
text="✗"
|
||||||
timeout=${5 * 1000}
|
timeout=${5 * 1000}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
/>`;
|
/>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Queue</title>
|
||||||
|
<link rel="stylesheet" href="./index.scss">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="love">
|
||||||
|
<noscript>
|
||||||
|
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script type="module" src="./index.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,28 @@
|
||||||
|
@use '../../node_modules/modern-normalize/modern-normalize.css';
|
||||||
|
@use 'scss/reset';
|
||||||
|
@use 'scss/mixins';
|
||||||
|
@use 'scss/love';
|
||||||
|
|
||||||
|
// Component styles
|
||||||
|
@use 'scss/components/page-header';
|
||||||
|
@use 'scss/components/page-main';
|
||||||
|
@use 'scss/components/page-footer';
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 62.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--db-1);
|
||||||
|
color: var(--df-1);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--da-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--df-2);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Component, render} from 'preact';
|
||||||
|
|
||||||
|
import {Settings} from '../settings/settings.js';
|
||||||
|
import {initializeMessaging, sendMessage} from '../utilities/messaging.js';
|
||||||
|
import {History} from '../utilities/history.js';
|
||||||
|
import {PageFooter, PageHeader, PageMain} from './components/components.js';
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
initializeMessaging();
|
||||||
|
await sendMessage('queue update badge');
|
||||||
|
|
||||||
|
const history = await History.fromLocalStorage();
|
||||||
|
const settings = await Settings.fromSyncStorage();
|
||||||
|
|
||||||
|
render(
|
||||||
|
html`<${OptionsPage} history=${history} settings=${settings} />`,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
history: Queue.Item[];
|
||||||
|
settings: Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OptionsPage extends Component<Props> {
|
||||||
|
render() {
|
||||||
|
const {history, settings} = this.props;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<${PageHeader} />
|
||||||
|
<${PageMain} history=${history} settings=${settings} />
|
||||||
|
<${PageFooter} settings=${settings} />
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
|
@use '../mixins';
|
||||||
|
|
||||||
.page-footer {
|
.page-footer {
|
||||||
@include responsive-container;
|
@include mixins.responsive-container;
|
||||||
|
|
||||||
border: 1px solid var(--df-2);
|
border: 1px solid var(--df-2);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
|
@ -1,5 +1,8 @@
|
||||||
|
@use '../mixins';
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@include responsive-container;
|
@include mixins.responsive-container;
|
||||||
|
|
||||||
border: 1px solid var(--df-2);
|
border: 1px solid var(--df-2);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
@use '../mixins';
|
||||||
|
|
||||||
.page-main {
|
.page-main {
|
||||||
@include responsive-container;
|
@include mixins.responsive-container;
|
||||||
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +35,7 @@
|
||||||
background-color: var(--la-1);
|
background-color: var(--la-1);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--df-1);
|
color: var(--df-1);
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -40,10 +44,6 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.confirm {
|
&.confirm {
|
||||||
background-color: var(--df-1);
|
background-color: var(--df-1);
|
||||||
color: var(--la-1);
|
color: var(--la-1);
|
|
@ -0,0 +1,11 @@
|
||||||
|
@use 'variables';
|
||||||
|
|
||||||
|
@mixin responsive-container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: variables.$large-breakpoint;
|
||||||
|
|
||||||
|
@media (max-width: variables.$large-breakpoint) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,10 +3,10 @@ h2,
|
||||||
h3,
|
h3,
|
||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
li,
|
|
||||||
ol,
|
ol,
|
||||||
p,
|
ul,
|
||||||
ul {
|
li,
|
||||||
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
$small-breakpoint: 600px;
|
||||||
|
$medium-breakpoint: 900px;
|
||||||
|
$large-breakpoint: 1200px;
|
||||||
|
$extra-large-breakpoint: 1800px;
|
|
@ -1,14 +0,0 @@
|
||||||
$small-breakpoint: 600px;
|
|
||||||
$medium-breakpoint: 900px;
|
|
||||||
$large-breakpoint: 1200px;
|
|
||||||
$extra-large-breakpoint: 1800px;
|
|
||||||
|
|
||||||
@mixin responsive-container {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
width: $large-breakpoint;
|
|
||||||
|
|
||||||
@media (max-width: $large-breakpoint) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
@import 'reset';
|
|
||||||
@import 'variables';
|
|
||||||
@import 'love';
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 62.5%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--db-1);
|
|
||||||
color: var(--df-1);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--da-3);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--df-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@import 'components/page-header';
|
|
||||||
@import 'components/page-main';
|
|
||||||
@import 'components/page-footer';
|
|
|
@ -1 +0,0 @@
|
||||||
@import '../../node_modules/modern-normalize/modern-normalize.css';
|
|
|
@ -1,48 +0,0 @@
|
||||||
import {html, render} from 'htm/preact';
|
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {backgroundHandler, getManifest} from './utilities/browser';
|
|
||||||
import {debug} from './utilities/log';
|
|
||||||
import {PageFooter, PageHeader, PageMain} from './utilities/components';
|
|
||||||
import Settings from './utilities/settings';
|
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
window.HollloQueue = {
|
|
||||||
clearHistory: async () => {
|
|
||||||
debug('Clearing history');
|
|
||||||
await browser.storage.local.remove('history');
|
|
||||||
},
|
|
||||||
dumpBackup: async () => {
|
|
||||||
debug(JSON.stringify(await browser.storage.local.get(), null, 2));
|
|
||||||
},
|
|
||||||
dumpSettings: async () => {
|
|
||||||
debug(JSON.stringify(await Settings.get(), null, 2));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
backgroundHandler();
|
|
||||||
await browser.runtime.sendMessage({action: 'queue update badge'});
|
|
||||||
|
|
||||||
const manifest = getManifest();
|
|
||||||
const settings = await Settings.get();
|
|
||||||
|
|
||||||
const showVersionUpdated = settings.versionGotUpdated;
|
|
||||||
if (showVersionUpdated) {
|
|
||||||
settings.versionGotUpdated = false;
|
|
||||||
await settings.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = await browser.storage.local.get({history: []});
|
|
||||||
|
|
||||||
render(
|
|
||||||
html`
|
|
||||||
<${PageHeader} />
|
|
||||||
<${PageMain} history=${history.history} settings=${settings} />
|
|
||||||
<${PageFooter}
|
|
||||||
manifest=${manifest}
|
|
||||||
showVersionUpdated=${showVersionUpdated}
|
|
||||||
/>
|
|
||||||
`,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
});
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import {Migration} from 'migration-helper';
|
||||||
|
|
||||||
|
export const dataMigrations: Array<Migration<string>> = [
|
||||||
|
{
|
||||||
|
version: '0.1.7',
|
||||||
|
async migrate(data: Record<string, any>) {
|
||||||
|
const migrated: Record<string, any> = {
|
||||||
|
version: '0.1.7',
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = (data.queue as Queue.Item[]) ?? [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `qi${item.id}`;
|
||||||
|
migrated[key] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function deserializeQueue(data: Record<string, any>): Queue.Item[] {
|
||||||
|
const deserialized: Queue.Item[] = [];
|
||||||
|
|
||||||
|
for (const [key, item] of Object.entries(data)) {
|
||||||
|
if (/^qi\d+$/.test(key)) {
|
||||||
|
item.added = new Date(item.added);
|
||||||
|
deserialized.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeQueue(queue: Queue.Item[]): Record<string, any> {
|
||||||
|
const serialized: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const item of queue) {
|
||||||
|
const key = `qi${item.id}`;
|
||||||
|
serialized[key] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
import {migrate} from 'migration-helper';
|
||||||
|
|
||||||
|
import {History} from '../utilities/history.js';
|
||||||
|
import {
|
||||||
|
dataMigrations,
|
||||||
|
deserializeQueue,
|
||||||
|
serializeQueue,
|
||||||
|
} from './migrations.js';
|
||||||
|
|
||||||
|
export class Settings {
|
||||||
|
public static async fromSyncStorage(): Promise<Settings> {
|
||||||
|
const settings = new Settings();
|
||||||
|
|
||||||
|
const sync = await browser.storage.sync.get(null);
|
||||||
|
const migrated = (await migrate(
|
||||||
|
sync,
|
||||||
|
sync.version ?? settings.version,
|
||||||
|
dataMigrations,
|
||||||
|
)) as Record<string, any>;
|
||||||
|
|
||||||
|
settings.queue = deserializeQueue(migrated);
|
||||||
|
settings.version = migrated.version as string;
|
||||||
|
|
||||||
|
await settings.save();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public manifest: browser.Manifest.ManifestBase;
|
||||||
|
public queue: Queue.Item[];
|
||||||
|
public version: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.manifest = browser.runtime.getManifest();
|
||||||
|
this.queue = [];
|
||||||
|
this.version = '0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async insertQueueItem(text: string, url: string): Promise<void> {
|
||||||
|
const id = this.newQueueItemId();
|
||||||
|
const item: Queue.Item = {
|
||||||
|
added: new Date(),
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
this.queue.push(item);
|
||||||
|
|
||||||
|
const sync: Record<string, Queue.Item> = {};
|
||||||
|
sync[`qi${id}`] = item;
|
||||||
|
await browser.storage.sync.set(sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
public newQueueItemId(): number {
|
||||||
|
const item = this.queue.sort((a, b) => b.id - a.id)[0];
|
||||||
|
return item === undefined ? 1 : item.id + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextQueueItem(): Queue.Item | undefined {
|
||||||
|
return this.queue.sort((a, b) => a.added.getTime() - b.added.getTime())[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeQueueItem(id: number): Promise<void> {
|
||||||
|
const itemIndex = this.queue.findIndex((item) => item.id === id);
|
||||||
|
if (itemIndex === -1) {
|
||||||
|
console.error(`Tried to remove an item with unknown ID: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedItems = this.queue.splice(itemIndex, 1);
|
||||||
|
await browser.storage.sync.remove(removedItems.map(({id}) => `qi${id}`));
|
||||||
|
|
||||||
|
const history = await History.fromLocalStorage();
|
||||||
|
await history.insertItems(removedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(): Promise<void> {
|
||||||
|
await browser.storage.sync.set({
|
||||||
|
...serializeQueue(this.queue),
|
||||||
|
version: this.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,43 +1,35 @@
|
||||||
import {html} from 'htm/preact';
|
import {html} from 'htm/preact';
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
// TypeScript fix to make it see this file as a module.
|
|
||||||
export {};
|
|
||||||
|
|
||||||
type HollloQueue = {
|
|
||||||
clearHistory: () => Promise<void>;
|
|
||||||
dumpBackup: () => Promise<void>;
|
|
||||||
dumpSettings: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
// See Vite documentation for `import.meta.env` usage.
|
||||||
HollloQueue: HollloQueue;
|
// https://vitejs.dev/guide/env-and-mode.html
|
||||||
}
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Queue {
|
interface ImportMetaEnv {
|
||||||
type Component = ReturnType<typeof html>;
|
readonly BASE_URL: string;
|
||||||
|
readonly DEV: boolean;
|
||||||
|
readonly MODE: string;
|
||||||
|
readonly PROD: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HtmComponent = ReturnType<typeof html>;
|
||||||
|
|
||||||
|
namespace Queue {
|
||||||
type Item = {
|
type Item = {
|
||||||
added: Date;
|
added: Date;
|
||||||
id: number;
|
id: number;
|
||||||
text?: string;
|
text: string;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Manifest = {nodeEnv?: string} & browser.Manifest.ManifestBase;
|
type MessageAction = 'queue open url' | 'queue update badge';
|
||||||
|
|
||||||
type Message<T> = {
|
type Message<T> = {
|
||||||
action: MessageAction;
|
action: MessageAction;
|
||||||
data: T;
|
data: T;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
type MessageAction = 'queue open url' | 'queue update badge';
|
|
||||||
|
|
||||||
type Migration = {
|
|
||||||
date: Date;
|
|
||||||
upgrade: (previous: Record<string, any>) => Record<string, any>;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
import {Settings} from '../settings/settings.js';
|
||||||
|
|
||||||
|
export async function updateBadge(settings: Settings): Promise<void> {
|
||||||
|
const queueLength = settings.queue.length.toString();
|
||||||
|
await browser.browserAction.setBadgeText({
|
||||||
|
text: queueLength === '0' ? null : queueLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.browserAction.setBadgeBackgroundColor({color: '#2a2041'});
|
||||||
|
browser.browserAction.setBadgeTextColor({color: '#f2efff'});
|
||||||
|
}
|
|
@ -1,38 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {Queue} from '../types.d';
|
|
||||||
import Settings from './settings';
|
|
||||||
|
|
||||||
/** Initializes the background message handler. */
|
|
||||||
export function backgroundHandler() {
|
|
||||||
browser.runtime.onMessage.addListener((request: Queue.Message<unknown>) => {
|
|
||||||
if (request.action === 'queue open url') {
|
|
||||||
// @ts-expect-error Changing <unknown> to <Queue.Item>.
|
|
||||||
const message: Queue.Message<Queue.Item> = request;
|
|
||||||
window.location.href = message.data.url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the WebExtension Manifest. */
|
|
||||||
export function getManifest(): Queue.Manifest {
|
|
||||||
return browser.runtime.getManifest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the extension icon badge with the number of saved items. This can
|
|
||||||
* only be run from the background script.
|
|
||||||
*/
|
|
||||||
export async function updateBadge(settings: Settings): Promise<void> {
|
|
||||||
await browser.browserAction.setBadgeBackgroundColor({
|
|
||||||
color: '#2a2041',
|
|
||||||
});
|
|
||||||
|
|
||||||
await browser.browserAction.setBadgeText({
|
|
||||||
text: settings.queue.length === 0 ? null : settings.queue.length.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
browser.browserAction.setBadgeTextColor({
|
|
||||||
color: '#f2efff',
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,58 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
import {useState} from 'preact/hooks';
|
|
||||||
|
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
|
|
||||||
type ConfirmButtonProps = {
|
|
||||||
// Extra classes to add to the button.
|
|
||||||
class: string;
|
|
||||||
// The click handler to call when confirmed.
|
|
||||||
clickHandler: (event: MouseEvent) => void;
|
|
||||||
// The class to add when in the confirm state.
|
|
||||||
confirmClass: string;
|
|
||||||
// The text to use when in the confirm state.
|
|
||||||
confirmText: string;
|
|
||||||
// The text to use when in the default state.
|
|
||||||
text: string;
|
|
||||||
// The timeout for the confirm state to return back to default.
|
|
||||||
timeout: number;
|
|
||||||
// The title to add to the element.
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a button that requires 2 clicks to trigger the main click handler.
|
|
||||||
* @param props The ConfirmButton properties.
|
|
||||||
*/
|
|
||||||
export function ConfirmButton(props: ConfirmButtonProps): Queue.Component {
|
|
||||||
let timeoutHandle: number | undefined;
|
|
||||||
|
|
||||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
|
||||||
const click = (event: MouseEvent) => {
|
|
||||||
if (isConfirmed) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
props.clickHandler(event);
|
|
||||||
timeoutHandle = undefined;
|
|
||||||
setIsConfirmed(false);
|
|
||||||
} else {
|
|
||||||
timeoutHandle = window.setTimeout(() => {
|
|
||||||
setIsConfirmed(false);
|
|
||||||
}, props.timeout);
|
|
||||||
setIsConfirmed(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmedClass = isConfirmed ? props.confirmClass : '';
|
|
||||||
const text = isConfirmed ? props.confirmText : props.text;
|
|
||||||
const title = isConfirmed ? `Confirm ${props.title}` : props.title;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<button
|
|
||||||
class="${props.class} ${confirmedClass}"
|
|
||||||
onClick=${click}
|
|
||||||
title="${title}"
|
|
||||||
>
|
|
||||||
${text}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './page-footer';
|
|
||||||
export * from './page-header';
|
|
||||||
export * from './page-main';
|
|
|
@ -1,20 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
|
|
||||||
type LinkProps = {
|
|
||||||
class: string;
|
|
||||||
text: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new <a/> element with target="_blank" and rel="noopener".
|
|
||||||
* @param props The Link properties.
|
|
||||||
*/
|
|
||||||
export function Link(props: LinkProps): Queue.Component {
|
|
||||||
return html`
|
|
||||||
<a class=${props.class} href=${props.url} target="_blank" rel="noopener">
|
|
||||||
${props.text}
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
|
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
import {Link} from './link';
|
|
||||||
|
|
||||||
type FooterProps = {
|
|
||||||
manifest: Queue.Manifest;
|
|
||||||
showVersionUpdated: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PageFooter(props: FooterProps): Queue.Component {
|
|
||||||
const donateLink = html`<${Link}
|
|
||||||
text="Donate"
|
|
||||||
url="https://liberapay.com/Holllo"
|
|
||||||
/>`;
|
|
||||||
|
|
||||||
const version = props.manifest.version;
|
|
||||||
const versionLink = html`<${Link}
|
|
||||||
text="v${version}"
|
|
||||||
url="https://github.com/Holllo/queue/releases/tag/${version}"
|
|
||||||
/>`;
|
|
||||||
|
|
||||||
const versionUpdated = props.showVersionUpdated ? 'Updated to' : '';
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<footer class="page-footer">
|
|
||||||
<p>
|
|
||||||
${donateLink} ♡ ${versionUpdated} ${versionLink} 🄯 Holllo — Free and
|
|
||||||
open-source, forever.
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
`;
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
|
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
|
|
||||||
export function PageHeader(): Queue.Component {
|
|
||||||
return html`
|
|
||||||
<header class="page-header">
|
|
||||||
<h1>
|
|
||||||
<span class="icon">⇥</span>
|
|
||||||
Queue
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
`;
|
|
||||||
}
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
export class History {
|
||||||
|
public static async clear(): Promise<void> {
|
||||||
|
await browser.storage.local.remove('history');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromLocalStorage(): Promise<History> {
|
||||||
|
const history = new History();
|
||||||
|
|
||||||
|
const stored = await browser.storage.local.get({history: []});
|
||||||
|
history.queue = stored.history as Queue.Item[];
|
||||||
|
|
||||||
|
// Initialize all the non-JSON values since they are stringified when saved.
|
||||||
|
for (const item of history.queue) {
|
||||||
|
item.added = new Date(item.added);
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
public queue: Queue.Item[];
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clear(): Promise<void> {
|
||||||
|
await History.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async insertItems(items: Queue.Item[]): Promise<void> {
|
||||||
|
this.queue = this.queue.concat(items);
|
||||||
|
|
||||||
|
for (const [index, item] of this.queue.entries()) {
|
||||||
|
item.id = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(): Promise<void> {
|
||||||
|
await browser.storage.local.set({history: this.queue});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +0,0 @@
|
||||||
export function debug(input: unknown): void {
|
|
||||||
console.debug('[Queue]', stringify(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function error(input: unknown): void {
|
|
||||||
console.error('[Queue]', stringify(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringify(input: unknown): unknown {
|
|
||||||
if (typeof input === 'object') {
|
|
||||||
input = JSON.stringify(input, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return input;
|
|
||||||
}
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
|
export function initializeMessaging() {
|
||||||
|
browser.runtime.onMessage.addListener((request: Queue.Message<unknown>) => {
|
||||||
|
if (request.action === 'queue open url') {
|
||||||
|
const message = request as Queue.Message<Queue.Item>;
|
||||||
|
window.location.href = message.data.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage<T>(
|
||||||
|
action: Queue.MessageAction,
|
||||||
|
data?: T,
|
||||||
|
): Promise<void> {
|
||||||
|
await browser.runtime.sendMessage({action, data});
|
||||||
|
}
|
|
@ -1,25 +0,0 @@
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
|
|
||||||
export const migration_2020_11_26: Queue.Migration = {
|
|
||||||
date: new Date('2020-11-26T14:32:00.000Z'),
|
|
||||||
version: '0.1.7',
|
|
||||||
upgrade,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This upgrades the sync settings to use 'qi<ID>'-named objects for QItems instead
|
|
||||||
* of them being in an array.
|
|
||||||
* Relevant commit: a668da05a179851b2a1117ef2d6aa9cef48d4964
|
|
||||||
*/
|
|
||||||
function upgrade(previous: Record<string, any>): Record<string, any> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
const items: Queue.Item[] = previous.queue ?? [];
|
|
||||||
const next: Record<string, any> = previous;
|
|
||||||
delete next.queue;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
next['qi' + item.id.toString()] = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import {Queue} from '../../types.d';
|
|
||||||
import {debug} from '../log';
|
|
||||||
import {versionAsNumber} from '../version';
|
|
||||||
import {migration_2020_11_26} from './2020-11-26';
|
|
||||||
|
|
||||||
const migrations: Queue.Migration[] = [migration_2020_11_26];
|
|
||||||
|
|
||||||
export function migrate(
|
|
||||||
latestVersion: string,
|
|
||||||
previous: Record<string, any>,
|
|
||||||
): Record<string, any> {
|
|
||||||
let next = previous;
|
|
||||||
|
|
||||||
for (const migration of migrations) {
|
|
||||||
// If the saved version is >= the version from the migration, we've already
|
|
||||||
// handled it previously, so skip it.
|
|
||||||
if (versionAsNumber(latestVersion) >= versionAsNumber(migration.version)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug(`Running migration ${migration.date.toISOString()}`);
|
|
||||||
next = migration.upgrade(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {Queue} from '../types.d';
|
|
||||||
import {debug} from './log';
|
|
||||||
|
|
||||||
const defaultSettings: ISettings = {
|
|
||||||
latestVersion: '0.0.0',
|
|
||||||
queue: [],
|
|
||||||
versionGotUpdated: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ISettings {
|
|
||||||
latestVersion: string;
|
|
||||||
queue: Queue.Item[];
|
|
||||||
versionGotUpdated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Settings implements ISettings {
|
|
||||||
public latestVersion: string;
|
|
||||||
public queue: Queue.Item[];
|
|
||||||
public versionGotUpdated: boolean;
|
|
||||||
|
|
||||||
private constructor(settings: ISettings) {
|
|
||||||
this.latestVersion = settings.latestVersion;
|
|
||||||
this.queue = settings.queue;
|
|
||||||
this.versionGotUpdated = settings.versionGotUpdated;
|
|
||||||
}
|
|
||||||
|
|
||||||
static async get(): Promise<Settings> {
|
|
||||||
const syncSettings: Record<string, any> = await browser.storage.sync.get();
|
|
||||||
|
|
||||||
// Append properties with a name matching 'qi'x to the queue.
|
|
||||||
const queue: Queue.Item[] = [];
|
|
||||||
|
|
||||||
if (syncSettings !== undefined) {
|
|
||||||
for (const prop in syncSettings) {
|
|
||||||
if (prop.startsWith('qi')) {
|
|
||||||
queue.push(syncSettings[prop]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize all the non-JSON values, as they are stringified when saved.
|
|
||||||
for (const item of queue) {
|
|
||||||
item.added = new Date(item.added);
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestVersion =
|
|
||||||
(syncSettings.latestVersion as string) ?? defaultSettings.latestVersion;
|
|
||||||
|
|
||||||
const versionGotUpdated =
|
|
||||||
(syncSettings.versionGotUpdated as boolean) ??
|
|
||||||
defaultSettings.versionGotUpdated;
|
|
||||||
|
|
||||||
return new Settings({
|
|
||||||
latestVersion,
|
|
||||||
queue,
|
|
||||||
versionGotUpdated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(): Promise<void> {
|
|
||||||
const syncSettings: Record<string, any> = {
|
|
||||||
latestVersion: this.latestVersion,
|
|
||||||
versionGotUpdated: this.versionGotUpdated,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const item of this.queue) {
|
|
||||||
syncSettings['qi' + item.id.toString()] = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.storage.sync.set(syncSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
newItemId(): number {
|
|
||||||
const highestItem = this.queue.sort((a, b) => b.id - a.id)[0];
|
|
||||||
return highestItem === undefined ? 1 : highestItem.id + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextItem(): Queue.Item | undefined {
|
|
||||||
return this.queue.sort((a, b) => a.added.getTime() - b.added.getTime())[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeItem(id: number): Promise<void> {
|
|
||||||
const index = this.queue.findIndex((item) => item.id === id);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
debug(`No Queue.Item with ID ${id} found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed = this.queue.splice(index, 1);
|
|
||||||
const history = (
|
|
||||||
(await browser.storage.local.get({history: []})) as {
|
|
||||||
history: Queue.Item[];
|
|
||||||
}
|
|
||||||
).history;
|
|
||||||
|
|
||||||
history.push(removed[0]);
|
|
||||||
for (const [index, item] of history.entries()) {
|
|
||||||
item.id = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.storage.local.set({history});
|
|
||||||
await browser.storage.sync.remove('qi' + id.toString());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
/** Returns a version string as a number by removing the dots. */
|
|
||||||
export function versionAsNumber(version: string): number {
|
|
||||||
return Number(version.replace(/\./g, ''));
|
|
||||||
}
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/// <reference path="../source/types.d.ts" />
|
||||||
|
|
||||||
|
import test from 'ava';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dataMigrations,
|
||||||
|
deserializeQueue,
|
||||||
|
serializeQueue,
|
||||||
|
} from '../source/settings/migrations';
|
||||||
|
|
||||||
|
const queueItemSample: Queue.Item = {
|
||||||
|
added: new Date('2022-03-02T16:00:00Z'),
|
||||||
|
id: 1,
|
||||||
|
text: 'Sample',
|
||||||
|
url: 'https://example.org',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('dataMigrations happy path', async (t) => {
|
||||||
|
let data: Record<string, any> = {
|
||||||
|
latestVersion: '0.1.0',
|
||||||
|
queue: [queueItemSample],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const migration of dataMigrations) {
|
||||||
|
data = (await migration.migrate(data)) as Record<string, any>;
|
||||||
|
t.snapshot(data, `Migration ${migration.version}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataMigrations unhappy path', async (t) => {
|
||||||
|
let data: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const migration of dataMigrations) {
|
||||||
|
data = (await migration.migrate(data)) as Record<string, any>;
|
||||||
|
t.snapshot(data, `Migration ${migration.version}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Serializing & Deserializing Queue', (t) => {
|
||||||
|
const serialized = serializeQueue([queueItemSample]);
|
||||||
|
t.snapshot(serialized, 'Serialized');
|
||||||
|
|
||||||
|
serialized.extra = 'Extra';
|
||||||
|
serialized.version = '0.0.0';
|
||||||
|
|
||||||
|
const deserialized = deserializeQueue(serialized);
|
||||||
|
t.snapshot(deserialized, 'Deserialized');
|
||||||
|
});
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Snapshot report for `tests/migrations.test.ts`
|
||||||
|
|
||||||
|
The actual snapshot is saved in `migrations.test.ts.snap`.
|
||||||
|
|
||||||
|
Generated by [AVA](https://avajs.dev).
|
||||||
|
|
||||||
|
## dataMigrations happy path
|
||||||
|
|
||||||
|
> Migration 0.1.7
|
||||||
|
|
||||||
|
{
|
||||||
|
qi1: {
|
||||||
|
added: Date 2022-03-02 16:00:00 UTC {},
|
||||||
|
id: 1,
|
||||||
|
text: 'Sample',
|
||||||
|
url: 'https://example.org',
|
||||||
|
},
|
||||||
|
version: '0.1.7',
|
||||||
|
}
|
||||||
|
|
||||||
|
## dataMigrations unhappy path
|
||||||
|
|
||||||
|
> Migration 0.1.7
|
||||||
|
|
||||||
|
{
|
||||||
|
version: '0.1.7',
|
||||||
|
}
|
||||||
|
|
||||||
|
## Serializing & Deserializing Queue
|
||||||
|
|
||||||
|
> Serialized
|
||||||
|
|
||||||
|
{
|
||||||
|
qi1: {
|
||||||
|
added: Date 2022-03-02 16:00:00 UTC {},
|
||||||
|
id: 1,
|
||||||
|
text: 'Sample',
|
||||||
|
url: 'https://example.org',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
> Deserialized
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
added: Date 2022-03-02 16:00:00 UTC {},
|
||||||
|
id: 1,
|
||||||
|
text: 'Sample',
|
||||||
|
url: 'https://example.org',
|
||||||
|
},
|
||||||
|
]
|
Binary file not shown.
|
@ -2,18 +2,23 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2019"
|
"ESNext"
|
||||||
],
|
],
|
||||||
"module": "commonjs",
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"outDir": "build/",
|
"outDir": "build",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es6"
|
"target": "ESNext"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"source/**/*.ts",
|
"source/**/*.ts",
|
||||||
|
"tests/**/*.ts",
|
||||||
|
"vite.config.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"ts-node": {
|
||||||
"node_modules/"
|
"compilerOptions": {
|
||||||
]
|
"module": "CommonJS"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
|
// Vite Plugins
|
||||||
|
import preactPreset from '@preact/preset-vite';
|
||||||
|
import webExtension from 'vite-plugin-web-extension';
|
||||||
|
|
||||||
|
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const buildDir = path.join(currentDir, 'build');
|
||||||
|
const sourceDir = path.join(currentDir, 'source');
|
||||||
|
|
||||||
|
// Create the Firefox profile if it doesn't already exist.
|
||||||
|
fs.mkdirSync(path.join(currentDir, 'firefox'), {recursive: true});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: buildDir,
|
||||||
|
sourcemap: 'inline',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
preactPreset(),
|
||||||
|
// See vite-plugin-web-extension for documentation.
|
||||||
|
// https://github.com/aklinker1/vite-plugin-web-extension
|
||||||
|
webExtension({
|
||||||
|
assets: 'assets',
|
||||||
|
browser: 'firefox',
|
||||||
|
manifest: path.join(sourceDir, 'manifest.json'),
|
||||||
|
webExtConfig: {
|
||||||
|
browserConsole: true,
|
||||||
|
firefoxProfile: 'firefox/',
|
||||||
|
keepProfileChanges: true,
|
||||||
|
startUrl: 'about:debugging#/runtime/this-firefox',
|
||||||
|
target: 'firefox-desktop',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
root: sourceDir,
|
||||||
|
});
|
Loading…
Reference in New Issue