Compare commits

..

No commits in common. "main" and "0.3.0" have entirely different histories.
main ... 0.3.0

51 changed files with 4149 additions and 4677 deletions

3
.envrc
View File

@ -1,3 +0,0 @@
#!/usr/bin/env bash
use flake

116
.gitignore vendored
View File

@ -1,8 +1,112 @@
.direnv/ # Logs
.vscode/ logs
build/ *.log
chromium/ npm-debug.log*
coverage/ yarn-debug.log*
firefox/ 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
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/ node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# 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/
firefox/
# Build output directories
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

@ -1,67 +0,0 @@
[env]
# Set BROWSER="firefox" if not already defined.
# All browser targets are defined in `source/types.d.ts` as a global `$browser`.
BROWSER = { condition = { env_not_set = ["BROWSER"] }, value = "firefox" }
# Set NODE_ENV="development" if not already defined.
# Either "development" or "production" should be used.
NODE_ENV = { condition = { env_not_set = ["NODE_ENV"] }, value = "development" }
# Start a browser instance that will reload the extension when changes are made.
[tasks.dev]
clear = true
dependencies = ["build"]
command = "pnpm"
args = ["conc", "-c=auto", "-k", "makers watch", "makers run"]
# Build the WebExtension.
[tasks.build]
clear = true
command = "pnpm"
args = ["tsx", "source/build.ts"]
# Remove build directories.
[tasks.clean]
clear = true
command = "pnpm"
args = ["trash", "build/${BROWSER}"]
# Run all other linting tasks.
[tasks.lint]
clear = true
dependencies = ["lint-js", "lint-scss"]
# Run XO.
[tasks.lint-js]
clear = true
command = "pnpm"
args = ["xo"]
# Run Stylelint.
[tasks.lint-scss]
clear = true
command = "pnpm"
args = ["stylelint", "source/**/*.scss"]
# Re-build and pack the WebExtension for publishing.
[tasks.pack]
clear = true
dependencies = ["clean", "build"]
command = "pnpm"
args = ["web-ext", "build", "--config=build/web-ext-${BROWSER}.json"]
# Start a browser instance with the extension loaded.
[tasks.run]
clear = true
command = "pnpm"
args = ["web-ext", "run", "--config=build/web-ext-${BROWSER}.json"]
# Alias for `WATCH=true makers build`.
[tasks.watch]
env = { WATCH="true" }
extend = "build"
# Create a ZIP archive with only the source code, for AMO publishing.
[tasks.zip-source]
clear = true
command = "git"
args = ["archive", "--format=zip", "--output=build/queue-source.zip", "HEAD"]

View File

@ -1,41 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1678901627,
"narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1679410443,
"narHash": "sha256-xDHO/jixWD+y5pmW5+2q4Z4O/I/nA4MAa30svnZKK+M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c9ece0059f42e0ab53ac870104ca4049df41b133",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,13 +0,0 @@
{
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = import ./shell.nix { inherit pkgs; };
}
);
}

View File

