Compare commits

...

101 Commits
0.2.0 ... main

Author SHA1 Message Date
Bauke 17e0fe83dc
Change bg-2 to surface0 and add foreground accent colors. 2023-06-07 12:44:57 +02:00
Bauke e3ca7f9494
Add a getItem function. 2023-06-03 12:53:54 +02:00
Bauke e53bea8fe4
Add Catppuccin colors. 2023-06-02 12:16:29 +02:00
Bauke 5669510d9c
Fix migration version check. 2023-05-17 12:24:36 +02:00
Bauke 37fb4780f3
Remove obsolete configuration and dependencies. 2023-05-17 12:21:35 +02:00
Bauke 18b908eca4
Remove obsolete includes from tsconfig. 2023-05-17 12:19:59 +02:00
Bauke acb2bb409b
Run the migrations on extension installation. 2023-05-17 12:19:15 +02:00
Bauke dfc42535f0
Add the migrations back using new tests. 2023-05-17 12:18:52 +02:00
Bauke 0ad7345a71
Remove the love class. 2023-05-11 12:06:19 +02:00
Bauke bf5a20c0b8
Add an explicit height to the SVG. 2023-05-10 12:25:44 +02:00
Bauke 786bcce6b4
Switch Queue logo to Catppuccin colors and draw the logo manually. 2023-05-10 12:22:48 +02:00
Bauke c728f7302c
Update migration-helper. 2023-05-08 13:05:09 +02:00
Bauke 29838f4734
Enable resolving JSON modules. 2023-05-04 14:25:17 +02:00
Bauke 6a5d39e8ad
Remove migrations and testing for it. 2023-05-04 14:25:01 +02:00
Bauke 8eb5e7d972
Clear and restore existing stored items for tests. 2023-05-04 14:22:44 +02:00
Bauke 1c2397b8f6
Fix nextItem not grabbing from correct storage. 2023-05-04 13:12:42 +02:00
Bauke 20f399bda8
Rewrite the background scripts. 2023-04-27 12:47:19 +02:00
Bauke 974d8f22fd
Move and update options index.html paths. 2023-04-26 12:47:47 +02:00
Bauke e03f163c30
Fix build paths. 2023-04-26 12:43:05 +02:00
Bauke daa46b8755
Remove the old options page. 2023-04-26 12:42:52 +02:00
Bauke 0a2891d919
Add clear history and next item functions. 2023-04-25 12:33:07 +02:00
Bauke 265ca87a90
Add documentation. 2023-04-25 12:32:38 +02:00
Bauke e266e9dd7b
Rewrite type definitions. 2023-04-24 12:02:27 +02:00
Bauke fbd8de8b03
Remove History and Settings files. 2023-04-20 13:12:12 +02:00
Bauke 091da70bea
Rework the Item code so it can be reused for the history. 2023-04-17 16:31:21 +02:00
Bauke 451ebb8189
Move badge update code. 2023-04-16 12:41:56 +02:00
Bauke 861c340ec7
Fix more linting issues. 2023-04-15 12:02:21 +02:00
Bauke a722421130
Fix the build file path. 2023-04-14 11:49:14 +02:00
Bauke d03e46a481
Add the build and web-ext files. 2023-04-14 11:48:52 +02:00
Bauke 30c042f991
Convert remaining package.json scripts to cargo-make tasks. 2023-04-13 12:30:03 +02:00
Bauke 862a2fdb86
Remove Vite. 2023-04-12 11:56:13 +02:00
Bauke 04b6c23093
Update the manifest code. 2023-04-11 13:16:56 +02:00
Bauke 544d915233
Fix linting. 2023-04-10 11:43:36 +02:00
Bauke c853802b68
Add shareable linter configs. 2023-04-09 12:34:14 +02:00
Bauke 849e443f4e
Add the new Item handling code. 2023-04-08 13:04:54 +02:00
Bauke 18e0e06edb
Adjust tsconfig for ES2022 and JSX. 2023-04-07 12:37:37 +02:00
Bauke adef8fc894
Add type definition for third-party packages that do not have them. 2023-04-06 10:51:19 +02:00
Bauke 940367fc49
Add dependencies in preparation of move to esbuild. 2023-04-05 13:00:27 +02:00
Bauke e40ebdd4c3
Add compiletime globals to the type definition. 2023-04-05 12:19:41 +02:00
Bauke 9dcca9fe3e
Make NODE_ENV conditional and set it for all tasks. 2023-04-05 11:02:33 +02:00
Bauke a325269e7c
Add cargo-make to Nix shell packages. 2023-04-04 00:36:48 +02:00
Bauke 69c7c10368
Add the cargo-make tasks. 2023-04-03 11:55:47 +02:00
Bauke 1afaabc2a8
Disable no-await-in-loop entirely. 2023-04-02 12:09:01 +02:00
Bauke 123df05906
Add webextension-storage and test dependencies. 2023-04-01 12:04:53 +02:00
Bauke 5dfdd73cf5
Update public directory paths for Vite. 2023-03-26 14:33:17 +02:00
Bauke 75eddf0e17
Fix linting issues. 2023-03-26 14:33:08 +02:00
Bauke 2e1c7edfa1
Change package to ES module. 2023-03-26 14:32:57 +02:00
Bauke 6383e77609
Update dependencies. 2023-03-26 14:32:16 +02:00
Bauke 096922ff75
Minimize gitignore. 2023-03-23 15:15:01 +01:00
Bauke 9713f9231e
Add direnv and Nix flake files. 2023-03-22 13:21:50 +01:00
Bauke 2ee0dcb0ed
Version 0.3.2! 2022-10-25 14:56:36 +02:00
Bauke 1bf8f519c6
Fix new queue items not being sorted properly. 2022-10-25 14:54:22 +02:00
Bauke ce7fbfe349
Fix items not being movable when the next sort index wasn't plus or minus 1. 2022-10-25 14:50:50 +02:00
Bauke 9696b78728
Version 0.3.1! 2022-10-25 14:00:38 +02:00
Bauke 4fc38b74ce
Also use sortIndex for next queue item. 2022-10-25 14:00:15 +02:00
Bauke 913e8f0da9
Version 0.3.0! 2022-10-25 13:23:14 +02:00
Bauke ee012aea1d
Add move buttons. 2022-10-25 13:08:53 +02:00
Bauke f6571b895b
Fix JS pass by copy/reference issue. 2022-10-25 12:55:07 +02:00
Bauke e591db7bcf
Add move item functionality to Settings. 2022-10-25 12:28:37 +02:00
Bauke d4400c5c31
Update (de)serializing test. 2022-10-25 12:25:41 +02:00
Bauke c72959841f
Add a sortIndex to items. 2022-10-25 12:25:23 +02:00
Bauke ab6ef97d91
Re-add donate link. 2022-10-03 18:48:14 +02:00
Bauke f54b3d6712
Update dependency names. 2022-10-03 18:44:28 +02:00
Bauke 526642158c
Format code. 2022-09-28 19:21:33 +02:00
Bauke 13542fe219
Update dependencies, fix issues. 2022-09-27 12:49:00 +02:00
Bauke 2f08cc8e26
Rewrite readme. 2022-09-27 12:21:53 +02:00
Bauke 639d2b4462
Version 0.2.6. 2022-03-24 15:02:25 +01:00
Bauke 88a1066081
Replace separated manifests with createManifest function. 2022-03-24 14:56:42 +01:00
Bauke 46bacf20f4
Disable minification correctly. 🤦 2022-03-24 10:22:32 +01:00
Bauke 6f6c2937d8
Adjust installation section. 2022-03-23 13:24:22 +01:00
Bauke f582543a30
Add badges for Chrome and Microsoft stores. 2022-03-22 10:34:24 +01:00
Bauke 4eb9e911be
Rename screenshots folder to images. 2022-03-21 13:52:43 +01:00
Bauke c9627fed24
Version 0.2.5. 2022-03-21 10:44:18 +01:00
Bauke 4b9a890861
Move ifs to the bottom. 2022-03-21 10:43:24 +01:00
Bauke 1abab7f635
Fix context menus not being initialized in Firefox. 2022-03-21 10:42:39 +01:00
Bauke 587f049e08
Version 0.2.4. 2022-03-21 01:49:36 +01:00
Bauke f43dee71ec
Move contextMenus.onClicked listener to initialize. 2022-03-21 01:48:26 +01:00
Bauke 6fc61d476c
Version 0.2.3. 2022-03-20 22:18:37 +01:00
Bauke c89156fe88
(De)serialize local history items the same as settings. 2022-03-20 22:06:11 +01:00
Bauke 0aa26aa809
Move the version number into Vite config. 2022-03-20 21:52:53 +01:00
Bauke 163f838ffc
Adjust usage details for missing Chromium functionality. 2022-03-20 19:59:00 +01:00
Bauke bdb06a1b15
Add context menus to action button in Chromium. 2022-03-20 19:43:44 +01:00
Bauke dcf395f01d
Make the background script/service worker Chromium compatible. 2022-03-20 19:39:46 +01:00
Bauke ded38c7bf7
Make contextMenus usage Chromium compatible. 2022-03-20 19:24:45 +01:00
Bauke 8d99e8200b
Make updateBadge Chromium compatible. 2022-03-20 19:23:59 +01:00
Bauke bf8712dbf6
Fix dates not being serialized properly in Chromium. 2022-03-20 19:21:52 +01:00
Bauke 62f2188070
Add build support for Chromium with Manifest V3. 2022-03-20 19:02:37 +01:00
Bauke e18714c9f2
Add a link to the wiki. 2022-03-20 14:18:30 +01:00
Bauke e6d1834a42
Version 0.2.2. 2022-03-17 15:55:02 +01:00
Bauke bb9680c19b
Make the icon slightly bigger. 2022-03-17 15:17:04 +01:00
Bauke 3a5f4f7027
Make summary text bold. 2022-03-17 15:13:34 +01:00
Bauke 21e29bf655
Remove $schema from extension manifest. 2022-03-17 14:20:48 +01:00
Bauke c25e390f68
Replace Link with @holllo/gram's PrivacyLink. 2022-03-17 14:11:06 +01:00
Bauke 5c9d6668b3
Replace custom ConfirmButton. 2022-03-17 14:06:27 +01:00
Bauke 7312aa1039
Add the @holllo/gram package. 2022-03-17 14:03:35 +01:00
Bauke 11bb3c695c
Disable Vite's minification. 2022-03-17 14:03:08 +01:00
Bauke f4d0009f73
Update the badge when opening next link in new tab. 2022-03-17 13:43:01 +01:00
Bauke adab28447d
Update the badge on start up. 2022-03-16 11:20:29 +01:00
Bauke 1489da6101
Version 0.2.1. 2022-03-15 11:48:56 +01:00
Bauke e0fd79f81f
Add a favicon to the options page. 2022-03-15 00:10:24 +01:00
Bauke ffb19a6d96
Simplify opening the next queued link by using tabs.update().
Previously we would use messaging to open the next link with either
a loaded content script in the current tab or a new tab if the tab
can't load a content script. This makes it simpler and also has the
added benefit of no longer requiring the permission to access
all websites.
2022-03-14 23:59:39 +01:00
65 changed files with 5611 additions and 3860 deletions

