Compare commits
55 Commits
Author | SHA1 | Date |
---|---|---|
Bauke | 17e0fe83dc | |
Bauke | e3ca7f9494 | |
Bauke | e53bea8fe4 | |
Bauke | 5669510d9c | |
Bauke | 37fb4780f3 | |
Bauke | 18b908eca4 | |
Bauke | acb2bb409b | |
Bauke | dfc42535f0 | |
Bauke | 0ad7345a71 | |
Bauke | bf5a20c0b8 | |
Bauke | 786bcce6b4 | |
Bauke | c728f7302c | |
Bauke | 29838f4734 | |
Bauke | 6a5d39e8ad | |
Bauke | 8eb5e7d972 | |
Bauke | 1c2397b8f6 | |
Bauke | 20f399bda8 | |
Bauke | 974d8f22fd | |
Bauke | e03f163c30 | |
Bauke | daa46b8755 | |
Bauke | 0a2891d919 | |
Bauke | 265ca87a90 | |
Bauke | e266e9dd7b | |
Bauke | fbd8de8b03 | |
Bauke | 091da70bea | |
Bauke | 451ebb8189 | |
Bauke | 861c340ec7 | |
Bauke | a722421130 | |
Bauke | d03e46a481 | |
Bauke | 30c042f991 | |
Bauke | 862a2fdb86 | |
Bauke | 04b6c23093 | |
Bauke | 544d915233 | |
Bauke | c853802b68 | |
Bauke | 849e443f4e | |
Bauke | 18e0e06edb | |
Bauke | adef8fc894 | |
Bauke | 940367fc49 | |
Bauke | e40ebdd4c3 | |
Bauke | 9dcca9fe3e | |
Bauke | a325269e7c | |
Bauke | 69c7c10368 | |
Bauke | 1afaabc2a8 | |
Bauke | 123df05906 | |
Bauke | 5dfdd73cf5 | |
Bauke | 75eddf0e17 | |
Bauke | 2e1c7edfa1 | |
Bauke | 6383e77609 | |
Bauke | 096922ff75 | |
Bauke | 9713f9231e | |
Bauke | 2ee0dcb0ed | |
Bauke | 1bf8f519c6 | |
Bauke | ce7fbfe349 | |
Bauke | 9696b78728 | |
Bauke | 4fc38b74ce |
|
@ -1,112 +1,8 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.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
|
||||
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/
|
||||
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
|
||||
.direnv/
|
||||
.vscode/
|
||||
build/
|
||||
chromium/
|
||||
coverage/
|
||||
firefox/
|
||||
node_modules/
|
||||
web-ext-artifacts/
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard-scss"
|
||||
],
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"string-quotes": "single"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
[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"]
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"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
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
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; };
|
||||
}
|
||||
);
|
||||
}
|
89
package.json
89
package.json
|
@ -1,79 +1,48 @@
|
|||
{
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@holllo/migration-helper": "^0.1.3",
|
||||
"@holllo/migration-helper": "^0.1.4",
|
||||
"@holllo/preact-components": "^0.2.3",
|
||||
"@holllo/test": "^0.2.1",
|
||||
"@holllo/webextension-storage": "^0.2.0",
|
||||
"htm": "^3.1.1",
|
||||
"modern-normalize": "^1.1.0",
|
||||
"preact": "^10.11.0",
|
||||
"preact": "^10.13.1",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.4.0",
|
||||
"@types/babel__core": "^7.1.19",
|
||||
"@types/webextension-polyfill": "^0.9.1",
|
||||
"ava": "^4.3.3",
|
||||
"c8": "^7.12.0",
|
||||
"postcss": "^8.4.16",
|
||||
"sass": "^1.55.0",
|
||||
"stylelint": "^14.12.1",
|
||||
"stylelint-config-standard-scss": "^5.0.0",
|
||||
"@bauke/eslint-config": "^0.1.2",
|
||||
"@bauke/prettier-config": "^0.1.2",
|
||||
"@bauke/stylelint-config": "^0.1.2",
|
||||
"@types/node": "^18.15.11",
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"concurrently": "^8.0.1",
|
||||
"cssnano": "^6.0.0",
|
||||
"esbuild": "^0.17.15",
|
||||
"esbuild-copy-static-files": "^0.1.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",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.8.3",
|
||||
"vite": "^3.1.3",
|
||||
"vite-plugin-web-extension": "^1.4.4",
|
||||
"web-ext": "^7.2.0",
|
||||
"xo": "^0.52.3"
|
||||
"tsx": "^3.12.6",
|
||||
"typescript": "^5.0.2",
|
||||
"web-ext": "^7.6.0",
|
||||
"xo": "^0.53.1"
|
||||
},
|
||||
"ava": {
|
||||
"extensions": [
|
||||
"ts"
|
||||
],
|
||||
"files": [
|
||||
"tests/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register"
|
||||
],
|
||||
"snapshotDir": "tests/snapshots"
|
||||
},
|
||||
"c8": {
|
||||
"include": [
|
||||
"source",
|
||||
"tests"
|
||||
],
|
||||
"reportDir": "coverage",
|
||||
"reporter": [
|
||||
"text",
|
||||
"html"
|
||||
]
|
||||
"prettier": "@bauke/prettier-config",
|
||||
"stylelint": {
|
||||
"extends": "@bauke/stylelint-config"
|
||||
},
|
||||
"xo": {
|
||||
"overrides": [
|
||||
{
|
||||
"files": "tests/**/*.test.ts",
|
||||
"rules": {
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
"import/extensions": "off",
|
||||
"no-await-in-loop": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"extends": "@bauke/eslint-config",
|
||||
"prettier": true,
|
||||
"rules": {
|
||||
"@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
|
||||
}
|
||||
|
|
6229
pnpm-lock.yaml
6229
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,7 @@
|
|||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
with pkgs;
|
||||
|
||||
mkShell rec {
|
||||
packages = [ cargo-make nodejs nodePackages.pnpm ];
|
||||
}
|
|
@ -6,16 +6,15 @@
|
|||
<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="shortcut icon" href="/assets/queue.png" type="image/png">
|
||||
<link rel="stylesheet" href="./index.scss">
|
||||
<link rel="shortcut icon" href="/queue.png" type="image/png">
|
||||
</head>
|
||||
|
||||
<body class="love">
|
||||
<body class="catppuccin">
|
||||
<noscript>
|
||||
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
|
||||
</noscript>
|
||||
|
||||
<script type="module" src="./index.ts"></script>
|
||||
<script type="module" src="./setup.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Binary file not shown.
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 760 B |
|
@ -1,5 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="128" viewBox="0 0 100 100">
|
||||
<rect fill="#E6DEFF" width="100" height="100" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 100 100">
|
||||
<rect fill="#eff1f5" width="100" height="100" />
|
||||
|
||||
<!-- Alignment grid. -->
|
||||
<g display="none">
|
||||
|
@ -13,16 +13,19 @@
|
|||
<rect fill="#f0f" x="86" width="1" height="100" />
|
||||
</g>
|
||||
|
||||
<text
|
||||
fill="#1F1731"
|
||||
font-family="Iosevka SS01"
|
||||
font-size="75"
|
||||
font-weight="900"
|
||||
x="47.9"
|
||||
y="55.6"
|
||||
alignment-baseline="middle"
|
||||
text-anchor="middle"
|
||||
>
|
||||
⇥
|
||||
</text>
|
||||
<g fill="#4c4f69">
|
||||
<path transform="translate(14, 46)" d="
|
||||
M0,0
|
||||
l51,0
|
||||
l-12,-12
|
||||
l4,-4
|
||||
l20,20
|
||||
l-20,20
|
||||
l-4,-4
|
||||
l12,-12
|
||||
l-51,0
|
||||
z
|
||||
" />
|
||||
<rect width="7" height="40" x="78" y="30" />
|
||||
</g>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 730 B After Width: | Height: | Size: 794 B |
|
@ -1,43 +0,0 @@
|
|||
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);
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
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();
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
// 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();
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
// 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);
|
||||
});
|
|
@ -0,0 +1,111 @@
|
|||
// 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);
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
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,
|
||||
},
|
||||
);
|
|
@ -0,0 +1,258 @@
|
|||
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();
|
||||
}
|
|
@ -1,49 +1,60 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
export default function createManifest(
|
||||
target: string,
|
||||
): Record<string, unknown> {
|
||||
const manifest: Record<string, unknown> = {
|
||||
name: 'Queue',
|
||||
description: 'A WebExtension for queueing links.',
|
||||
version: '0.3.0',
|
||||
permissions: ['contextMenus', 'storage'],
|
||||
import {type Manifest} from "webextension-polyfill";
|
||||
|
||||
/**
|
||||
* Creates the WebExtension manifest based on the browser target.
|
||||
*
|
||||
* @param browser The browser target ("firefox" or "chromium").
|
||||
* @returns The WebExtension manifest.
|
||||
*/
|
||||
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: {
|
||||
page: 'options/index.html',
|
||||
page: "options/index.html",
|
||||
open_in_tab: true,
|
||||
},
|
||||
};
|
||||
|
||||
const icons = {
|
||||
128: 'assets/queue.png',
|
||||
const icons: Manifest.IconPath = {
|
||||
128: "queue.png",
|
||||
};
|
||||
|
||||
manifest.icons = icons;
|
||||
|
||||
const browserAction = {
|
||||
const action: Manifest.ActionManifest = {
|
||||
default_icon: icons,
|
||||
};
|
||||
|
||||
const backgroundScript = 'background-scripts/initialize.ts';
|
||||
const backgroundScript = "background/setup.js";
|
||||
|
||||
if (target === 'chromium') {
|
||||
manifest.manifest_version = 3;
|
||||
manifest.action = browserAction;
|
||||
manifest.background = {
|
||||
service_worker: backgroundScript,
|
||||
type: 'module',
|
||||
};
|
||||
} else {
|
||||
if (browser === "firefox") {
|
||||
manifest.manifest_version = 2;
|
||||
manifest.browser_action = browserAction;
|
||||
manifest.background = {
|
||||
scripts: [backgroundScript],
|
||||
};
|
||||
manifest.applications = {
|
||||
manifest.browser_action = action;
|
||||
manifest.browser_specific_settings = {
|
||||
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;
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,117 @@
|
|||
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>);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
|
@ -1,3 +0,0 @@
|
|||
export * from './page-footer.js';
|
||||
export * from './page-header.js';
|
||||
export * from './page-main.js';
|
|
@ -1,39 +0,0 @@
|
|||
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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import {html} from 'htm/preact';
|
||||
import {Component} from 'preact';
|
||||
|
||||
export class PageHeader extends Component {
|
||||
render() {
|
||||
return html`
|
||||
<header class="page-header">
|
||||
<h1>
|
||||
<span class="icon">⇥</span>
|
||||
Queue
|
||||
</h1>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,189 +0,0 @@
|
|||
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>
|
||||
`;
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
@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);
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
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} />
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
@use '../mixins';
|
||||
|
||||
.page-footer {
|
||||
@include mixins.responsive-container;
|
||||
|
||||
border: 1px solid var(--df-2);
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
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;
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
@use 'variables';
|
||||
|
||||
@mixin responsive-container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: variables.$large-breakpoint;
|
||||
|
||||
@media (max-width: variables.$large-breakpoint) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
ol,
|
||||
ul,
|
||||
li,
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
$small-breakpoint: 600px;
|
||||
$medium-breakpoint: 900px;
|
||||
$large-breakpoint: 1200px;
|
||||
$extra-large-breakpoint: 1800px;
|
|
@ -0,0 +1,15 @@
|
|||
// 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;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// 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
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
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;
|
||||
}
|
|
@ -1,118 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,32 +1,8 @@
|
|||
import type {html} from 'htm/preact';
|
||||
// Export something so TypeScript doesn't see this file as an ambient module.
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
// See Vite documentation for `import.meta.env` usage.
|
||||
// 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;
|
||||
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';
|
||||
}
|
||||
const $browser: "chromium" | "firefox";
|
||||
const $dev: boolean;
|
||||
const $test: boolean;
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
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'});
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
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),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
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;
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
/// <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');
|
||||
});
|
|
@ -1,72 +0,0 @@
|
|||
# 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.
|
@ -1,24 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"lib": [
|
||||
"ESNext"
|
||||
"DOM",
|
||||
"ES2022"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": true,
|
||||
"outDir": "build",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"target": "ESNext"
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": [
|
||||
"source/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"vite.config.ts"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
||||
"source"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
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,
|
||||
});
|
Loading…
Reference in New Issue