@ -1,48 +1,79 @@
{ {
"private": true, "private": true,
"type": "module", "scripts": {
"start": "vite build -m development --watch",
"start:chromium": "VITE_BROWSER=chromium pnpm start",
"clean": "trash build web-ext-artifacts",
"build": "pnpm clean && pnpm build:chromium && pnpm build:firefox && pnpm zip-source",
"build:chromium": "VITE_BROWSER=chromium vite build && web-ext build -n queue-chromium-{version}.zip -s build/chromium",
"build:firefox": "VITE_BROWSER=firefox vite build && web-ext build -n queue-firefox-{version}.zip -s build/firefox",
"zip-source": "git archive --format zip --output web-ext-artifacts/queue-source.zip HEAD",
"test": "xo && stylelint 'source/**/*.scss' && tsc && c8 ava"
},
"dependencies": { "dependencies": {
"@holllo/migration-helper": "^0.1.4", "@holllo/migration-helper": "^0.1.3",
"@holllo/preact-components": "^0.2.3", "@holllo/preact-components": "^0.2.3",
"@holllo/test": "^0.2.1",
"@holllo/webextension-storage": "^0.2.0",
"htm": "^3.1.1", "htm": "^3.1.1",
"modern-normalize": "^1.1.0", "modern-normalize": "^1.1.0",
"preact": "^10.13.1", "preact": "^10.11.0",
"webextension-polyfill": "^0.10.0" "webextension-polyfill": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@bauke/eslint-config": "^0.1.2", "@preact/preset-vite": "^2.4.0",
"@bauke/prettier-config": "^0.1.2", "@types/babel__core": "^7.1.19",
"@bauke/stylelint-config": "^0.1.2", "@types/webextension-polyfill": "^0.9.1",
"@types/node": "^18.15.11", "ava": "^4.3.3",
"@types/webextension-polyfill": "^0.10.0", "c8": "^7.12.0",
"concurrently": "^8.0.1", "postcss": "^8.4.16",
"cssnano": "^6.0.0", "sass": "^1.55.0",
"esbuild": "^0.17.15", "stylelint": "^14.12.1",
"esbuild-copy-static-files": "^0.1.0", "stylelint-config-standard-scss": "^5.0.0",
"esbuild-sass-plugin": "^2.8.0",
"postcss": "^8.4.21",
"sass": "^1.60.0",
"stylelint": "^15.3.0",
"stylelint-config-standard-scss": "^7.0.1",
"trash-cli": "^5.0.0", "trash-cli": "^5.0.0",
"tsx": "^3.12.6", "ts-node": "^10.9.1",
"typescript": "^5.0.2", "typescript": "^4.8.3",
"web-ext": "^7.6.0", "vite": "^3.1.3",
"xo": "^0.53.1" "vite-plugin-web-extension": "^1.4.4",
"web-ext": "^7.2.0",
"xo": "^0.52.3"
}, },
"prettier": "@bauke/prettier-config", "ava": {
"stylelint": { "extensions": [
"extends": "@bauke/stylelint-config" "ts"
],
"files": [
"tests/**/*.test.ts"
],
"require": [
"ts-node/register"
],
"snapshotDir": "tests/snapshots"
},
"c8": {
"include": [
"source",
"tests"
],
"reportDir": "coverage",
"reporter": [
"text",
"html"
]
}, },
"xo": { "xo": {
"extends": "@bauke/eslint-config", "overrides": [
{
"files": "tests/**/*.test.ts",
"rules": {
"@typescript-eslint/triple-slash-reference": "off",
"import/extensions": "off",
"no-await-in-loop": "off"
}
}
],
"prettier": true, "prettier": true,
"rules": { "rules": {
"@typescript-eslint/consistent-type-definitions": "off", "@typescript-eslint/consistent-type-definitions": "off",
"n/file-extension-in-import": "off", "n/file-extension-in-import": "off"
"no-await-in-loop": "off"
}, },
"space": true "space": true
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
{ pkgs ? import <nixpkgs> { } }:
with pkgs;
mkShell rec {
packages = [ cargo-make nodejs nodePackages.pnpm ];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" width="128" viewBox="0 0 100 100">
<rect fill="#eff1f5" width="100" height="100" /> <rect fill="#E6DEFF" width="100" height="100" />
<!-- Alignment grid. --> <!-- Alignment grid. -->
<g display="none"> <g display="none">
@ -13,19 +13,16 @@
<rect fill="#f0f" x="86" width="1" height="100" /> <rect fill="#f0f" x="86" width="1" height="100" />
</g> </g>
<g fill="#4c4f69"> <text
<path transform="translate(14, 46)" d=" fill="#1F1731"
M0,0 font-family="Iosevka SS01"
l51,0 font-size="75"
l-12,-12 font-weight="900"
l4,-4 x="47.9"
l20,20 y="55.6"
l-20,20 alignment-baseline="middle"
l-4,-4 text-anchor="middle"
l12,-12 >
l-51,0
z </text>
" />
<rect width="7" height="40" x="78" y="30" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 794 B

After

Width:  |  Height:  |  Size: 730 B

View File

@ -0,0 +1,43 @@
import browser from 'webextension-polyfill';
import {Settings} from '../settings/settings.js';
import {updateBadge} from '../utilities/badge.js';
// Chromium action handler in service worker.
export async function actionClicked(): Promise<void> {
await nextItem();
}
let timeoutId: number | undefined;
// Firefox browser action handler in background script.
export async function browserActionClicked(): Promise<void> {
// When the extension icon is initially clicked, create a timeout for 500ms
// that will open the next queue item when it expires.
if (timeoutId === undefined) {
timeoutId = window.setTimeout(async () => {
timeoutId = undefined;
await nextItem();
}, 500);
return;
}
// If the icon is clicked again in those 500ms, open the options page instead.
window.clearTimeout(timeoutId);
timeoutId = undefined;
await browser.runtime.openOptionsPage();
}
async function nextItem(): Promise<void> {
const settings = await Settings.fromSyncStorage();
const nextItem = settings.nextQueueItem();
if (nextItem === undefined) {
await browser.runtime.openOptionsPage();
return;
}
await browser.tabs.update({url: nextItem.url});
await settings.removeQueueItem(nextItem.id);
await updateBadge(settings);
}

View File

@ -0,0 +1,106 @@
import browser from 'webextension-polyfill';
import {Settings} from '../settings/settings.js';
import {updateBadge} from '../utilities/badge.js';
export function getContextMenus(): browser.Menus.CreateCreatePropertiesType[] {
const actionContext =
import.meta.env.VITE_BROWSER === 'chromium' ? 'action' : 'browser_action';
const contextMenus: browser.Menus.CreateCreatePropertiesType[] = [
{
id: 'queue-add-new-link',
title: 'Add to Queue',
contexts: ['link'],
},
{
id: 'queue-open-next-link-in-new-tab',
title: 'Open next link in new tab',
contexts: [actionContext],
},
{
id: 'queue-open-options-page',
title: 'Open the extension page',
contexts: [actionContext],
},
];
if (import.meta.env.VITE_BROWSER === 'firefox') {
contextMenus.push({
id: 'queue-add-new-link-tab',
title: 'Add to Queue',
contexts: ['tab'],
});
}
return contextMenus;
}
export async function initializeContextMenus(): Promise<void> {
const contextMenus = getContextMenus();
await browser.contextMenus.removeAll();
for (const contextMenu of contextMenus) {
browser.contextMenus.create(contextMenu, contextCreated);
}
}
function contextCreated(): void {
const error = browser.runtime.lastError;
if (error !== null && error !== undefined) {
console.error('Queue', error.message);
}
}
export async function contextClicked(
contextMenuIds: Set<string>,
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 updateBadge(settings);
}
} else if (id === 'queue-open-options-page') {
await browser.runtime.openOptionsPage();
}
}

View File

@ -0,0 +1,42 @@
import browser from 'webextension-polyfill';
import {Settings} from '../settings/settings.js';
import {updateBadge} from '../utilities/badge.js';
import {History} from '../utilities/history.js';
import {actionClicked, browserActionClicked} from './browser-action.js';
import {
contextClicked,
getContextMenus,
initializeContextMenus,
} from './context-menus.js';
browser.runtime.onStartup.addListener(async () => {
console.debug('Clearing history.');
await History.clear();
await updateBadge(await Settings.fromSyncStorage());
});
browser.runtime.onInstalled.addListener(async () => {
await initializeContextMenus();
await updateBadge(await Settings.fromSyncStorage());
});
browser.contextMenus.onClicked.addListener(async (info, tab) => {
const contextMenus = getContextMenus();
const contextMenuIds = new Set<string>(
contextMenus.map(({id}) => id ?? 'queue-unknown'),
);
await contextClicked(contextMenuIds, info, tab);
});
if (import.meta.env.DEV) {
void browser.runtime.openOptionsPage();
}
if (import.meta.env.VITE_BROWSER === 'chromium') {
browser.action.onClicked.addListener(actionClicked);
} else {
browser.browserAction.onClicked.addListener(browserActionClicked);
void initializeContextMenus();
}

View File

@ -1,50 +0,0 @@
// Code for the WebExtension icon (AKA the "browser action").
import browser from "webextension-polyfill";
import {createValue} from "@holllo/webextension-storage";
import {
nextItem,
setBadgeText,
openNextItemOrOptionsPage,
} from "../item/item.js";
/**
* Handle single and double clicks for Firefox.
* - For single click: open the next queued item or the options page if none are
* in the queue.
* - For double click: open the options page.
*
* The reason this can't be done in Chromium is due to Manifest V3 running
* background scripts in service workers where `setTimeout` doesn't work
* reliably. The solution is to use `browser.alarms` instead, however, alarms
* also don't work reliably for this use case because they can only run every
* minute and we need milliseconds for this. And so, Chromium doesn't get double
* click functionality.
*/
export async function firefoxActionClick(): Promise<void> {
const timeoutId = await createValue<number | undefined>({
deserialize: Number,
key: "actionClickTimeoutId",
value: undefined,
});
// If no ID is in storage, this is the first click so start a timeout and
// save its ID.
if (timeoutId.value === undefined) {
timeoutId.value = window.setTimeout(async () => {
// When no second click happens, open the next item or the options page.
await openNextItemOrOptionsPage();
await timeoutId.remove();
}, 500);
await timeoutId.save();
return;
}
// If an ID is present in storage, this is the second click and we want to
// open the options page instead.
window.clearTimeout(timeoutId.value);
await browser.runtime.openOptionsPage();
await timeoutId.remove();
}

View File

@ -1,117 +0,0 @@
import browser from "webextension-polyfill";
import {
createItem,
setBadgeText,
openNextItemOrOptionsPage,
} from "../item/item.js";
/**
* Get properties for all the context menu entries.
*
* @returns The context menu entries.
*/
export function getContextMenus(): browser.Menus.CreateCreatePropertiesType[] {
// In Manifest V2 the WebExtension icon is referred to as the
// "browser action", in MV3 it's just "action".
const actionContext: browser.Menus.ContextType =
$browser === "firefox" ? "browser_action" : "action";
const contextMenus: ReturnType<typeof getContextMenus> = [
{
id: "queue-add-new-link",
title: "Add to Queue",
contexts: ["link"],
},
{
id: "queue-open-next-link-in-new-tab",
title: "Open next link in new tab",
contexts: [actionContext],
},
{
id: "queue-open-options-page",
title: "Open the extension page",
contexts: [actionContext],
},
];
// Only Firefox supports context menu entries for tabs.
if ($browser === "firefox") {
contextMenus.push({
id: "queue-add-new-link-tab",
title: "Add to Queue",
contexts: ["tab"],
});
}
return contextMenus;
}
/**
* Initialize all the context menu entries.
*/
export async function initializeContextMenus(): Promise<void> {
const contextMenus = getContextMenus();
await browser.contextMenus.removeAll();
for (const contextMenu of contextMenus) {
browser.contextMenus.create(contextMenu, contextCreatedHandler);
}
}
/**
* Event handler for context menu creation.
*/
function contextCreatedHandler(): void {
const error = browser.runtime.lastError;
if (error !== null && error !== undefined) {
console.error("Queue", error.message);
}
}
/**
* Event handler for context menu clicks.
*
* @param contextMenuIds A set of all our context menu IDs.
* @param info The context menu click data.
* @param tab The browser tab, if available.
*/
export async function contextClicked(
contextMenuIds: Set<string>,
info: browser.Menus.OnClickData,
tab?: browser.Tabs.Tab,
): Promise<void> {
// Only handle context menus that we know the ID of.
const id = info.menuItemId.toString();
if (!contextMenuIds.has(id)) {
return;
}
if (id.startsWith("queue-add-new-link")) {
let text: string | undefined;
let url: string | undefined;
if (id === "queue-add-new-link") {
text = info.linkText;
url = info.linkUrl;
} else if (id === "queue-add-new-link-tab") {
text = tab?.title;
url = info.pageUrl;
} else {
console.warn(`Encountered unknown context menu ID: ${id}`);
return;
}
if (url === undefined) {
console.warn("Cannot add a new item without a URL.");
return;
}
const item = await createItem(text, url);
await item.save();
await setBadgeText();
} else if (id === "queue-open-next-link-in-new-tab") {
await openNextItemOrOptionsPage(true);
} else if (id === "queue-open-options-page") {
await browser.runtime.openOptionsPage();
}
}

View File

@ -1,48 +0,0 @@
// The main entry point for the background script. Note that in Manifest V3 this
// is run in a service worker.
// https://developer.chrome.com/docs/extensions/migrating/to-service-workers/
import browser from "webextension-polyfill";
import {runMigrations} from "../migrations/migrations.js";
import {
clearHistory,
openNextItemOrOptionsPage,
setBadgeText,
} from "../item/item.js";
import {firefoxActionClick} from "./action.js";
import {
contextClicked,
getContextMenus,
initializeContextMenus,
} from "./context-menu.js";
if ($browser === "firefox") {
browser.browserAction.onClicked.addListener(firefoxActionClick);
} else {
browser.action.onClicked.addListener(async () => {
await openNextItemOrOptionsPage();
});
}
browser.runtime.onStartup.addListener(async () => {
await clearHistory();
await setBadgeText();
});
browser.runtime.onInstalled.addListener(async () => {
await runMigrations();
await initializeContextMenus();
await setBadgeText();
if ($dev) {
await browser.runtime.openOptionsPage();
}
});
browser.contextMenus.onClicked.addListener(async (info, tab) => {
const contextMenus = getContextMenus();
const contextMenuIds = new Set<string>(contextMenus.map(({id}) => id!));
await contextClicked(contextMenuIds, info, tab);
});

View File

@ -1,111 +0,0 @@
// Import native Node libraries.
import path from "node:path";
import process from "node:process";
import fsp from "node:fs/promises";
// Import Esbuild and associated plugins.
import esbuild from "esbuild";
import copyPlugin from "esbuild-copy-static-files";
import {sassPlugin} from "esbuild-sass-plugin";
// Import PostCSS and associated plugins.
import cssnano from "cssnano";
import postcss from "postcss";
// Import local functions.
import {createManifest} from "./manifest.js";
import {createWebExtConfig} from "./web-ext.js";
/**
* Create an absolute path from a given relative one, using the directory
* this file is located in as the base.
*
* @param relative The relative path to make absolute.
* @returns The absolute path.
*/
function toAbsolutePath(relative: string): string {
return new URL(relative, import.meta.url).pathname;
}
// Create variables based on the environment.
const browser = process.env.BROWSER ?? "firefox";
const dev = process.env.NODE_ENV === "development";
const test = process.env.TEST === "true";
const watch = process.env.WATCH === "true";
// Create absolute paths to various directories.
const buildDir = toAbsolutePath("../build");
const outDir = path.join(buildDir, browser);
const sourceDir = toAbsolutePath("../source");
// Ensure that the output directory exists.
await fsp.mkdir(outDir, {recursive: true});
// Write the WebExtension manifest file.
await fsp.writeFile(
path.join(outDir, "manifest.json"),
JSON.stringify(createManifest(browser)),
);
// Write the web-ext configuration file.
await fsp.writeFile(
path.join(buildDir, `web-ext-${browser}.json`),
JSON.stringify(createWebExtConfig(browser, buildDir, dev, outDir)),
);
const cssProcessor = postcss([cssnano()]);
const options: esbuild.BuildOptions = {
bundle: true,
// Define variables to be replaced in the code. Note that these are replaced
// "as is" and so we have to stringify them as JSON, otherwise a string won't
// have its quotes for example.
define: {
$browser: JSON.stringify(browser),
$dev: JSON.stringify(dev),
$test: JSON.stringify(test),
},
entryPoints: [
path.join(sourceDir, "background/setup.ts"),
path.join(sourceDir, "options/setup.tsx"),
],
format: "esm",
logLevel: "info",
minify: !dev,
outdir: outDir,
plugins: [
// Copy all files from `source/assets/` to the output directory.
copyPlugin({src: path.join(sourceDir, "assets/"), dest: outDir}),
// Compile SCSS to CSS.
sassPlugin({
type: "style",
async transform(source) {
// In development, don't do any extra processing.
if (dev) {
return source;
}
// But in production, run the CSS through PostCSS.
const {css} = await cssProcessor.process(source, {from: undefined});
return css;
},
}),
],
// Link sourcemaps in development but omit them in production.
sourcemap: dev ? "linked" : false,
// Currently code splitting can't be used because we use ES modules and
// Firefox doesn't run the background script with `type="module"`.
// Once Firefox properly supports Manifest V3 this should be possible though.
splitting: false,
// Target ES2022, and the first Chromium and Firefox releases from 2022.
target: ["es2022", "chrome97", "firefox102"],
treeShaking: true,
};
if (watch) {
const context = await esbuild.context(options);
await context.watch();
} else {
await esbuild.build(options);
}

View File

@ -1,103 +0,0 @@
import browser from "webextension-polyfill";
import {type TestContext, setup} from "@holllo/test";
import {type Value} from "@holllo/webextension-storage";
import {type Item, createItem, nextItem, nextItemId} from "./item.js";
const testText = "Test Item";
const testUrl = "https://example.org/";
/**
* Check all properties of an {@link Item}.
*
* @param item The {@link Item} to assert.
* @param test The {@link TestContext} for the assertions.
*/
function assertItem(item: Value<Item>, test: TestContext): void {
// Assert that itemKeyPrefix is used.
test.true(/^item-\d+$/.test(item.key), "item key regex");
// Assert that deserialization instantiates any classes.
test.true(item.value.dateAdded instanceof Date, "dateAdded is a Date");
// Assert that the expected values are indeed present.
test.true(item.value.id > 0, "id is set");
test.equals(item.value.text, testText, "text is set");
test.equals(item.value.url, testUrl, "url is set");
}
await setup(
"Item",
async (group) => {
const existingStorages: Array<Record<string, any>> = [];
group.beforeAll(async () => {
existingStorages.push(
await browser.storage.local.get(),
await browser.storage.sync.get(),
);
await browser.storage.local.clear();
await browser.storage.sync.clear();
});
group.afterAll(async () => {
await browser.storage.local.set(existingStorages[0]);
await browser.storage.sync.set(existingStorages[1]);
});
group.test("create & nextItem", async (test) => {
const testItem = await createItem(testText, testUrl);
assertItem(testItem, test);
await testItem.save();
// Make sure `nextItem()` returns an item.
let storedNext = await nextItem();
if (storedNext === undefined || storedNext.value === undefined) {
throw new Error("Expected an item");
}
// Assert that our first test item and the stored one are identical.
test.equals(storedNext.key, testItem.key, "id check");
assertItem(storedNext, test);
// Store all test items we create so we can remove them later on.
const items = [testItem];
// Create a bunch of test items and assert them all.
for (let index = 1; index < 10; index++) {
const next = await createItem(testText, testUrl);
test.equals(testItem.value.id + index, next.value.id, "id check");
assertItem(next, test);
items.push(next);
await next.save();
}
// Remove all test items.
await Promise.all(items.map(async (item) => item.remove()));
// After all items have been removed test that `nextItem` returns nothing.
// This test will fail if an item is left from development.
storedNext = await nextItem();
test.equals(storedNext, undefined, "next item is undefined");
});
group.test("nextItemId", async (test) => {
const testItem = await createItem(testText, testUrl);
assertItem(testItem, test);
await testItem.save();
const id = await nextItemId();
test.equals(typeof id, "number", "id is a number");
test.false(Number.isNaN(id), "id is not NaN");
test.true(id > 0, "id larger than 0");
test.equals(await nextItemId(), testItem.value.id + 1, "id check");
await testItem.remove();
});
},
{
// Run tests in series since we're using WebExtension storage to test stuff
// and don't want the ID checks to interfere with one another.
parallel: false,
},
);

View File

@ -1,258 +0,0 @@
import browser from "webextension-polyfill";
import {createValue, type Value} from "@holllo/webextension-storage";
/** A queued item. */
export type Item = {
/** The date when the item was added. */
dateAdded: Date;
/** The unique ID for this item. */
id: number;
/**
* The display text of the item.
*
* This can be undefined when the context menu doesn't have access to the text
* like when a tab's context menu is used.
*/
text: string | undefined;
/** The URL of the item. */
url: string;
};
/** A serialized representation of {@link Item} for use in storage. */
export type SerializedItem = {
// Create an index signature with every key from Item and the type for each
// as `string`.
[k in keyof Item]: string;
};
/** The key prefix for {@link Item}s. */
export type ItemKeyPrefix = "item-" | "history-";
/**
* Returns the dedicated WebExtension storage area for a given
* {@link ItemKeyPrefix}.
*
* @param prefix The target {@link ItemKeyPrefix}.
* @returns The WebExtension storage area.
*/
export function storageForPrefix(
prefix: ItemKeyPrefix,
): browser.Storage.StorageArea {
return prefix === "item-" ? browser.storage.sync : browser.storage.local;
}
/**
* Serialize and JSON-stringify an {@link Item}.
*
* @param input The {@link Item} to serialize.
* @returns The serialized {@link Item} string.
*/
export const serializeItem: Value<Item>["serialize"] = (
input: Item,
): string => {
const serialized: SerializedItem = {
dateAdded: input.dateAdded.toISOString(),
id: input.id.toString(),
text: input.text ?? "",
url: input.url,
};
return JSON.stringify(serialized);
};
/**
* Deserialize and JSON-parse an {@link Item} from a string.
*
* This function should only ever be used with {@link Value} as this
* doesn't do any validation. With {@link Value} it's reasonable to assume
* the input will actually deserialize to an {@link Item}.
*
* @param input The {@link Item} string to deserialize.
* @returns The deserialized {@link Item}.
*/
export const deserializeItem: Value<Item>["deserialize"] = (
input: string,
): Item => {
const parsed = JSON.parse(input) as SerializedItem;
return {
dateAdded: new Date(parsed.dateAdded),
id: Number(parsed.id),
// In `serializeItem()` the item text is set to an empty string when
// undefined, so revert it back to undefined here if that's the case.
text: parsed.text === "" ? undefined : parsed.text,
url: parsed.url,
};
};
/**
* Create a new {@link Item} in the default storage.
*
* @param text The text of the {@link Item} to create.
* @param url The URL of the {@link Item} to create.
* @param itemKeyPrefix The prefix for the {@link Item} key.
* @returns The created {@link Value} with inner {@link Item}.
*/
export async function createItem(
text: Item["text"],
url: Item["url"],
itemKeyPrefix: ItemKeyPrefix = "item-",
): Promise<Value<Item>> {
const nextId = await nextItemId();
const item = await createValue<Item>({
deserialize: deserializeItem,
serialize: serializeItem,
storage: storageForPrefix(itemKeyPrefix),
key: `${itemKeyPrefix}${nextId}`,
value: {
dateAdded: new Date(),
id: nextId,
text,
url,
},
});
return item;
}
/**
* Get the item for a given key and {@link ItemKeyPrefix}. Note that this
* function assumes that the item definitely exists.
*/
export async function getItem(
key: string,
itemKeyPrefix: ItemKeyPrefix,
): Promise<Value<Item>> {
return createValue<Item>({
deserialize: deserializeItem,
serialize: serializeItem,
storage: storageForPrefix(itemKeyPrefix),
key,
value: undefined!,
});
}
/**
* Get all keys from storage that start with the {@link ItemKeyPrefix}.
*
* @returns The keys as a string array.
*/
export async function getItemKeys(
itemKeyPrefix: ItemKeyPrefix,
): Promise<string[]> {
const storage = storageForPrefix(itemKeyPrefix);
const stored = Object.keys(await storage.get());
const keys = stored.filter((key) => key.startsWith(itemKeyPrefix));
return keys;
}
/**
* Get the next unique {@link Item} ID.
*
* @returns The next ID as a number.
*/
export async function nextItemId(
itemKeyPrefix: ItemKeyPrefix = "item-",
): Promise<number> {
// Get all the item keys and sort them so the highest ID is first.
const keys = await getItemKeys(itemKeyPrefix);
keys.sort((a, b) => b.localeCompare(a));
// Get the first key or use 0 if no items exist yet.
const highestKey = keys[0] ?? `${itemKeyPrefix}0`;
// Create the next ID by removing the item key prefix and adding 1.
return Number(highestKey.slice(itemKeyPrefix.length)) + 1;
}
/**
* Get the next queued {@link Item}.
*
* @returns The {@link Value} with inner {@link Item} or `undefined` if the
* queue is empty.
*/
export async function nextItem(): Promise<Value<Item> | undefined> {
const itemKeyPrefix: ItemKeyPrefix = "item-";
// Get all the item keys and sort them so the lowest ID is first.
const keys = await getItemKeys(itemKeyPrefix);
keys.sort((a, b) => a.localeCompare(b));
// If no keys exist then exit early.
const key = keys[0];
if (key === undefined) {
return undefined;
}
return createValue<Item>({
deserialize: deserializeItem,
key,
// We know that an item exists in storage since there is a key for it, which
// means passing undefined here is fine as it won't be used.
value: undefined!,
serialize: serializeItem,
storage: storageForPrefix(itemKeyPrefix),
});
}
/**
* Set the WebExtension's badge text to show the current {@link Item} count.
*/
export async function setBadgeText(): Promise<void> {
const itemKeyPrefix: ItemKeyPrefix = "item-";
const keys = await getItemKeys(itemKeyPrefix);
const count = keys.length;
const action: browser.Action.Static =
$browser === "firefox" ? browser.browserAction : browser.action;
await action.setBadgeBackgroundColor({color: "#2a2041"});
await action.setBadgeText({text: count === 0 ? "" : count.toString()});
// Only Firefox supports the `setBadgeTextColor` function.
if ($browser === "firefox") {
action.setBadgeTextColor({color: "#f2efff"});
}
}
/**
* Remove all historical items from local WebExtension storage.
*/
export async function clearHistory(): Promise<void> {
const historyPrefix: ItemKeyPrefix = "history-";
const historyItemKeys = await getItemKeys(historyPrefix);
const storage = storageForPrefix(historyPrefix);
await storage.remove(historyItemKeys);
}
/**
* Opens the next queued item if one is available, otherwise opens the
* WebExtension options page.
*
* @param newTab Open the next item in a new tab (default `false`).
*/
export async function openNextItemOrOptionsPage(newTab = false): Promise<void> {
const item = await nextItem();
if (item === undefined) {
await browser.runtime.openOptionsPage();
return;
}
const url = item.value.url;
await (newTab
? browser.tabs.create({active: true, url})
: browser.tabs.update({url}));
await item.remove();
await setBadgeText();
const historyItem = await createItem(
item.value.text,
item.value.url,
"history-",
);
await historyItem.save();
}

View File

@ -1,60 +1,49 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import {type Manifest} from "webextension-polyfill"; export default function createManifest(
target: string,
/** ): Record<string, unknown> {
* Creates the WebExtension manifest based on the browser target. const manifest: Record<string, unknown> = {
* name: 'Queue',
* @param browser The browser target ("firefox" or "chromium"). description: 'A WebExtension for queueing links.',
* @returns The WebExtension manifest. version: '0.3.0',
*/ permissions: ['contextMenus', 'storage'],
export function createManifest(browser: string): Manifest.WebExtensionManifest {
const manifest: Manifest.WebExtensionManifest = {
manifest_version: Number.NaN,
name: "Queue",
version: "0.3.2",
permissions: ["contextMenus", "storage"],
options_ui: { options_ui: {
page: "options/index.html", page: 'options/index.html',
open_in_tab: true, open_in_tab: true,
}, },
}; };
const icons: Manifest.IconPath = { const icons = {
128: "queue.png", 128: 'assets/queue.png',
}; };
const action: Manifest.ActionManifest = { manifest.icons = icons;
const browserAction = {
default_icon: icons, default_icon: icons,
}; };
const backgroundScript = "background/setup.js"; const backgroundScript = 'background-scripts/initialize.ts';
if (browser === "firefox") { if (target === 'chromium') {
manifest.manifest_version = 3;
manifest.action = browserAction;
manifest.background = {
service_worker: backgroundScript,
type: 'module',
};
} else {
manifest.manifest_version = 2; manifest.manifest_version = 2;
manifest.browser_action = browserAction;
manifest.background = { manifest.background = {
scripts: [backgroundScript], scripts: [backgroundScript],
}; };
manifest.browser_action = action; manifest.applications = {
manifest.browser_specific_settings = {
gecko: { gecko: {
id: "{c3560e6b-00e5-4ab3-b89e-8a54ee5b2c9f}", id: '{c3560e6b-00e5-4ab3-b89e-8a54ee5b2c9f}',
strict_min_version: "102.0",
}, },
}; };
} else if (browser === "chromium") {
manifest.manifest_version = 3;
manifest.action = action;
manifest.background = {
service_worker: backgroundScript,
type: "module",
};
} else {
throw new Error(`Unknown target browser: ${browser}`);
}
if (Number.isNaN(manifest.manifest_version)) {
throw new TypeError("Manifest version is NaN");
} }
return manifest; return manifest;

View File

@ -1,29 +0,0 @@
import {setup} from "@holllo/test";
import {dataMigrations, type QueueItemPre030} from "./migrations.js";
import snapshots from "./snapshots.json";
const queueItemSample: QueueItemPre030 = {
added: new Date("2022-03-02T16:00:00Z"),
id: 1,
text: "Sample",
url: "https://example.org",
};
await setup("Migrations", async (group) => {
group.test("Snapshots", async (test) => {
let data: Record<string, any> = {
latestVersion: "0.1.0",
queue: [queueItemSample],
};
for (const [index, migration] of dataMigrations.entries()) {
data = (await migration.migrate(data)) as Record<string, any>;
test.equals(
JSON.stringify(data, null, 2),
JSON.stringify(snapshots[index], null, 2),
);
}
});
});

View File

@ -1,117 +0,0 @@
import browser from "webextension-polyfill";
import {migrate, type Migration} from "@holllo/migration-helper";
import {createValue} from "@holllo/webextension-storage";
import type {ItemKeyPrefix} from "../item/item.js";
/** The Queue Item type for versions `<0.3.0`. */
export type QueueItemPre030 = {
added: Date;
id: number;
text: string;
url: string;
};
/** The Queue Item type for versions `>=0.3.0 <1.0.0`. */
export type QueueItem030 = {
sortIndex: number;
} & QueueItemPre030;
/** The Queue Item type for versions `>=1.0.0`. */
export type QueueItem100 = {
dateAdded: Date;
id: number;
text: string | undefined;
url: string;
};
/** All migrations for Queue storage. */
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 QueueItemPre030[]) ?? [];
for (const item of items) {
const key = `qi${item.id}`;
migrated[key] = item;
}
return migrated;
},
},
{
version: "0.3.0",
async migrate(data: Record<string, any>) {
const migrated: Record<string, any> = {
version: "0.3.0",
};
for (const [key, value] of Object.entries<QueueItemPre030>(data)) {
if (key.startsWith("qi")) {
const item: QueueItem030 = {
sortIndex: value.id,
...value,
};
migrated[key] = item;
}
}
return migrated;
},
},
{
version: "1.0.0",
async migrate(data: Record<string, any>) {
const migrated: Record<string, any> = {
version: "1.0.0",
};
for (const [key, value] of Object.entries<QueueItem030>(data)) {
if (key.startsWith("qi")) {
const item: QueueItem100 = {
dateAdded: new Date(value.added),
id: value.id,
text: value.text === "" ? undefined : value.text,
url: value.url,
};
const prefix: ItemKeyPrefix = "item-";
migrated[`${prefix}${item.id}`] = item;
}
}
return migrated;
},
},
];
/** Run the migrations and apply the result to storage. */
export async function runMigrations(): Promise<void> {
const manifest = browser.runtime.getManifest();
const version = await createValue<string>({
deserialize: (input) => input,
serialize: (input) => input,
key: "version",
storage: browser.storage.sync,
value: manifest.version,
});
// Only when the current data version is lower than the manifest version
// should the migrations run.
if (version.value >= manifest.version) {
return;
}
const migrated = await migrate(
await browser.storage.sync.get(),
version.value,
dataMigrations,
);
await browser.storage.sync.clear();
await browser.storage.sync.set(migrated as Record<string, any>);
}