3
.envrc Normal file
View File

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

116
.gitignore vendored
View File

@ -1,112 +1,8 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Browser profile directories
chromium/
firefox/
# Build output directories
.direnv/
.vscode/
build/
chromium/
coverage/
firefox/
node_modules/
web-ext-artifacts/

View File

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

67
Makefile.toml Normal file
View File

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

View File

@ -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
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
[Mozilla Addons]: https://addons.mozilla.org/firefox/addon/holllo-queue/
[the Releases page]: https://github.com/Holllo/queue/releases
[manually from a file]: https://support.mozilla.org/en-US/kb/find-and-install-add-ons-add-features-to-firefox#w_how-do-i-find-and-install-add-ons
[the Releases page]: https://git.bauke.xyz/Holllo/queue/releases
## Development
To build Queue you will need [git], [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
[NodeJS]: https://nodejs.org
[pnpm]: https://pnpm.io
```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
```
* Install the dependencies with `pnpm install`.
* Start a separate browser with `pnpm start`.
* Build the WebExtension for production with `pnpm build`.
* Test the code with `pnpm test`.
## License
Queue is open-sourced with the [AGPL-3.0-or-later] license.
[AGPL-3.0-or-later]: https://github.com/Holllo/queue/blob/main/LICENSE
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.

41
flake.lock Normal file
View File

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

13
flake.nix Normal file
View File

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

BIN
images/chrome-web-store.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
images/microsoft.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
images/mozilla-addons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -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,
"scripts": {
"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"
},
"type": "module",
"dependencies": {
"htm": "^3.1.0",
"migration-helper": "^0.1.2",
"@holllo/migration-helper": "^0.1.4",
"@holllo/preact-components": "^0.2.3",
"@holllo/test": "^0.2.1",
"@holllo/webextension-storage": "^0.2.0",
"htm": "^3.1.1",
"modern-normalize": "^1.1.0",
"preact": "^10.6.6",
"webextension-polyfill": "^0.8.0"
"preact": "^10.13.1",
"webextension-polyfill": "^0.10.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.1.7",
"@types/webextension-polyfill": "^0.8.2",
"ava": "^4.0.1",
"c8": "^7.11.0",
"postcss": "^8.4.7",
"sass": "^1.49.9",
"stylelint": "^14.5.3",
"stylelint-config-standard-scss": "^3.0.0",
"@bauke/eslint-config": "^0.1.2",
"@bauke/prettier-config": "^0.1.2",
"@bauke/stylelint-config": "^0.1.2",
"@types/node": "^18.15.11",
"@types/webextension-polyfill": "^0.10.0",
"concurrently": "^8.0.1",
"cssnano": "^6.0.0",
"esbuild": "^0.17.15",
"esbuild-copy-static-files": "^0.1.0",
"esbuild-sass-plugin": "^2.8.0",
"postcss": "^8.4.21",
"sass": "^1.60.0",
"stylelint": "^15.3.0",
"stylelint-config-standard-scss": "^7.0.1",
"trash-cli": "^5.0.0",
"ts-node": "^10.6.0",
"typescript": "^4.5.5",
"vite": "^2.8.4",
"vite-plugin-web-extension": "^1.1.2",
"web-ext": "^6.7.0",
"xo": "^0.48.0"
"tsx": "^3.12.6",
"typescript": "^5.0.2",
"web-ext": "^7.6.0",
"xo": "^0.53.1"
},
"ava": {
"extensions": [
"ts"
],
"files": [
"tests/**/*.test.ts"
],
"require": [
"ts-node/register"
],
"snapshotDir": "tests/snapshots"
},
"c8": {
"include": [
"source",
"tests"
],
"reportDir": "coverage",
"reporter": [
"text",
"html"
]
"prettier": "@bauke/prettier-config",
"stylelint": {
"extends": "@bauke/stylelint-config"
},
"xo": {
"overrides": [
{
"files": "tests/**/*.test.ts",
"rules": {
"@typescript-eslint/triple-slash-reference": "off",
"import/extensions": "off",
"no-await-in-loop": "off"
}
}
],
"extends": "@bauke/eslint-config",
"prettier": true,
"rules": {
"@typescript-eslint/consistent-type-definitions": "off",
"n/file-extension-in-import": "off",
"no-await-in-loop": "off"
},
"space": true
}
}

File diff suppressed because it is too large Load Diff

7
shell.nix Normal file
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 760 B

View File

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

Before

Width:  |  Height:  |  Size: 730 B

After

Width:  |  Height:  |  Size: 794 B

View File

@ -1,48 +0,0 @@
import browser from 'webextension-polyfill';
import {Settings} from '../settings/settings.js';
let timeoutId: number | undefined;
export async function browserActionClicked(): Promise<void> {
const settings = await Settings.fromSyncStorage();
// When the extension icon is initially clicked, create a timeout for 500ms
// that will open the next queue item when it expires.
// If the icon is clicked again in those 500ms, open the options page instead.
if (timeoutId === undefined) {
timeoutId = window.setTimeout(async () => {
timeoutId = undefined;
const nextItem = settings.nextQueueItem();
if (nextItem === undefined) {
await browser.runtime.openOptionsPage();
return;
}
const tabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const message: Queue.Message<Queue.Item> = {
action: 'queue open url',
data: nextItem,
};
try {
await browser.tabs.sendMessage(tabs[0].id!, message);
} catch {
await browser.tabs.create({active: true, url: nextItem.url});
}
await settings.removeQueueItem(nextItem.id);
}, 500);
return;
}
window.clearTimeout(timeoutId);
timeoutId = undefined;
await browser.runtime.openOptionsPage();
}

View File

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

View File

@ -1,31 +0,0 @@
import browser from 'webextension-polyfill';
import {Settings} from '../settings/settings.js';
import {updateBadge} from '../utilities/badge.js';
import {History} from '../utilities/history.js';
import {browserActionClicked} from './browser-action.js';
import {initializeContextMenus} from './context-menus.js';
browser.runtime.onStartup.addListener(async () => {
console.debug('Clearing history.');
await History.clear();
});
browser.browserAction.onClicked.addListener(browserActionClicked);
browser.runtime.onInstalled.addListener(async () => {
if (import.meta.env.DEV) {
await browser.runtime.openOptionsPage();
}
});
browser.runtime.onMessage.addListener(
async (request: Queue.Message<unknown>) => {
if (request.action === 'queue update badge') {
const settings = await Settings.fromSyncStorage();
await updateBadge(settings);
}
},
);
initializeContextMenus();

View File

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

View File

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

View File

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

111
source/build.ts Normal file
View File

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

View File

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

103
source/item/item.test.ts Normal file
View File

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

258
source/item/item.ts Normal file
View File

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

View File

@ -1,50 +0,0 @@
{
"$schema": "http://json.schemastore.org/webextension",
"manifest_version": 2,
"name": "Queue",
"description": "A WebExtension for queueing links.",
"version": "0.2.0",
"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"
]
},
"content_scripts": [
{
"matches": [
"*://*/*"
],
"run_at": "document_end",
"js": [
"content-scripts/initialize.ts"
]
}
],
"applications": {
"gecko": {
"id": "{c3560e6b-00e5-4ab3-b89e-8a54ee5b2c9f}"
}
}
}

