Compare commits
No commits in common. "main" and "0.1.1" have entirely different histories.
|
@ -1,8 +1,72 @@
|
||||||
.direnv/
|
# Logs
|
||||||
.vscode/
|
logs
|
||||||
build/
|
*.log
|
||||||
chromium/
|
npm-debug.log*
|
||||||
coverage/
|
yarn-debug.log*
|
||||||
firefox/
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://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/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Profile directories
|
||||||
|
chromium/
|
||||||
|
firefox/
|
||||||
|
|
||||||
|
# Parcel cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Output directory
|
||||||
|
build/
|
||||||
web-ext-artifacts/
|
web-ext-artifacts/
|
||||||
|
|
|
@ -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"]
|
|
32
README.md
|
@ -1,33 +1,7 @@
|
||||||
# Queue ⇥
|
# Queue
|
||||||
|
|
||||||
> **Effortless temporary bookmarks.**
|
> A WebExtension for queueing links.
|
||||||
|
|
||||||
[![Get Queue for Firefox](./images/mozilla-addons.png)](https://addons.mozilla.org/firefox/addon/holllo-queue)
|
|
||||||
[![Get Queue for Chrome](./images/chrome-web-store.png)](https://chrome.google.com/webstore/detail/queue/epnbikemcmienphlfmidkimpjnmohcbl)
|
|
||||||
[![Get Queue for Edge](./images/microsoft.png)](https://microsoftedge.microsoft.com/addons/detail/queue/aanjampfdpcnhoeglmfefmmegdbifaak)
|
|
||||||
|
|
||||||
![Latest Queue screenshot](./images/queue-version-0-3-0.png)
|
|
||||||
|
|
||||||
## Wiki
|
|
||||||
|
|
||||||
Want to find out more about Queue? Check out [the wiki](https://git.bauke.xyz/Holllo/queue/wiki).
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
You can install Queue through the stores linked above, [manually from a file] (see [the Releases page] for ZIP files) or [from source](#development).
|
|
||||||
|
|
||||||
[manually from a file]: https://support.mozilla.org/en-US/kb/find-and-install-add-ons-add-features-to-firefox#w_how-do-i-find-and-install-add-ons
|
|
||||||
[the Releases page]: https://git.bauke.xyz/Holllo/queue/releases
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
To build Queue you will need [git](https://git-scm.com), [NodeJS](https://nodejs.org) and [pnpm](https://pnpm.io).
|
|
||||||
|
|
||||||
* Install the dependencies with `pnpm install`.
|
|
||||||
* Start a separate browser with `pnpm start`.
|
|
||||||
* Build the WebExtension for production with `pnpm build`.
|
|
||||||
* Test the code with `pnpm test`.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [AGPL-3.0-or-later](https://spdx.org/licenses/AGPL-3.0-or-later.html) license, see [LICENSE](https://git.bauke.xyz/Holllo/queue/src/branch/main/LICENSE) for more information.
|
Licensed under [AGPL-3.0-or-later](https://github.com/Holllo/queue/blob/main/LICENSE).
|
||||||
|
|
41
flake.lock
|
@ -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
|
|
||||||
}
|
|
13
flake.nix
|
@ -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; };
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
Before Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 105 KiB |
Before Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 103 KiB |
98
package.json
|
@ -1,49 +1,79 @@
|
||||||
{
|
{
|
||||||
|
"name": "queue",
|
||||||
|
"description": "A WebExtension for queueing links.",
|
||||||
|
"author": "Holllo <helllo@holllo.cc>",
|
||||||
|
"repository": "https://github.com/Holllo/queue",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"scripts": {
|
||||||
|
"watch": "NODE_ENV=development parcel 'source/manifest.json' -d 'build/' --no-hmr",
|
||||||
|
"start": "web-ext run --source-dir build/ --bc",
|
||||||
|
"start:chromium": "yarn start --chromium-profile chromium/ --keep-profile-changes --target chromium --start-url \"chrome://extensions\"",
|
||||||
|
"start:firefox": "yarn start --firefox-profile firefox/ --keep-profile-changes --target firefox-desktop --start-url \"about:debugging#/runtime/this-firefox\"",
|
||||||
|
"clean": "trash .cache build/ web-ext-artifacts/",
|
||||||
|
"build": "yarn clean && parcel build 'source/manifest.json' -d 'build/' && web-ext build --source-dir 'build/' && yarn zip-source",
|
||||||
|
"zip-source": "zip -r web-ext-artifacts/queue-source.zip README.md yarn.lock tsconfig.json package.json .gitignore LICENSE source/",
|
||||||
|
"test": "xo && stylelint 'source/scss/**/*.scss' && tsc --noEmit"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@holllo/migration-helper": "^0.1.4",
|
"debounce": "^1.2.0",
|
||||||
"@holllo/preact-components": "^0.2.3",
|
"htm": "^3.0.4",
|
||||||
"@holllo/test": "^0.2.1",
|
"modern-normalize": "^1.0.0",
|
||||||
"@holllo/webextension-storage": "^0.2.0",
|
"preact": "^10.5.5",
|
||||||
"htm": "^3.1.1",
|
"webextension-polyfill-ts": "^0.21.0"
|
||||||
"modern-normalize": "^1.1.0",
|
|
||||||
"preact": "^10.13.1",
|
|
||||||
"webextension-polyfill": "^0.10.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bauke/eslint-config": "^0.1.2",
|
"@types/debounce": "^1.2.0",
|
||||||
"@bauke/prettier-config": "^0.1.2",
|
"eslint-config-xo-typescript": "^0.35.0",
|
||||||
"@bauke/stylelint-config": "^0.1.2",
|
"husky": "^4.3.0",
|
||||||
"@types/node": "^18.15.11",
|
"parcel-bundler": "^1.12.4",
|
||||||
"@types/webextension-polyfill": "^0.10.0",
|
"parcel-plugin-web-extension": "^1.6.1",
|
||||||
"concurrently": "^8.0.1",
|
"sass": "^1.29.0",
|
||||||
"cssnano": "^6.0.0",
|
"stylelint": "^13.7.2",
|
||||||
"esbuild": "^0.17.15",
|
"stylelint-config-xo-scss": "^0.14.0",
|
||||||
"esbuild-copy-static-files": "^0.1.0",
|
"stylelint-config-xo-space": "^0.15.1",
|
||||||
"esbuild-sass-plugin": "^2.8.0",
|
"trash-cli": "^3.1.0",
|
||||||
"postcss": "^8.4.21",
|
"typescript": "^4.0.5",
|
||||||
"sass": "^1.60.0",
|
"web-ext": "^5.3.0",
|
||||||
"stylelint": "^15.3.0",
|
"web-ext-types": "^3.2.1",
|
||||||
"stylelint-config-standard-scss": "^7.0.1",
|
"xo": "^0.34.2"
|
||||||
"trash-cli": "^5.0.0",
|
|
||||||
"tsx": "^3.12.6",
|
|
||||||
"typescript": "^5.0.2",
|
|
||||||
"web-ext": "^7.6.0",
|
|
||||||
"xo": "^0.53.1"
|
|
||||||
},
|
},
|
||||||
"prettier": "@bauke/prettier-config",
|
|
||||||
"stylelint": {
|
"stylelint": {
|
||||||
"extends": "@bauke/stylelint-config"
|
"extends": [
|
||||||
|
"stylelint-config-xo-scss",
|
||||||
|
"stylelint-config-xo-space"
|
||||||
|
],
|
||||||
|
"ignoreFiles": [
|
||||||
|
"source/**/*.ts",
|
||||||
|
"build/**"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"scss/at-rule-no-unknown": null,
|
||||||
|
"at-rule-empty-line-before": null,
|
||||||
|
"at-rule-no-unknown": null,
|
||||||
|
"block-no-empty": null,
|
||||||
|
"no-descending-specificity": null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"xo": {
|
"xo": {
|
||||||
"extends": "@bauke/eslint-config",
|
"globals": [
|
||||||
|
"document",
|
||||||
|
"window"
|
||||||
|
],
|
||||||
"prettier": true,
|
"prettier": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/consistent-type-definitions": "off",
|
"@typescript-eslint/no-implicit-any-catch": "off",
|
||||||
"n/file-extension-in-import": "off",
|
"@typescript-eslint/no-loop-func": "off"
|
||||||
"no-await-in-loop": "off"
|
|
||||||
},
|
},
|
||||||
"space": true
|
"space": true
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"last 2 Chrome versions"
|
||||||
|
],
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "yarn test",
|
||||||
|
"pre-push": "yarn test"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
8507
pnpm-lock.yaml
|
@ -1,7 +0,0 @@
|
||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
with pkgs;
|
|
||||||
|
|
||||||
mkShell rec {
|
|
||||||
packages = [ cargo-make nodejs nodePackages.pnpm ];
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Queue</title>
|
|
||||||
<link rel="shortcut icon" href="/queue.png" type="image/png">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="catppuccin">
|
|
||||||
<noscript>
|
|
||||||
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<script type="module" src="./setup.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 1.8 KiB |
|
@ -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 |
|
@ -0,0 +1,104 @@
|
||||||
|
import {browser} from 'webextension-polyfill-ts';
|
||||||
|
import {
|
||||||
|
error,
|
||||||
|
getManifest,
|
||||||
|
getNextQItem,
|
||||||
|
getSettings,
|
||||||
|
newQItemID,
|
||||||
|
QItem,
|
||||||
|
QMessage,
|
||||||
|
removeQItem,
|
||||||
|
saveSettings
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
let timeoutID: number | null = null;
|
||||||
|
|
||||||
|
browser.browserAction.onClicked.addListener(async () => {
|
||||||
|
// When the extension icon is initially clicked, create a timeout for 500ms
|
||||||
|
// that will open the next queue item when it expires.
|
||||||
|
// If the icon is clicked again in those 500ms, open the options page instead.
|
||||||
|
if (timeoutID === null) {
|
||||||
|
timeoutID = window.setTimeout(async () => {
|
||||||
|
timeoutID = null;
|
||||||
|
const nextItem = await getNextQItem();
|
||||||
|
|
||||||
|
if (nextItem === undefined) {
|
||||||
|
await openOptionsPage();
|
||||||
|
} else {
|
||||||
|
const tabs = await browser.tabs.query({
|
||||||
|
currentWindow: true,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const message: QMessage<QItem> = {
|
||||||
|
action: 'queue open url',
|
||||||
|
data: nextItem
|
||||||
|
};
|
||||||
|
|
||||||
|
await browser.tabs.sendMessage(tabs[0].id!, message);
|
||||||
|
await removeQItem(nextItem.id);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
window.clearTimeout(timeoutID);
|
||||||
|
timeoutID = null;
|
||||||
|
await openOptionsPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(async () => {
|
||||||
|
const manifest = getManifest();
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Open the options page when:
|
||||||
|
// * The extension is first installed or is updated.
|
||||||
|
// * In development, for convenience.
|
||||||
|
if (
|
||||||
|
manifest.version !== settings.latestVersion ||
|
||||||
|
manifest.nodeEnv === 'development'
|
||||||
|
) {
|
||||||
|
settings.latestVersion = manifest.version;
|
||||||
|
await saveSettings(settings);
|
||||||
|
await openOptionsPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openOptionsPage() {
|
||||||
|
return browser.runtime.openOptionsPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback function for custom context menu entries.
|
||||||
|
*/
|
||||||
|
function contextCreated() {
|
||||||
|
if (
|
||||||
|
browser.runtime.lastError !== null &&
|
||||||
|
browser.runtime.lastError !== undefined
|
||||||
|
) {
|
||||||
|
error(browser.runtime.lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.contextMenus.create(
|
||||||
|
{
|
||||||
|
id: 'queue-add-new-link',
|
||||||
|
title: 'Add to Queue',
|
||||||
|
contexts: ['link']
|
||||||
|
},
|
||||||
|
contextCreated
|
||||||
|
);
|
||||||
|
|
||||||
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
|
if (info.menuItemId === 'queue-add-new-link') {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
settings.queue.push({
|
||||||
|
added: new Date(),
|
||||||
|
id: newQItemID(settings.queue),
|
||||||
|
text: info.linkText!,
|
||||||
|
url: info.linkUrl!
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSettings(settings);
|
||||||
|
}
|
||||||
|
});
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
});
|
|
111
source/build.ts
|
@ -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);
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import {initializeBackgroundMessageHandler} from '.';
|
||||||
|
|
||||||
|
initializeBackgroundMessageHandler();
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Queue</title>
|
||||||
|
<link rel="shortcut icon" href="./assets/queue.png" type="image/png">
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="../node_modules/modern-normalize/modern-normalize.css">
|
||||||
|
<link rel="stylesheet" href="./scss/index.scss">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="love">
|
||||||
|
<noscript>
|
||||||
|
This web extension does not work without JavaScript, sorry. :(
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script src="./settings-page.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,12 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
|
||||||
|
type QMessageAction = 'queue open url';
|
||||||
|
|
||||||
|
export type QMessage<T> = {
|
||||||
|
action: QMessageAction;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QComponent = ReturnType<typeof html>;
|
||||||
|
|
||||||
|
export * from './utilities';
|
|
@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"nodeEnv": "development"
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/webextension",
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Queue",
|
||||||
|
"description": "A WebExtension for queueing links.",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"permissions": [
|
||||||
|
"contextMenus",
|
||||||
|
"storage",
|
||||||
|
"tabs",
|
||||||
|
"*://*/*"
|
||||||
|
],
|
||||||
|
"content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'",
|
||||||
|
"web_accessible_resources": [
|
||||||
|
"./assets/**"
|
||||||
|
],
|
||||||
|
"icons": {
|
||||||
|
"128": "./assets/queue.png"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"scripts": [
|
||||||
|
"./background.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_icon": {
|
||||||
|
"128": "./assets/queue.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "./index.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://*/*"
|
||||||
|
],
|
||||||
|
"run_at": "document_end",
|
||||||
|
"js": [
|
||||||
|
"./content-scripts.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "{c3560e6b-00e5-4ab3-b89e-8a54ee5b2c9f}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,61 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
|
||||||
|
|
||||||
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",
|
|
||||||
open_in_tab: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const icons: Manifest.IconPath = {
|
|
||||||
128: "queue.png",
|
|
||||||
};
|
|
||||||
|
|
||||||
const action: Manifest.ActionManifest = {
|
|
||||||
default_icon: icons,
|
|
||||||
};
|
|
||||||
|
|
||||||
const backgroundScript = "background/setup.js";
|
|
||||||
|
|
||||||
if (browser === "firefox") {
|
|
||||||
manifest.manifest_version = 2;
|
|
||||||
manifest.background = {
|
|
||||||
scripts: [backgroundScript],
|
|
||||||
};
|
|
||||||
manifest.browser_action = action;
|
|
||||||
manifest.browser_specific_settings = {
|
|
||||||
gecko: {
|
|
||||||
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;
|
|
||||||
}
|
|
|
@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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>);
|
|
||||||
}
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
li,
|
||||||
|
ol,
|
||||||
|
p,
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
$small-breakpoint: 600px;
|
||||||
|
$medium-breakpoint: 900px;
|
||||||
|
$large-breakpoint: 1200px;
|
||||||
|
$extra-large-breakpoint: 1800px;
|
||||||
|
|
||||||
|
@mixin responsive-container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
width: $large-breakpoint;
|
||||||
|
|
||||||
|
@media (max-width: $large-breakpoint) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,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
|
|
||||||
}
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
.page-footer {
|
||||||
|
@include responsive-container;
|
||||||
|
border: 1px solid var(--lb-2);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
.page-header {
|
||||||
|
@include 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: 4rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 4rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
.page-main {
|
||||||
|
@include 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);
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-button {
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--la-1);
|
||||||
|
border: none;
|
||||||
|
color: var(--df-1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: bold;
|
||||||
|
height: 2.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
background-color: var(--df-1);
|
||||||
|
color: var(--la-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--df-1);
|
||||||
|
color: var(--db-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: square;
|
||||||
|
margin: 4px 0 2rem 16px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
@import 'reset';
|
||||||
|
@import 'variables';
|
||||||
|
@import 'love';
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 62.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--db-1);
|
||||||
|
color: var(--df-1);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--da-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--df-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@import 'components/page-header';
|
||||||
|
@import 'components/page-main';
|
||||||
|
@import 'components/page-footer';
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {html, render} from 'htm/preact';
|
||||||
|
import {
|
||||||
|
initializeBackgroundMessageHandler,
|
||||||
|
getManifest,
|
||||||
|
getSettings,
|
||||||
|
PageFooter,
|
||||||
|
PageHeader,
|
||||||
|
PageMain
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
initializeBackgroundMessageHandler();
|
||||||
|
|
||||||
|
const manifest = getManifest();
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
render(
|
||||||
|
html`
|
||||||
|
<${PageHeader} />
|
||||||
|
<${PageMain} settings=${settings} />
|
||||||
|
<${PageFooter} manifest=${manifest} />
|
||||||
|
`,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
})();
|
|
@ -1,8 +0,0 @@
|
||||||
// Export something so TypeScript doesn't see this file as an ambient module.
|
|
||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
const $browser: "chromium" | "firefox";
|
|
||||||
const $dev: boolean;
|
|
||||||
const $test: boolean;
|
|
||||||
}
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {useState} from 'preact/hooks';
|
||||||
|
import {QComponent} from '../..';
|
||||||
|
|
||||||
|
type LinkProps = {
|
||||||
|
class: string;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new <a/> element with target="_blank" and rel="noopener".
|
||||||
|
* @param props The Link properties.
|
||||||
|
*/
|
||||||
|
export function Link(props: LinkProps): QComponent {
|
||||||
|
return html`
|
||||||
|
<a class=${props.class} href=${props.url} target="_blank" rel="noopener">
|
||||||
|
${props.text}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmButtonProps = {
|
||||||
|
// Extra classes to add to the button.
|
||||||
|
class: string;
|
||||||
|
// The click handler to call when confirmed.
|
||||||
|
clickHandler: (event: MouseEvent) => void;
|
||||||
|
// The class to add when in the confirm state.
|
||||||
|
confirmClass: string;
|
||||||
|
// The text to use when in the confirm state.
|
||||||
|
confirmText: string;
|
||||||
|
// The text to use when in the default state.
|
||||||
|
text: string;
|
||||||
|
// The timeout for the confirm state to return back to default.
|
||||||
|
timeout: number;
|
||||||
|
// The title to add to the element.
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a button that requires 2 clicks to trigger the main click handler.
|
||||||
|
* @param props The ConfirmButton properties.
|
||||||
|
*/
|
||||||
|
export function ConfirmButton(props: ConfirmButtonProps): QComponent {
|
||||||
|
let timeoutHandle: number | undefined;
|
||||||
|
|
||||||
|
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||||
|
const click = (event: MouseEvent) => {
|
||||||
|
if (isConfirmed) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
props.clickHandler(event);
|
||||||
|
timeoutHandle = undefined;
|
||||||
|
setIsConfirmed(false);
|
||||||
|
} else {
|
||||||
|
timeoutHandle = window.setTimeout(() => {
|
||||||
|
setIsConfirmed(false);
|
||||||
|
}, props.timeout);
|
||||||
|
setIsConfirmed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmedClass = isConfirmed ? props.confirmClass : '';
|
||||||
|
const text = isConfirmed ? props.confirmText : props.text;
|
||||||
|
const title = isConfirmed ? `Confirm ${props.title}` : props.title;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="${props.class} ${confirmedClass}"
|
||||||
|
onClick=${click}
|
||||||
|
title="${title}"
|
||||||
|
>
|
||||||
|
${text}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './page-footer';
|
||||||
|
export * from './page-header';
|
||||||
|
export * from './page-main';
|
|
@ -0,0 +1,22 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {Link, QComponent, QManifest} from '../..';
|
||||||
|
|
||||||
|
type FooterProps = {
|
||||||
|
manifest: QManifest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageFooter(props: FooterProps): QComponent {
|
||||||
|
const version = props.manifest.version;
|
||||||
|
const versionLink = html`<${Link}
|
||||||
|
text="v${version}"
|
||||||
|
url="https://github.com/Holllo/queue/releases/tag/${version}"
|
||||||
|
/>`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<footer class="page-footer">
|
||||||
|
<p>
|
||||||
|
${versionLink} 🄯 Holllo — Free and open-source, forever.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {QComponent} from '../..';
|
||||||
|
|
||||||
|
export function PageHeader(): QComponent {
|
||||||
|
return html`
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>
|
||||||
|
<span class="icon">⇥</span>
|
||||||
|
Queue
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
import {html} from 'htm/preact';
|
||||||
|
import {useState} from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
ConfirmButton,
|
||||||
|
Link,
|
||||||
|
QComponent,
|
||||||
|
QItem,
|
||||||
|
removeQItem,
|
||||||
|
Settings
|
||||||
|
} from '../..';
|
||||||
|
|
||||||
|
type MainProps = {
|
||||||
|
settings: Settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageMain(props: MainProps): QComponent {
|
||||||
|
const [queue, updateQueue] = useState(props.settings.queue);
|
||||||
|
|
||||||
|
const _removeQItem = async (id: number) => {
|
||||||
|
const updated = await removeQItem(id);
|
||||||
|
updateQueue(updated.queue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const qItems: QComponent[] = queue
|
||||||
|
.sort((a, b) => a.added.getTime() - b.added.getTime())
|
||||||
|
.map((item) => html`<${Item} item=${item} remove=${_removeQItem} />`);
|
||||||
|
|
||||||
|
if (qItems.length === 0) {
|
||||||
|
qItems.push(html`<li>No items queued. 🤷</li>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<main class="page-main">
|
||||||
|
<ul class="q-list">
|
||||||
|
${qItems}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<details class="usage">
|
||||||
|
<summary>How do I use Queue?</summary>
|
||||||
|
|
||||||
|
<p>Adding links to your queue:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Right-click any link and click "Add to Queue".</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Opening the next link from your queue:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Click on the extension icon.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Opening the extension page:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Double-click the extension icon.</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: QItem;
|
||||||
|
remove: (id: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Item(props: ItemProps): QComponent {
|
||||||
|
const {added, id, text, url} = props.item;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<li class="q-item">
|
||||||
|
<p class="title">
|
||||||
|
<${Link} text=${text ?? url} url=${url} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<${ConfirmButton}
|
||||||
|
class="confirm-button"
|
||||||
|
clickHandler=${async () => props.remove(id)}
|
||||||
|
confirmClass="confirm"
|
||||||
|
confirmText="✓"
|
||||||
|
text="✗"
|
||||||
|
timeout=${5 * 1000}
|
||||||
|
title="Remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<time
|
||||||
|
datetime=${added.toLocaleString()}
|
||||||
|
title="Link queued on ${added.toLocaleString()}."
|
||||||
|
>
|
||||||
|
${added.toLocaleString()}
|
||||||
|
</time>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {browser, Manifest} from 'webextension-polyfill-ts';
|
||||||
|
import {QItem, QMessage} from '..';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the background message handler.
|
||||||
|
*/
|
||||||
|
export function initializeBackgroundMessageHandler() {
|
||||||
|
browser.runtime.onMessage.addListener((request: QMessage<unknown>) => {
|
||||||
|
if (request.action === 'queue open url') {
|
||||||
|
// TypeScript can't assign QMessage<unknown> to QMessage<QItem> but since
|
||||||
|
// we know it's correct, just ignore the error.
|
||||||
|
// @ts-expect-error
|
||||||
|
const message: QMessage<QItem> = request;
|
||||||
|
window.location.href = message.data.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebExtension Manifest with an extra nodeEnv property.
|
||||||
|
*/
|
||||||
|
export type QManifest = {nodeEnv?: string} & Manifest.ManifestBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the WebExtension Manifest.
|
||||||
|
*/
|
||||||
|
export function getManifest(): QManifest {
|
||||||
|
const manifest: Manifest.ManifestBase = browser.runtime.getManifest();
|
||||||
|
return {...manifest};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a thing in the console in the debug level.
|
||||||
|
* @param thing The thing to log.
|
||||||
|
*/
|
||||||
|
export function log(thing: unknown) {
|
||||||
|
console.debug('[Queue]', thing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a thing in the console in the error level.
|
||||||
|
* @param thing The thing to log.
|
||||||
|
*/
|
||||||
|
export function error(thing: unknown) {
|
||||||
|
console.error('[Queue]', thing);
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './components';
|
||||||
|
export * from './settings';
|
|
@ -0,0 +1,92 @@
|
||||||
|
import {browser} from 'webextension-polyfill-ts';
|
||||||
|
import {log} from '.';
|
||||||
|
|
||||||
|
export type QItem = {
|
||||||
|
added: Date;
|
||||||
|
id: number;
|
||||||
|
text?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
latestVersion: string;
|
||||||
|
queue: QItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSettings: Settings = {
|
||||||
|
latestVersion: '0.0.0',
|
||||||
|
queue: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user's settings.
|
||||||
|
*/
|
||||||
|
export async function getSettings(): Promise<Settings> {
|
||||||
|
const syncSettings: any = await browser.storage.sync.get(defaultSettings);
|
||||||
|
const settings: Settings = {
|
||||||
|
latestVersion: syncSettings.latestVersion,
|
||||||
|
queue: syncSettings.queue
|
||||||
|
};
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the user's settings to local storage.
|
||||||
|
* @param settings The settings to save.
|
||||||
|
*/
|
||||||
|
export async function saveSettings(settings: Settings): Promise<Settings> {
|
||||||
|
await browser.storage.sync.set(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new ID to use for a new QItem.
|
||||||
|
* @param items All the queue items.
|
||||||
|
*/
|
||||||
|
export function newQItemID(items: QItem[]): number {
|
||||||
|
const highestItem = items.sort((a, b) => b.id - a.id)[0];
|
||||||
|
if (highestItem === undefined) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return highestItem.id + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a QItem from the Settings with the specified ID.
|
||||||
|
* @param id The ID of the QItem to be removed.
|
||||||
|
* @param settings Optional user settings to use.
|
||||||
|
*/
|
||||||
|
export async function removeQItem(
|
||||||
|
id: number,
|
||||||
|
settings?: Settings
|
||||||
|
): Promise<Settings> {
|
||||||
|
if (settings === undefined) {
|
||||||
|
settings = await getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = settings.queue.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
log(`No QItem with ID ${id} found.`);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.queue.splice(index, 1);
|
||||||
|
return saveSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next QItem.
|
||||||
|
* @param settings Optional user settings to use.
|
||||||
|
*/
|
||||||
|
export async function getNextQItem(
|
||||||
|
settings?: Settings
|
||||||
|
): Promise<QItem | undefined> {
|
||||||
|
if (settings === undefined) {
|
||||||
|
settings = await getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.queue.sort((a, b) => a.added.getTime() - b.added.getTime());
|
||||||
|
return settings.queue[0];
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,19 +1,22 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "preact",
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"ES2019"
|
||||||
"ES2022"
|
|
||||||
],
|
],
|
||||||
"module": "ES2022",
|
"module": "commonjs",
|
||||||
"moduleResolution": "Node",
|
"outDir": "build/",
|
||||||
"resolveJsonModule": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2022"
|
"target": "es6",
|
||||||
|
"typeRoots": [
|
||||||
|
"node_modules/@types",
|
||||||
|
"node_modules/web-ext-types"
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"source"
|
"source/**/*.ts",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|