Version 0.2.0, fix all the things.

This commit is contained in:
Bauke 2022-03-05 14:10:45 +01:00
parent 596487a835
commit f7c66feb76
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
58 changed files with 7616 additions and 10653 deletions

54
.gitignore vendored
View File

@ -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/

9
.stylelintrc.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": [
"stylelint-config-standard-scss"
],
"rules": {
"no-descending-specificity": null,
"string-quotes": "single"
}
}

View File

@ -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

View File

@ -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"
]
} }

6588
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -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();
}

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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();
}
});

View File

@ -1,8 +0,0 @@
import browser from 'webextension-polyfill';
import {backgroundHandler} from './utilities/browser';
(async () => {
backgroundHandler();
await browser.runtime.sendMessage({action: 'queue update badge'});
})();

View File

@ -0,0 +1,8 @@
import {initializeMessaging, sendMessage} from '../utilities/messaging.js';
async function initializeScripts() {
initializeMessaging();
await sendMessage('queue update badge');
}
void initializeScripts();

View File

@ -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>

View File

@ -1,3 +0,0 @@
{
"nodeEnv": "development"
}

View File

@ -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"
] ]
} }
], ],

View File

@ -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';

View File

@ -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>
`;
}
}

View File

@ -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>
`;
}
}

View File

@ -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>
`;
}
}

View File

@ -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>
`;
}
}

View File

@ -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`
<summary>Queue history</summary> <details class="history">
<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}
clickHandler=${async () => props.remove!(id)} cssClass="confirm-button"
confirmClass="confirm" clickHandler=${async () => props.remove!(id)}
confirmText="✓" confirmClass="confirm"
text="✗" confirmText="✓"
timeout=${5 * 1000} text="✗"
title="Remove" timeout=${5 * 1000}
/>`; title="Remove"
/>
`;
} }
return html` return html`

20
source/options/index.html Normal file
View File

@ -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>

28
source/options/index.scss Normal file
View File

@ -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);
}
}

37
source/options/index.ts Normal file
View File

@ -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} />
`;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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%;
}
}

View File

@ -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;
} }

View File

@ -0,0 +1,4 @@
$small-breakpoint: 600px;
$medium-breakpoint: 900px;
$large-breakpoint: 1200px;
$extra-large-breakpoint: 1800px;

View File

@ -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%;
}
}

View File

@ -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';

View File

@ -1 +0,0 @@
@import '../../node_modules/modern-normalize/modern-normalize.css';

View File

@ -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,
);
});

View File

@ -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;
}

View File

@ -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,
});
}
}

68
source/types.d.ts vendored
View File

@ -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;
}
interface ImportMetaEnv {
readonly BASE_URL: string;
readonly DEV: boolean;
readonly MODE: string;
readonly PROD: boolean;
}
type HtmComponent = ReturnType<typeof html>;
namespace Queue {
type Item = {
added: Date;
id: number;
text: string;
url: string;
};
type MessageAction = 'queue open url' | 'queue update badge';
type Message<T> = {
action: MessageAction;
data: T;
};
} }
} }
export namespace Queue {
type Component = ReturnType<typeof html>;
type Item = {
added: Date;
id: number;
text?: string;
url: string;
};
type Manifest = {nodeEnv?: string} & browser.Manifest.ManifestBase;
type Message<T> = {
action: MessageAction;
data: T;
};
type MessageAction = 'queue open url' | 'queue update badge';
type Migration = {
date: Date;
upgrade: (previous: Record<string, any>) => Record<string, any>;
version: string;
};
}

13
source/utilities/badge.ts Normal file
View File

@ -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'});
}

View File

@ -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',
});
}

View File

@ -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>
`;
}

View File

@ -1,3 +0,0 @@
export * from './page-footer';
export * from './page-header';
export * from './page-main';

View File

@ -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>
`;
}

View File

@ -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>
`;
}

View File

@ -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>
`;
}

View File

@ -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});
}
}

View File

@ -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;
}

View File

@ -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});
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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, ''));
}

48
tests/migrations.test.ts Normal file
View File

@ -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');
});

View File

@ -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.

View File

@ -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"
}
}
} }

42
vite.config.ts Normal file
View File

@ -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,
});

9845
yarn.lock

File diff suppressed because it is too large Load Diff