Compare commits
98 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 | |
Bauke | 913e8f0da9 | |
Bauke | ee012aea1d | |
Bauke | f6571b895b | |
Bauke | e591db7bcf | |
Bauke | d4400c5c31 | |
Bauke | c72959841f | |
Bauke | ab6ef97d91 | |
Bauke | f54b3d6712 | |
Bauke | 526642158c | |
Bauke | 13542fe219 | |
Bauke | 2f08cc8e26 | |
Bauke | 639d2b4462 | |
Bauke | 88a1066081 | |
Bauke | 46bacf20f4 | |
Bauke | 6f6c2937d8 | |
Bauke | f582543a30 | |
Bauke | 4eb9e911be | |
Bauke | c9627fed24 | |
Bauke | 4b9a890861 | |
Bauke | 1abab7f635 | |
Bauke | 587f049e08 | |
Bauke | f43dee71ec | |
Bauke | 6fc61d476c | |
Bauke | c89156fe88 | |
Bauke | 0aa26aa809 | |
Bauke | 163f838ffc | |
Bauke | bdb06a1b15 | |
Bauke | dcf395f01d | |
Bauke | ded38c7bf7 | |
Bauke | 8d99e8200b | |
Bauke | bf8712dbf6 | |
Bauke | 62f2188070 | |
Bauke | e18714c9f2 | |
Bauke | e6d1834a42 | |
Bauke | bb9680c19b | |
Bauke | 3a5f4f7027 | |
Bauke | 21e29bf655 | |
Bauke | c25e390f68 | |
Bauke | 5c9d6668b3 | |
Bauke | 7312aa1039 | |
Bauke | 11bb3c695c | |
Bauke | f4d0009f73 | |
Bauke | adab28447d |
|
@ -1,112 +1,8 @@
|
||||||
# Logs
|
.direnv/
|
||||||
logs
|
.vscode/
|
||||||
*.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
|
|
||||||
build/
|
build/
|
||||||
|
chromium/
|
||||||
|
coverage/
|
||||||
|
firefox/
|
||||||
|
node_modules/
|
||||||
web-ext-artifacts/
|
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"]
|
53
README.md
|
@ -1,48 +1,33 @@
|
||||||
# Queue
|
# Queue ⇥
|
||||||
|
|
||||||
> A WebExtension for queueing links.
|
> **Effortless temporary bookmarks.**
|
||||||
|
|
||||||
[![Queue on AMO](https://img.shields.io/amo/v/holllo-queue)](https://addons.mozilla.org/firefox/addon/holllo-queue)
|
[![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)
|
||||||
|
|
||||||
![Queue 0.2.0](./screenshots/queue-version-0-2-0.png)
|
![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
|
## Installation
|
||||||
|
|
||||||
You can install Queue through [Mozilla Addons], [installing from a file] (see [the Releases page] for a prebuilt version) or building [from source](#development).
|
You can install Queue through the stores linked above, [manually from a file] (see [the Releases page] for ZIP files) or [from source](#development).
|
||||||
|
|
||||||
[installing from a file]: https://support.mozilla.org/en-US/kb/find-and-install-add-ons-add-features-to-firefox#w_how-do-i-find-and-install-add-ons
|
[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
|
||||||
[Mozilla Addons]: https://addons.mozilla.org/firefox/addon/holllo-queue/
|
[the Releases page]: https://git.bauke.xyz/Holllo/queue/releases
|
||||||
[the Releases page]: https://github.com/Holllo/queue/releases
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
To build Queue you will need [git], [NodeJS] and [pnpm]. Then from a terminal, run the following commands.
|
To build Queue you will need [git](https://git-scm.com), [NodeJS](https://nodejs.org) and [pnpm](https://pnpm.io).
|
||||||
|
|
||||||
[git]: https://git-scm.com
|
* Install the dependencies with `pnpm install`.
|
||||||
[NodeJS]: https://nodejs.org
|
* Start a separate browser with `pnpm start`.
|
||||||
[pnpm]: https://pnpm.io
|
* Build the WebExtension for production with `pnpm build`.
|
||||||
|
* Test the code with `pnpm test`.
|
||||||
```sh
|
|
||||||
# Step 1. Download the repository with Git.
|
|
||||||
git clone https://github.com/Holllo/queue
|
|
||||||
cd queue
|
|
||||||
|
|
||||||
# Step 2. Install the dependencies.
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Step 3. Start an auto-reloading browser instance for development.
|
|
||||||
pnpm start
|
|
||||||
|
|
||||||
# Step 4. Lint the code and run tests.
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# Step 5. Build the WebExtension for production.
|
|
||||||
# See the web-ext-artifacts directory for output.
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Queue is open-sourced with the [AGPL-3.0-or-later] 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.
|
||||||
|
|
||||||
[AGPL-3.0-or-later]: https://github.com/Holllo/queue/blob/main/LICENSE
|
|
||||||
|
|
|
@ -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; };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
After Width: | Height: | Size: 3.7 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
After Width: | Height: | Size: 118 KiB |
After Width: | Height: | Size: 103 KiB |
100
package.json
|
@ -1,79 +1,49 @@
|
||||||
{
|
{
|
||||||
"name": "queue",
|
|
||||||
"description": "A WebExtension for queueing links.",
|
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"author": "Holllo <helllo@holllo.cc>",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Holllo/queue"
|
|
||||||
},
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"start": "vite build -m development --watch",
|
|
||||||
"clean": "trash build web-ext-artifacts",
|
|
||||||
"build": "pnpm clean && vite build && web-ext build --source-dir build && pnpm zip-source",
|
|
||||||
"zip-source": "git archive --format zip --output web-ext-artifacts/queue-source.zip HEAD",
|
|
||||||
"test": "xo && stylelint 'source/**/*.scss' && tsc && c8 ava"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htm": "^3.1.0",
|
"@holllo/migration-helper": "^0.1.4",
|
||||||
"migration-helper": "^0.1.2",
|
"@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",
|
"modern-normalize": "^1.1.0",
|
||||||
"preact": "^10.6.6",
|
"preact": "^10.13.1",
|
||||||
"webextension-polyfill": "^0.8.0"
|
"webextension-polyfill": "^0.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "^2.1.7",
|
"@bauke/eslint-config": "^0.1.2",
|
||||||
"@types/webextension-polyfill": "^0.8.2",
|
"@bauke/prettier-config": "^0.1.2",
|
||||||
"ava": "^4.0.1",
|
"@bauke/stylelint-config": "^0.1.2",
|
||||||
"c8": "^7.11.0",
|
"@types/node": "^18.15.11",
|
||||||
"postcss": "^8.4.7",
|
"@types/webextension-polyfill": "^0.10.0",
|
||||||
"sass": "^1.49.9",
|
"concurrently": "^8.0.1",
|
||||||
"stylelint": "^14.5.3",
|
"cssnano": "^6.0.0",
|
||||||
"stylelint-config-standard-scss": "^3.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",
|
"trash-cli": "^5.0.0",
|
||||||
"ts-node": "^10.6.0",
|
"tsx": "^3.12.6",
|
||||||
"typescript": "^4.5.5",
|
"typescript": "^5.0.2",
|
||||||
"vite": "^2.8.4",
|
"web-ext": "^7.6.0",
|
||||||
"vite-plugin-web-extension": "^1.1.2",
|
"xo": "^0.53.1"
|
||||||
"web-ext": "^6.7.0",
|
|
||||||
"xo": "^0.48.0"
|
|
||||||
},
|
},
|
||||||
"ava": {
|
"prettier": "@bauke/prettier-config",
|
||||||
"extensions": [
|
"stylelint": {
|
||||||
"ts"
|
"extends": "@bauke/stylelint-config"
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
"tests/**/*.test.ts"
|
|
||||||
],
|
|
||||||
"require": [
|
|
||||||
"ts-node/register"
|
|
||||||
],
|
|
||||||
"snapshotDir": "tests/snapshots"
|
|
||||||
},
|
|
||||||
"c8": {
|
|
||||||
"include": [
|
|
||||||
"source",
|
|
||||||
"tests"
|
|
||||||
],
|
|
||||||
"reportDir": "coverage",
|
|
||||||
"reporter": [
|
|
||||||
"text",
|
|
||||||
"html"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"xo": {
|
"xo": {
|
||||||
"overrides": [
|
"extends": "@bauke/eslint-config",
|
||||||
{
|
|
||||||
"files": "tests/**/*.test.ts",
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/triple-slash-reference": "off",
|
|
||||||
"import/extensions": "off",
|
|
||||||
"no-await-in-loop": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prettier": true,
|
"prettier": true,
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/consistent-type-definitions": "off",
|
||||||
|
"n/file-extension-in-import": "off",
|
||||||
|
"no-await-in-loop": "off"
|
||||||
|
},
|
||||||
"space": true
|
"space": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
6799
pnpm-lock.yaml
|
@ -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 http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Queue</title>
|
<title>Queue</title>
|
||||||
<link rel="shortcut icon" href="/assets/queue.png" type="image/png">
|
<link rel="shortcut icon" href="/queue.png" type="image/png">
|
||||||
<link rel="stylesheet" href="./index.scss">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="love">
|
<body class="catppuccin">
|
||||||
<noscript>
|
<noscript>
|
||||||
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
|
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<script type="module" src="./index.ts"></script>
|
<script type="module" src="./setup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 100 100">
|
||||||
<rect fill="#E6DEFF" width="100" height="100" />
|
<rect fill="#eff1f5" width="100" height="100" />
|
||||||
|
|
||||||
<!-- Alignment grid. -->
|
<!-- Alignment grid. -->
|
||||||
<g display="none">
|
<g display="none">
|
||||||
|
@ -13,16 +13,19 @@
|
||||||
<rect fill="#f0f" x="86" width="1" height="100" />
|
<rect fill="#f0f" x="86" width="1" height="100" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<text
|
<g fill="#4c4f69">
|
||||||
fill="#1F1731"
|
<path transform="translate(14, 46)" d="
|
||||||
font-family="Iosevka SS01"
|
M0,0
|
||||||
font-size="75"
|
l51,0
|
||||||
font-weight="900"
|
l-12,-12
|
||||||
x="47.9"
|
l4,-4
|
||||||
y="55.6"
|
l20,20
|
||||||
alignment-baseline="middle"
|
l-20,20
|
||||||
text-anchor="middle"
|
l-4,-4
|
||||||
>
|
l12,-12
|
||||||
⇥
|
l-51,0
|
||||||
</text>
|
z
|
||||||
|
" />
|
||||||
|
<rect width="7" height="40" x="78" y="30" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 730 B After Width: | Height: | Size: 794 B |
|
@ -1,35 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {Settings} from '../settings/settings.js';
|
|
||||||
import {updateBadge} from '../utilities/badge.js';
|
|
||||||
|
|
||||||
let timeoutId: number | undefined;
|
|
||||||
|
|
||||||
export async function browserActionClicked(): Promise<void> {
|
|
||||||
const settings = await Settings.fromSyncStorage();
|
|
||||||
|
|
||||||
// When the extension icon is initially clicked, create a timeout for 500ms
|
|
||||||
// that will open the next queue item when it expires.
|
|
||||||
if (timeoutId === undefined) {
|
|
||||||
timeoutId = window.setTimeout(async () => {
|
|
||||||
timeoutId = undefined;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}, 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();
|
|
||||||
}
|
|
|
@ -1,97 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {Settings} from '../settings/settings.js';
|
|
||||||
import {updateBadge} from '../utilities/badge.js';
|
|
||||||
|
|
||||||
const contextMenus: browser.Menus.CreateCreatePropertiesType[] = [
|
|
||||||
{
|
|
||||||
id: 'queue-add-new-link',
|
|
||||||
title: 'Add to Queue',
|
|
||||||
contexts: ['link'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-add-new-link-tab',
|
|
||||||
title: 'Add to Queue',
|
|
||||||
contexts: ['tab'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-open-next-link-in-new-tab',
|
|
||||||
title: 'Open next link in new tab',
|
|
||||||
contexts: ['browser_action'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'queue-open-options-page',
|
|
||||||
title: 'Open the extension page',
|
|
||||||
contexts: ['browser_action'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const contextMenuIds = new Set<string>(
|
|
||||||
contextMenus.map(({id}) => id ?? 'queue-unknown'),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function initializeContextMenus(): void {
|
|
||||||
for (const contextMenu of contextMenus) {
|
|
||||||
browser.contextMenus.create(contextMenu, contextCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.contextMenus.onClicked.addListener(contextClicked);
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextCreated(): void {
|
|
||||||
const error = browser.runtime.lastError;
|
|
||||||
|
|
||||||
if (error !== null && error !== undefined) {
|
|
||||||
console.error('Queue', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function contextClicked(
|
|
||||||
info: browser.Menus.OnClickData,
|
|
||||||
tab?: browser.Tabs.Tab,
|
|
||||||
): Promise<void> {
|
|
||||||
const id = info.menuItemId.toString();
|
|
||||||
if (!contextMenuIds.has(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await Settings.fromSyncStorage();
|
|
||||||
|
|
||||||
if (id.startsWith('queue-add-new-link')) {
|
|
||||||
let text: string | undefined;
|
|
||||||
let url: string | undefined;
|
|
||||||
|
|
||||||
switch (id) {
|
|
||||||
case 'queue-add-new-link':
|
|
||||||
text = info.linkText;
|
|
||||||
url = info.linkUrl;
|
|
||||||
break;
|
|
||||||
case 'queue-add-new-link-tab':
|
|
||||||
text = tab?.title;
|
|
||||||
url = info.pageUrl;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.warn(`Encountered unknown context menu ID: ${id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url === undefined) {
|
|
||||||
console.warn('Cannot add a new item without a URL.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await settings.insertQueueItem(text ?? url, url);
|
|
||||||
await updateBadge(settings);
|
|
||||||
} else if (id === 'queue-open-next-link-in-new-tab') {
|
|
||||||
const nextItem = settings.nextQueueItem();
|
|
||||||
if (nextItem === undefined) {
|
|
||||||
await browser.runtime.openOptionsPage();
|
|
||||||
} else {
|
|
||||||
await browser.tabs.create({active: true, url: nextItem.url});
|
|
||||||
await settings.removeQueueItem(nextItem.id);
|
|
||||||
await settings.save();
|
|
||||||
}
|
|
||||||
} else if (id === 'queue-open-options-page') {
|
|
||||||
await browser.runtime.openOptionsPage();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {History} from '../utilities/history.js';
|
|
||||||
import {browserActionClicked} from './browser-action.js';
|
|
||||||
import {initializeContextMenus} from './context-menus.js';
|
|
||||||
|
|
||||||
browser.runtime.onStartup.addListener(async () => {
|
|
||||||
console.debug('Clearing history.');
|
|
||||||
await History.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
browser.browserAction.onClicked.addListener(browserActionClicked);
|
|
||||||
|
|
||||||
browser.runtime.onInstalled.addListener(async () => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
await browser.runtime.openOptionsPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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,38 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json.schemastore.org/webextension",
|
|
||||||
"manifest_version": 2,
|
|
||||||
"name": "Queue",
|
|
||||||
"description": "A WebExtension for queueing links.",
|
|
||||||
"version": "0.2.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"
|
|
||||||
},
|
|
||||||
"browser_action": {
|
|
||||||
"default_icon": {
|
|
||||||
"128": "assets/queue.png"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"options_ui": {
|
|
||||||
"page": "options/index.html",
|
|
||||||
"open_in_tab": true
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"scripts": [
|
|
||||||
"background-scripts/initialize.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"applications": {
|
|
||||||
"gecko": {
|
|
||||||
"id": "{c3560e6b-00e5-4ab3-b89e-8a54ee5b2c9f}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
|
@ -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,5 +0,0 @@
|
||||||
export * from './confirm-button.js';
|
|
||||||
export * from './link.js';
|
|
||||||
export * from './page-footer.js';
|
|
||||||
export * from './page-header.js';
|
|
||||||
export * from './page-main.js';
|
|
|
@ -1,72 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
import {Component} from 'preact';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
// Extra classes to add to the button.
|
|
||||||
cssClass: string;
|
|
||||||
// The click handler to call when confirmed.
|
|
||||||
clickHandler: (event: MouseEvent) => void;
|
|
||||||
// The class to add when in the confirm state.
|
|
||||||
confirmClass: string;
|
|
||||||
// The text to use when in the confirm state.
|
|
||||||
confirmText: string;
|
|
||||||
// The text to use when in the default state.
|
|
||||||
text: string;
|
|
||||||
// The timeout for the confirm state to return back to default.
|
|
||||||
timeout: number;
|
|
||||||
// The title to add to the element.
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
isConfirmed: boolean;
|
|
||||||
timeoutHandle?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ConfirmButton extends Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
isConfirmed: false,
|
|
||||||
timeoutHandle: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
onClick = (event: MouseEvent) => {
|
|
||||||
const {clickHandler, timeout} = this.props;
|
|
||||||
const {isConfirmed, timeoutHandle} = this.state;
|
|
||||||
|
|
||||||
if (isConfirmed) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
clickHandler(event);
|
|
||||||
this.setState({
|
|
||||||
isConfirmed: false,
|
|
||||||
timeoutHandle: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isConfirmed: true,
|
|
||||||
timeoutHandle: window.setTimeout(() => {
|
|
||||||
this.setState({isConfirmed: false});
|
|
||||||
}, timeout),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {confirmClass, confirmText, cssClass, text, title} = this.props;
|
|
||||||
const {isConfirmed} = this.state;
|
|
||||||
|
|
||||||
const confirmedClass = isConfirmed ? confirmClass : '';
|
|
||||||
const buttonText = isConfirmed ? confirmText : text;
|
|
||||||
const buttonTitle = isConfirmed ? `Confirm ${title}` : title;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<button
|
|
||||||
class="${cssClass} ${confirmedClass}"
|
|
||||||
onClick=${this.onClick}
|
|
||||||
title="${buttonTitle}"
|
|
||||||
>
|
|
||||||
${buttonText}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
import {Component} from 'preact';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
cssClass: string;
|
|
||||||
text: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Link extends Component<Props> {
|
|
||||||
render() {
|
|
||||||
const {cssClass, text, url} = this.props;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<a class=${cssClass} href=${url} target="_blank" rel="noopener">
|
|
||||||
${text}
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
import {html} from 'htm/preact';
|
|
||||||
import {Component} from 'preact';
|
|
||||||
|
|
||||||
import {Settings} from '../../settings/settings.js';
|
|
||||||
import {Link} from './link.js';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
settings: Settings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PageFooter extends Component<Props> {
|
|
||||||
render() {
|
|
||||||
const {settings} = this.props;
|
|
||||||
const version = settings.manifest.version;
|
|
||||||
|
|
||||||
const donateLink = html`
|
|
||||||
<${Link} text="Donate" url="https://github.com/sponsors/Bauke" />
|
|
||||||
`;
|
|
||||||
|
|
||||||
const versionLink = html`
|
|
||||||
<${Link}
|
|
||||||
text="v${version}"
|
|
||||||
url="https://github.com/Holllo/queue/releases/tag/${version}"
|
|
||||||
/>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<footer class="page-footer">
|
|
||||||
<p>
|
|
||||||
${donateLink} 💖 ${versionLink} © Holllo — Free and open-source,
|
|
||||||
forever.
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,147 +0,0 @@
|
||||||
import {Component, html} from 'htm/preact';
|
|
||||||
|
|
||||||
import {Settings} from '../../settings/settings.js';
|
|
||||||
import {updateBadge} from '../../utilities/badge.js';
|
|
||||||
import {History} from '../../utilities/history.js';
|
|
||||||
import {ConfirmButton} from './confirm-button.js';
|
|
||||||
import {Link} from './link.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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
removeItem = async (id: number) => {
|
|
||||||
const {settings} = this.props;
|
|
||||||
await settings.removeQueueItem(id);
|
|
||||||
await updateBadge(settings);
|
|
||||||
this.setState({queue: this.props.settings.queue});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const queueItems = this.state.queue
|
|
||||||
.sort((a, b) => a.added.getTime() - b.added.getTime())
|
|
||||||
.map(
|
|
||||||
(item) => html`<${queueItem} item=${item} 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 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>
|
|
||||||
<li>Double-click the extension icon.</li>
|
|
||||||
<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;
|
|
||||||
remove?: (id: number) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function queueItem(props: ItemProps): HtmComponent {
|
|
||||||
const added = props.item.added.toLocaleString();
|
|
||||||
const {id, text, url} = props.item;
|
|
||||||
let remove;
|
|
||||||
if (props.remove !== undefined) {
|
|
||||||
remove = html`
|
|
||||||
<${ConfirmButton}
|
|
||||||
cssClass="confirm-button"
|
|
||||||
clickHandler=${async () => props.remove!(id)}
|
|
||||||
confirmClass="confirm"
|
|
||||||
confirmText="✓"
|
|
||||||
text="✗"
|
|
||||||
timeout=${5 * 1000}
|
|
||||||
title="Remove"
|
|
||||||
/>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<li class="q-item">
|
|
||||||
<p class="title">
|
|
||||||
<${Link} text=${text ?? url} url=${url} />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="buttons">${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: 4rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 8px;
|
|
||||||
width: 4rem;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,94 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-button {
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--la-1);
|
|
||||||
border: none;
|
|
||||||
color: var(--df-1);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-weight: bold;
|
|
||||||
height: 2.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
|
|
||||||
&.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;
|
|
||||||
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,44 +0,0 @@
|
||||||
import {Migration} from 'migration-helper';
|
|
||||||
|
|
||||||
export const dataMigrations: Array<Migration<string>> = [
|
|
||||||
{
|
|
||||||
version: '0.1.7',
|
|
||||||
async migrate(data: Record<string, any>) {
|
|
||||||
const migrated: Record<string, any> = {
|
|
||||||
version: '0.1.7',
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = (data.queue as Queue.Item[]) ?? [];
|
|
||||||
for (const item of items) {
|
|
||||||
const key = `qi${item.id}`;
|
|
||||||
migrated[key] = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return migrated;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function deserializeQueue(data: Record<string, any>): Queue.Item[] {
|
|
||||||
const deserialized: Queue.Item[] = [];
|
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(data)) {
|
|
||||||
if (/^qi\d+$/.test(key)) {
|
|
||||||
item.added = new Date(item.added);
|
|
||||||
deserialized.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deserialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeQueue(queue: Queue.Item[]): Record<string, any> {
|
|
||||||
const serialized: Record<string, any> = {};
|
|
||||||
|
|
||||||
for (const item of queue) {
|
|
||||||
const key = `qi${item.id}`;
|
|
||||||
serialized[key] = item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return serialized;
|
|
||||||
}
|
|
|
@ -1,84 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {migrate} from 'migration-helper';
|
|
||||||
|
|
||||||
import {History} from '../utilities/history.js';
|
|
||||||
import {
|
|
||||||
dataMigrations,
|
|
||||||
deserializeQueue,
|
|
||||||
serializeQueue,
|
|
||||||
} from './migrations.js';
|
|
||||||
|
|
||||||
export class Settings {
|
|
||||||
public static async fromSyncStorage(): Promise<Settings> {
|
|
||||||
const settings = new Settings();
|
|
||||||
|
|
||||||
const sync = await browser.storage.sync.get(null);
|
|
||||||
const migrated = (await migrate(
|
|
||||||
sync,
|
|
||||||
sync.version ?? settings.version,
|
|
||||||
dataMigrations,
|
|
||||||
)) as Record<string, any>;
|
|
||||||
|
|
||||||
settings.queue = deserializeQueue(migrated);
|
|
||||||
settings.version = migrated.version as string;
|
|
||||||
|
|
||||||
await settings.save();
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public manifest: browser.Manifest.ManifestBase;
|
|
||||||
public queue: Queue.Item[];
|
|
||||||
public version: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.manifest = browser.runtime.getManifest();
|
|
||||||
this.queue = [];
|
|
||||||
this.version = '0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
public async insertQueueItem(text: string, url: string): Promise<void> {
|
|
||||||
const id = this.newQueueItemId();
|
|
||||||
const item: Queue.Item = {
|
|
||||||
added: new Date(),
|
|
||||||
id,
|
|
||||||
text,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
this.queue.push(item);
|
|
||||||
|
|
||||||
const sync: Record<string, Queue.Item> = {};
|
|
||||||
sync[`qi${id}`] = item;
|
|
||||||
await browser.storage.sync.set(sync);
|
|
||||||
}
|
|
||||||
|
|
||||||
public newQueueItemId(): number {
|
|
||||||
const item = this.queue.sort((a, b) => b.id - a.id)[0];
|
|
||||||
return item === undefined ? 1 : item.id + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public nextQueueItem(): Queue.Item | undefined {
|
|
||||||
return this.queue.sort((a, b) => a.added.getTime() - b.added.getTime())[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeQueueItem(id: number): Promise<void> {
|
|
||||||
const itemIndex = this.queue.findIndex((item) => item.id === id);
|
|
||||||
if (itemIndex === -1) {
|
|
||||||
console.error(`Tried to remove an item with unknown ID: ${id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedItems = this.queue.splice(itemIndex, 1);
|
|
||||||
await browser.storage.sync.remove(removedItems.map(({id}) => `qi${id}`));
|
|
||||||
|
|
||||||
const history = await History.fromLocalStorage();
|
|
||||||
await history.insertItems(removedItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
|
||||||
await browser.storage.sync.set({
|
|
||||||
...serializeQueue(this.queue),
|
|
||||||
version: this.version,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +1,8 @@
|
||||||
import {html} from 'htm/preact';
|
// Export something so TypeScript doesn't see this file as an ambient module.
|
||||||
|
export {};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// See Vite documentation for `import.meta.env` usage.
|
const $browser: "chromium" | "firefox";
|
||||||
// https://vitejs.dev/guide/env-and-mode.html
|
const $dev: boolean;
|
||||||
|
const $test: boolean;
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly BASE_URL: string;
|
|
||||||
readonly DEV: boolean;
|
|
||||||
readonly MODE: string;
|
|
||||||
readonly PROD: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HtmComponent = ReturnType<typeof html>;
|
|
||||||
|
|
||||||
namespace Queue {
|
|
||||||
type Item = {
|
|
||||||
added: Date;
|
|
||||||
id: number;
|
|
||||||
text: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
import {Settings} from '../settings/settings.js';
|
|
||||||
|
|
||||||
export async function updateBadge(settings: Settings): Promise<void> {
|
|
||||||
const queueLength = settings.queue.length.toString();
|
|
||||||
await browser.browserAction.setBadgeText({
|
|
||||||
text: queueLength === '0' ? null : queueLength,
|
|
||||||
});
|
|
||||||
|
|
||||||
await browser.browserAction.setBadgeBackgroundColor({color: '#2a2041'});
|
|
||||||
browser.browserAction.setBadgeTextColor({color: '#f2efff'});
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
export class History {
|
|
||||||
public static async clear(): Promise<void> {
|
|
||||||
await browser.storage.local.remove('history');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async fromLocalStorage(): Promise<History> {
|
|
||||||
const history = new History();
|
|
||||||
|
|
||||||
const stored = await browser.storage.local.get({history: []});
|
|
||||||
history.queue = stored.history as Queue.Item[];
|
|
||||||
|
|
||||||
// Initialize all the non-JSON values since they are stringified when saved.
|
|
||||||
for (const item of history.queue) {
|
|
||||||
item.added = new Date(item.added);
|
|
||||||
}
|
|
||||||
|
|
||||||
return history;
|
|
||||||
}
|
|
||||||
|
|
||||||
public queue: Queue.Item[];
|
|
||||||
|
|
||||||
private constructor() {
|
|
||||||
this.queue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async clear(): Promise<void> {
|
|
||||||
await History.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async insertItems(items: Queue.Item[]): Promise<void> {
|
|
||||||
this.queue = this.queue.concat(items);
|
|
||||||
|
|
||||||
for (const [index, item] of this.queue.entries()) {
|
|
||||||
item.id = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
|
||||||
await browser.storage.local.set({history: this.queue});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,48 +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',
|
|
||||||
};
|
|
||||||
|
|
||||||
test('dataMigrations happy path', async (t) => {
|
|
||||||
let data: Record<string, any> = {
|
|
||||||
latestVersion: '0.1.0',
|
|
||||||
queue: [queueItemSample],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const migration of dataMigrations) {
|
|
||||||
data = (await migration.migrate(data)) as Record<string, any>;
|
|
||||||
t.snapshot(data, `Migration ${migration.version}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dataMigrations unhappy path', async (t) => {
|
|
||||||
let data: Record<string, any> = {};
|
|
||||||
|
|
||||||
for (const migration of dataMigrations) {
|
|
||||||
data = (await migration.migrate(data)) as Record<string, any>;
|
|
||||||
t.snapshot(data, `Migration ${migration.version}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Serializing & Deserializing Queue', (t) => {
|
|
||||||
const serialized = serializeQueue([queueItemSample]);
|
|
||||||
t.snapshot(serialized, 'Serialized');
|
|
||||||
|
|
||||||
serialized.extra = 'Extra';
|
|
||||||
serialized.version = '0.0.0';
|
|
||||||
|
|
||||||
const deserialized = deserializeQueue(serialized);
|
|
||||||
t.snapshot(deserialized, 'Deserialized');
|
|
||||||
});
|
|
|
@ -1,51 +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',
|
|
||||||
}
|
|
||||||
|
|
||||||
## dataMigrations unhappy path
|
|
||||||
|
|
||||||
> Migration 0.1.7
|
|
||||||
|
|
||||||
{
|
|
||||||
version: '0.1.7',
|
|
||||||
}
|
|
||||||
|
|
||||||
## Serializing & Deserializing Queue
|
|
||||||
|
|
||||||
> Serialized
|
|
||||||
|
|
||||||
{
|
|
||||||
qi1: {
|
|
||||||
added: Date 2022-03-02 16:00:00 UTC {},
|
|
||||||
id: 1,
|
|
||||||
text: 'Sample',
|
|
||||||
url: 'https://example.org',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
> Deserialized
|
|
||||||
|
|
||||||
[
|
|
||||||
{
|
|
||||||
added: Date 2022-03-02 16:00:00 UTC {},
|
|
||||||
id: 1,
|
|
||||||
text: 'Sample',
|
|
||||||
url: 'https://example.org',
|
|
||||||
},
|
|
||||||
]
|
|
|
@ -1,24 +1,19 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
"lib": [
|
"lib": [
|
||||||
"ESNext"
|
"DOM",
|
||||||
|
"ES2022"
|
||||||
],
|
],
|
||||||
"module": "ESNext",
|
"module": "ES2022",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"noEmit": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "build",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ESNext"
|
"target": "ES2022"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"source/**/*.ts",
|
"source"
|
||||||
"tests/**/*.ts",
|
]
|
||||||
"vite.config.ts"
|
|
||||||
],
|
|
||||||
"ts-node": {
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "CommonJS"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import url from 'node:url';
|
|
||||||
|
|
||||||
import {defineConfig} from 'vite';
|
|
||||||
|
|
||||||
// Vite Plugins
|
|
||||||
import preactPreset from '@preact/preset-vite';
|
|
||||||
import webExtension from 'vite-plugin-web-extension';
|
|
||||||
|
|
||||||
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
const buildDir = path.join(currentDir, 'build');
|
|
||||||
const sourceDir = path.join(currentDir, 'source');
|
|
||||||
|
|
||||||
// Create the Firefox profile if it doesn't already exist.
|
|
||||||
fs.mkdirSync(path.join(currentDir, 'firefox'), {recursive: true});
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
build: {
|
|
||||||
outDir: buildDir,
|
|
||||||
sourcemap: 'inline',
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
preactPreset(),
|
|
||||||
// See vite-plugin-web-extension for documentation.
|
|
||||||
// https://github.com/aklinker1/vite-plugin-web-extension
|
|
||||||
webExtension({
|
|
||||||
assets: 'assets',
|
|
||||||
browser: 'firefox',
|
|
||||||
manifest: path.join(sourceDir, 'manifest.json'),
|
|
||||||
webExtConfig: {
|
|
||||||
browserConsole: true,
|
|
||||||
firefoxProfile: 'firefox/',
|
|
||||||
keepProfileChanges: true,
|
|
||||||
startUrl: 'about:debugging#/runtime/this-firefox',
|
|
||||||
target: 'firefox-desktop',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
root: sourceDir,
|
|
||||||
});
|
|