61
source/manifest.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,150 +0,0 @@
import {Component, html} from 'htm/preact';
import browser from 'webextension-polyfill';
import {Settings} from '../../settings/settings.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 browser.runtime.sendMessage({action: 'queue update badge'});
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 (when
possible).
</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>
`;
}

View File

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

View File

@ -1,37 +0,0 @@
import {html} from 'htm/preact';
import {Component, render} from 'preact';
import {Settings} from '../settings/settings.js';
import {initializeMessaging, sendMessage} from '../utilities/messaging.js';
import {History} from '../utilities/history.js';
import {PageFooter, PageHeader, PageMain} from './components/components.js';
window.addEventListener('DOMContentLoaded', async () => {
initializeMessaging();
await sendMessage('queue update badge');
const history = await History.fromLocalStorage();
const settings = await Settings.fromSyncStorage();
render(
html`<${OptionsPage} history=${history} settings=${settings} />`,
document.body,
);
});
type Props = {
history: Queue.Item[];
settings: Settings;
};
class OptionsPage extends Component<Props> {
render() {
const {history, settings} = this.props;
return html`
<${PageHeader} />
<${PageMain} history=${history} settings=${settings} />
<${PageFooter} settings=${settings} />
`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
source/packages.d.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

37
source/types.d.ts vendored
View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
import browser from 'webextension-polyfill';
export function initializeMessaging() {
browser.runtime.onMessage.addListener((request: Queue.Message<unknown>) => {
if (request.action === 'queue open url') {
const message = request as Queue.Message<Queue.Item>;
window.location.href = message.data.url;
}
});
}
export async function sendMessage<T>(
action: Queue.MessageAction,
data?: T,
): Promise<void> {
await browser.runtime.sendMessage({action, data});
}

75
source/web-ext.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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