View File

@ -1,30 +0,0 @@
[
{
"version": "0.1.7",
"qi1": {
"added": "2022-03-02T16:00:00.000Z",
"id": 1,
"text": "Sample",
"url": "https://example.org"
}
},
{
"version": "0.3.0",
"qi1": {
"sortIndex": 1,
"added": "2022-03-02T16:00:00.000Z",
"id": 1,
"text": "Sample",
"url": "https://example.org"
}
},
{
"version": "1.0.0",
"item-1": {
"dateAdded": "2022-03-02T16:00:00.000Z",
"id": 1,
"text": "Sample",
"url": "https://example.org"
}
}
]

View File

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

View File

@ -0,0 +1,39 @@
import {PrivacyLink} from '@holllo/preact-components';
import {html} from 'htm/preact';
import {Component} from 'preact';
import type {Settings} from '../../settings/settings.js';
type Props = {
settings: Settings;
};
export class PageFooter extends Component<Props> {
render() {
const {settings} = this.props;
const version = settings.manifest.version;
const donateAttributes = {
href: 'https://liberapay.com/Holllo',
};
const donateLink = html`
<${PrivacyLink} attributes="${donateAttributes}">Donate<//>
`;
const versionAttributes = {
href: `https://git.bauke.xyz/Holllo/queue/releases/tag/${version}`,
};
const versionLink = html`
<${PrivacyLink} attributes="${versionAttributes}">v${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

@ -0,0 +1,189 @@
import {ConfirmButton, PrivacyLink} from '@holllo/preact-components';
import {Component, html} from 'htm/preact';
import type {Settings} from '../../settings/settings.js';
import {updateBadge} from '../../utilities/badge.js';
import type {History} from '../../utilities/history.js';
type Props = {
history: History;
settings: Settings;
};
type State = {
queue: Queue.Item[];
};
export class PageMain extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
queue: props.settings.queue,
};
}
moveItem = async (id: number, direction: Queue.MoveDirection) => {
const {settings} = this.props;
await settings.moveQueueItem(id, direction);
this.setState({queue: this.props.settings.queue});
};
removeItem = async (id: number) => {
const {settings} = this.props;
await settings.removeQueueItem(id);
await updateBadge(settings);
this.setState({queue: this.props.settings.queue});
};
render() {
const isFirefox = import.meta.env.VITE_BROWSER === 'firefox';
const queueItems = this.state.queue
.sort((a, b) => a.sortIndex - b.sortIndex)
.map(
(item) =>
html`
<${queueItem}
item=${item}
move=${this.moveItem}
remove=${this.removeItem}
/>
`,
);
if (queueItems.length === 0) {
queueItems.push(html`<li>No items queued. 🤷</li>`);
}
const historyItems = this.props.history.queue
.sort((a, b) => b.added.getTime() - a.added.getTime())
.map((item) => html`<${queueItem} item=${item} />`);
let history: HtmComponent | undefined;
if (historyItems.length > 0) {
history = html`
<details class="history">
<summary>Queue history</summary>
<ul class="q-list">
${historyItems}
</ul>
</details>
`;
}
return html`
<main class="page-main">
<ul class="q-list">
${queueItems}
</ul>
${history}
<details class="usage">
<summary>How do I use Queue?</summary>
<p>Adding links to your queue:</p>
<ul>
<li>
Right-click any link ${isFirefox ? 'or tab' : ''} and click "Add
to Queue".
</li>
</ul>
<p>Opening the next link from your queue:</p>
<ul>
<li>Click on the extension icon to open it in the current tab.</li>
<li>
Right-click the extension icon and click "Open next link in new
tab".
</li>
</ul>
<p>Opening the extension page:</p>
<ul>
${isFirefox
? html`<li>Double-click the extension icon.</li>`
: undefined}
<li>
Right-click the extension icon and click "Open the extension
page".
</li>
</ul>
<p>Deleting queue items:</p>
<ul>
<li>
Click the red button with the and then confirm it by clicking
again.
</li>
</ul>
</details>
</main>
`;
}
}
type ItemProps = {
item: Queue.Item;
move?: (id: number, direction: Queue.MoveDirection) => Promise<void>;
remove?: (id: number) => Promise<void>;
};
function queueItem(props: ItemProps): HtmComponent {
const added = props.item.added.toLocaleString();
const {id, text, url} = props.item;
const move = [];
if (props.move !== undefined) {
const moveButtons: Array<[string, Queue.MoveDirection]> = [
['↑', 'up'],
['↓', 'down'],
];
move.push(
...moveButtons.map(
([text, direction]) =>
html`
<button
title="Move item ${direction}"
onClick=${async () => props.move!(id, direction)}
>
${text}
</button>
`,
),
);
}
let remove;
if (props.remove !== undefined) {
remove = html`
<${ConfirmButton}
class="confirm-button"
click=${async () => props.remove!(id)}
confirmClass="confirm"
confirmText="✓"
extraAttributes=${{title: 'Remove'}}
text="✗"
timeout=${5 * 1000}
/>
`;
}
return html`
<li class="q-item">
<p class="title">
<${PrivacyLink} attributes=${{href: url}}>${text ?? url}<//>
</p>
<div class="buttons">${move}${remove}</div>
<p>
<time datetime=${added} title="Link queued on ${added}.">
${added}
</time>
</p>
</li>
`;
}

View File

@ -6,15 +6,16 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Queue</title> <title>Queue</title>
<link rel="shortcut icon" href="/queue.png" type="image/png"> <link rel="shortcut icon" href="/assets/queue.png" type="image/png">
<link rel="stylesheet" href="./index.scss">
</head> </head>
<body class="catppuccin"> <body class="love">
<noscript> <noscript>
This WebExtension doesn't work without JavaScript enabled, sorry! 😭 This WebExtension doesn't work without JavaScript enabled, sorry! 😭
</noscript> </noscript>
<script type="module" src="./setup.js"></script> <script type="module" src="./index.ts"></script>
</body> </body>
</html> </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);
}
}

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

@ -0,0 +1,35 @@
import {html} from 'htm/preact';
import {Component, render} from 'preact';
import {Settings} from '../settings/settings.js';
import {updateBadge} from '../utilities/badge.js';
import {History} from '../utilities/history.js';
import {PageFooter, PageHeader, PageMain} from './components/components.js';
window.addEventListener('DOMContentLoaded', async () => {
const history = await History.fromLocalStorage();
const settings = await Settings.fromSyncStorage();
await updateBadge(settings);
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

@ -0,0 +1,9 @@
@use '../mixins';
.page-footer {
@include mixins.responsive-container;
border: 1px solid var(--df-2);
margin-bottom: 16px;
padding: 16px;
}

View File

@ -0,0 +1,23 @@
@use '../mixins';
.page-header {
@include mixins.responsive-container;
border: 1px solid var(--df-2);
margin-bottom: 16px;
h1 {
font-size: 2.5rem;
}
.icon {
align-items: center;
background-color: var(--df-2);
color: var(--db-1);
display: inline-flex;
height: 4.5rem;
justify-content: center;
margin-right: 8px;
width: 4.5rem;
}
}

View File

@ -0,0 +1,106 @@
@use '../mixins';
.page-main {
@include mixins.responsive-container;
margin-bottom: 16px;
}
.q-list {
border: 1px solid var(--df-2);
list-style: none;
margin-bottom: 16px;
padding: 16px;
> li:not(:last-child) {
margin-bottom: 16px;
}
}
.q-item {
background-color: var(--db-2);
display: grid;
grid-template-columns: auto min-content;
padding: 8px;
.title {
display: inline-block;
font-size: 2rem;
font-weight: bold;
margin-bottom: 8px;
}
.buttons {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
button {
align-items: center;
background-color: var(--da-3);
border: none;
color: var(--db-1);
cursor: pointer;
display: flex;
flex-direction: column;
font-weight: bold;
height: 2.5rem;
justify-content: center;
padding: 0;
width: 2.5rem;
&.confirm-button {
background-color: var(--la-1);
color: var(--df-1);
&.confirm {
background-color: var(--df-1);
color: var(--la-1);
}
}
}
}
}
.history,
.usage {
border: 1px solid var(--df-2);
&[open] {
summary {
background-color: var(--df-2);
color: var(--db-1);
margin-bottom: 16px;
}
> :not(summary) {
padding: 0 16px;
}
}
summary {
cursor: pointer;
font-weight: bold;
padding: 16px;
&:hover {
background-color: var(--df-1);
color: var(--db-1);
}
}
}
.history {
margin-bottom: 16px;
.q-list {
border: none;
}
}
.usage {
ul {
list-style: square;
margin: 4px 0 2rem 16px;
}
}

View File

@ -0,0 +1,45 @@
/*
The Love Theme CSS Custom Properties
https://love.holllo.cc - version 0.1.0
MIT license
*/
.love {
/* Love Dark */
--df-1: #f2efff;
--df-2: #e6deff;
--db-1: #1f1731;
--db-2: #2a2041;
--da-1: #f99fb1;
--da-2: #faa56c;
--da-3: #d2b83a;
--da-4: #96c839;
--da-5: #3bd18a;
--da-6: #3ecdbf;
--da-7: #41c8e5;
--da-8: #98b9f8;
--da-9: #d5a6f8;
--da-10: #f99add;
--dg-1: #e2e2e2;
--dg-2: #c6c6c6;
--dg-3: #ababab;
/* Love Light */
--lf-1: #1f1731;
--lf-2: #2a2041;
--lb-1: #f2efff;
--lb-2: #e6deff;
--la-1: #8b123c;
--la-2: #6a3b11;
--la-3: #514610;
--la-4: #384d10;
--la-5: #115133;
--la-6: #124f49;
--la-7: #144d5a;
--la-8: #17477e;
--la-9: #6f1995;
--la-10: #81156a;
--lg-1: #1b1b1b;
--lg-2: #303030;
--lg-3: #474747;
}

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

@ -0,0 +1,12 @@
h1,
h2,
h3,
h4,
h5,
ol,
ul,
li,
p {
margin: 0;
padding: 0;
}

View File

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

15
source/packages.d.ts vendored
View File

@ -1,15 +0,0 @@
// Type definitions for third-party packages.
declare module "esbuild-copy-static-files" {
import {type cpSync} from "node:fs";
import {type Plugin} from "esbuild";
type CopySyncParameters = Parameters<typeof cpSync>;
type Options = {
src?: CopySyncParameters[0];
dest?: CopySyncParameters[1];
} & CopySyncParameters[2];
export default function (options: Options): Plugin;
}

View File

@ -1,13 +0,0 @@
// Colors are from the Catppuccin palette.
// https://catppuccin.com
// License: MIT
.catppuccin {
// The comments after each color indicate which variant and variable it is.
--bg-1: #1e1e2e; // Mocha $base
--bg-2: #313244; // Mocha $surface0
--fg-1: #eff1f5; // Frappe $base
--fg-2: #a6adc8; // Mocha $subtext0
--fa-1: #fab387; // Mocha $peach
--fa-2: #74c7ec; // Mocha $sapphire
}

View File

@ -0,0 +1,62 @@
import type {Migration} from '@holllo/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;
},
},
{
version: '0.3.0',
async migrate(data: Record<string, any>) {
const migrated: Record<string, any> = {
version: '0.3.0',
};
for (const [key, value] of Object.entries<Queue.Item>(data)) {
if (key.startsWith('qi')) {
migrated[key] = value;
migrated[key].sortIndex = value.id;
}
}
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};
serialized[key].added = item.added.toISOString();
}
return serialized;
}

118
source/settings/settings.ts Normal file
View File

@ -0,0 +1,118 @@
import browser from 'webextension-polyfill';
import {migrate} from '@holllo/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,
sortIndex: id,
text,
url,
};
this.queue.push(item);
await browser.storage.sync.set({
[`qi${id}`]: {
added: item.added.toISOString(),
id,
sortIndex: id,
text,
url,
},
});
}
public async moveQueueItem(
id: number,
direction: Queue.MoveDirection,
): Promise<void> {
const targetItem = this.queue.find((item) => item.id === id);
if (targetItem === undefined) {
throw new Error(`Failed to move item with ID: ${id}`);
}
const previousIndex = targetItem.sortIndex;
let targetIndex = previousIndex;
if (direction === 'down') {
targetIndex += 1;
} else if (direction === 'up') {
targetIndex -= 1;
}
const existingItem = this.queue.find(
(item) => item.sortIndex === targetIndex,
);
if (existingItem !== undefined) {
existingItem.sortIndex = previousIndex;
targetItem.sortIndex = targetIndex;
await this.save();
}
}
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,
});
}
}

34
source/types.d.ts vendored
View File

@ -1,8 +1,32 @@
// Export something so TypeScript doesn't see this file as an ambient module. import type {html} from 'htm/preact';
export {};
declare global { declare global {
const $browser: "chromium" | "firefox"; // See Vite documentation for `import.meta.env` usage.
const $dev: boolean; // https://vitejs.dev/guide/env-and-mode.html
const $test: boolean;
interface ImportMeta {
readonly env: ImportMetaEnv;
}
interface ImportMetaEnv {
readonly BASE_URL: string;
readonly DEV: boolean;
readonly MODE: string;
readonly PROD: boolean;
readonly VITE_BROWSER: 'chromium' | 'firefox';
}
type HtmComponent = ReturnType<typeof html>;
namespace Queue {
type Item = {
added: Date;
id: number;
sortIndex: number;
text: string;
url: string;
};
type MoveDirection = 'up' | 'down';
}
} }

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

@ -0,0 +1,23 @@
import browser from 'webextension-polyfill';
import type {Settings} from '../settings/settings.js';
export async function updateBadge(settings: Settings): Promise<void> {
let action: browser.Action.Static = browser.browserAction;
if (import.meta.env.VITE_BROWSER === 'chromium') {
action = browser.action;
}
const queueLength = settings.queue.length.toString();
await action.setBadgeText({
text: queueLength === '0' ? '' : queueLength,
});
await action.setBadgeBackgroundColor({
color: '#2a2041',
});
if (import.meta.env.VITE_BROWSER === 'firefox') {
action.setBadgeTextColor({color: '#f2efff'});
}
}

View File

@ -0,0 +1,44 @@
import browser from 'webextension-polyfill';
import {deserializeQueue, serializeQueue} from '../settings/migrations.js';
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 = deserializeQueue(stored.history);
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: serializeQueue(this.queue),
});
}
}

View File

@ -1,75 +0,0 @@
import path from "node:path";
/**
* Barebones type definition for web-ext configuration.
*
* Since web-ext doesn't export any types this is done by ourselves. The keys
* mostly follow a camelCased version of the CLI options
* (ie. --start-url becomes startUrl).
*/
type WebExtConfig = {
artifactsDir: string;
sourceDir: string;
verbose?: boolean;
build: {
filename: string;
overwriteDest: boolean;
};
run: {
browserConsole: boolean;
firefoxProfile: string;
keepProfileChanges: boolean;
profileCreateIfMissing: boolean;
startUrl: string[];
target: string[];
};
};
/**
* Create the web-ext configuration.
*
* @param browser The browser target ("firefox" or "chromium").
* @param buildDir The path to the build directory.
* @param dev Is this for development or production.
* @param outDir The path to the output directory.
* @returns The configuration for web-ext.
*/
export function createWebExtConfig(
browser: string,
buildDir: string,
dev: boolean,
outDir: string,
): WebExtConfig {
const config: WebExtConfig = {
artifactsDir: path.join(buildDir, "artifacts"),
sourceDir: outDir,
build: {
filename: `{name}-{version}-${browser}.zip`,
overwriteDest: true,
},
run: {
browserConsole: dev,
firefoxProfile: path.join(buildDir, "firefox-profile/"),
keepProfileChanges: true,
profileCreateIfMissing: true,
startUrl: [],
target: [],
},
};
if (browser === "firefox") {
config.run.startUrl.push("about:debugging#/runtime/this-firefox");
config.run.target.push("firefox-desktop");
} else if (browser === "chromium") {
config.run.startUrl.push("chrome://extensions/");
config.run.target.push("chromium");
} else {
throw new Error(`Unknown target browser: ${browser}`);
}
return config;
}

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

@ -0,0 +1,55 @@
/// <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',
} as unknown as Queue.Item;
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 sample: Queue.Item = {
added: queueItemSample.added,
id: queueItemSample.id,
sortIndex: queueItemSample.id,
text: queueItemSample.text,
url: queueItemSample.url,
};
const serialized = serializeQueue([sample]);
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,72 @@
# 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',
}
> Migration 0.3.0
{
qi1: {
added: Date 2022-03-02 16:00:00 UTC {},
id: 1,
sortIndex: 1,
text: 'Sample',
url: 'https://example.org',
},
version: '0.3.0',
}
## dataMigrations unhappy path
> Migration 0.1.7
{
version: '0.1.7',
}
> Migration 0.3.0
{
version: '0.3.0',
}
## Serializing & Deserializing Queue
> Serialized
{
qi1: {
added: '2022-03-02T16:00:00.000Z',
id: 1,
sortIndex: 1,
text: 'Sample',
url: 'https://example.org',
},
}
> Deserialized
[
{
added: Date 2022-03-02 16:00:00 UTC {},
id: 1,
sortIndex: 1,
text: 'Sample',
url: 'https://example.org',
},
]

Binary file not shown.

View File

@ -1,19 +1,24 @@
{ {
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": [ "lib": [
"DOM", "ESNext"
"ES2022"
], ],
"module": "ES2022", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"resolveJsonModule": true, "noEmit": true,
"outDir": "build",
"strict": true, "strict": true,
"target": "ES2022" "target": "ESNext"
}, },
"include": [ "include": [
"source" "source/**/*.ts",
] "tests/**/*.ts",
"vite.config.ts"
],
"ts-node": {
"compilerOptions": {
"module": "CommonJS"
}
}
} }

56
vite.config.ts Normal file
View File

@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import url from 'node:url';
import {defineConfig} from 'vite';
// Vite Plugins
import preactPreset from '@preact/preset-vite';
import webExtension from 'vite-plugin-web-extension';
import createManifest from './source/manifest.js';
const targetBrowser = process.env.VITE_BROWSER ?? 'firefox';
process.env.VITE_BROWSER = targetBrowser;
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
const buildDir = path.join(currentDir, 'build', targetBrowser);
const sourceDir = path.join(currentDir, 'source');
fs.mkdirSync(path.join(currentDir, targetBrowser), {recursive: true});
const webExtConfig: Record<string, unknown> = {
browserConsole: true,
chromiumProfile: 'chromium/',
firefoxProfile: 'firefox/',
keepProfileChanges: true,
};
if (targetBrowser === 'chromium') {
webExtConfig.startUrl = 'chrome://extensions/';
webExtConfig.target = 'chromium';
} else {
webExtConfig.startUrl = 'about:debugging#/runtime/this-firefox';
webExtConfig.target = 'firefox-desktop';
}
export default defineConfig({
build: {
minify: false,
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: targetBrowser,
manifest: () => createManifest(targetBrowser),
webExtConfig,
}),
],
root: sourceDir,
});