├── src ├── icons │ ├── icon16.png │ ├── icon19.png │ ├── icon32.png │ ├── icon38.png │ ├── icon48.png │ ├── icon96.png │ ├── icon128.png │ └── icon256.png ├── options.css ├── utils.js ├── refererGetter.js ├── uploadURLGenerator.js ├── background.js ├── options.js ├── uploadURLService.js ├── urlFixuper.js ├── contextMenuManager.js ├── urlOpener.js ├── settings.js ├── options.html ├── uploadToDanbooru.js └── manifest.json ├── screenshots ├── page-action.png └── context-menu.png ├── .gitignore ├── render_icon.sh ├── .editorconfig ├── .github └── workflows │ └── default.yml ├── danbooru.svg ├── chromeifyManifest.impl.js ├── package.json ├── test ├── test_refererGetter.js ├── test_uploadURLGenerator.js ├── test_utils.js ├── test_urlFixuper.js ├── test_contextMenuManager.js ├── test_settings.js ├── test_chromeifyManifest.js ├── test_uploadURLService.js ├── test_uploadToDanbooru.js └── test_urlOpener.js ├── LICENSE ├── eslint.config.mjs └── README.md /src/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon16.png -------------------------------------------------------------------------------- /src/icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon19.png -------------------------------------------------------------------------------- /src/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon32.png -------------------------------------------------------------------------------- /src/icons/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon38.png -------------------------------------------------------------------------------- /src/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon48.png -------------------------------------------------------------------------------- /src/icons/icon96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon96.png -------------------------------------------------------------------------------- /src/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon128.png -------------------------------------------------------------------------------- /src/icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/src/icons/icon256.png -------------------------------------------------------------------------------- /screenshots/page-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/screenshots/page-action.png -------------------------------------------------------------------------------- /screenshots/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danbooru/upload-to-danbooru/HEAD/screenshots/context-menu.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | web-ext-artifacts/ 3 | dist/ 4 | coverage/ 5 | browser-polyfill.js 6 | *.pem 7 | .idea/ 8 | .vscode/ 9 | *~ 10 | -------------------------------------------------------------------------------- /render_icon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | SIZES=( 32 48 96 128 256 ) 6 | 7 | for size in "${SIZES[@]}"; do 8 | outfile="src/icons/icon$size.png" 9 | 10 | convert -background none -size "${size}x${size}" danbooru.svg "$outfile" 11 | optipng -o7 -strip all "$outfile" 12 | done 13 | -------------------------------------------------------------------------------- /src/options.css: -------------------------------------------------------------------------------- 1 | :root { 2 | background-color: #eee; 3 | color: #333; 4 | font-family: system-ui; 5 | } 6 | 7 | body { 8 | color: inherit; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | background-color: #1e1e2c; 14 | color: #d1d1da; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.svg] 12 | indent_style = tab 13 | 14 | [*.html] 15 | indent_size = 2 16 | 17 | [*.yml] 18 | indent_size = 2 19 | 20 | [package.json] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: [ push ] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: Use Node.js 9 | uses: actions/setup-node@v3 10 | - name: Install deps 11 | run: npm ci 12 | - name: Run eslint 13 | run: npx eslint src test *.js 14 | - name: Run tests 15 | if: success() || failure() 16 | run: npm test 17 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const DanbooruURL = "https://danbooru.donmai.us/"; 2 | 3 | export function getPageActionMatchRegExp(globs) { 4 | return globs.map((glob) => "^" + glob.replace(/\./g, "\\.").replace(/\*/g, ".*")).join("|"); 5 | } 6 | 7 | export function getAPI(ctx) { 8 | if (ctx.browser) { 9 | return [ctx.browser, false, !ctx.browser.contextMenus]; 10 | } 11 | 12 | return [ctx.chrome, true, false]; 13 | } 14 | 15 | export function asBool(value, default_) { 16 | if (value) { 17 | return /^(yes|true|on|t|y)$/i.test(value); 18 | } 19 | 20 | return default_ || false; 21 | } 22 | -------------------------------------------------------------------------------- /danbooru.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | -------------------------------------------------------------------------------- /src/refererGetter.js: -------------------------------------------------------------------------------- 1 | export class NotImplementedError extends Error {} 2 | 3 | export class RefererGetter { 4 | // eslint-disable-next-line no-unused-vars 5 | onClickData(info) { 6 | throw new NotImplementedError(); 7 | } 8 | } 9 | 10 | export class RefererGetterImpl extends RefererGetter { 11 | constructor(refererRegex) { 12 | super(); 13 | 14 | this.refererRegex = refererRegex; 15 | } 16 | 17 | fromOnClickData(info) { 18 | if (info.srcUrl === info.pageUrl) { 19 | return info.frameUrl ? info.frameUrl : info.srcUrl; 20 | } 21 | 22 | if (info.linkUrl && this.refererRegex.test(info.linkUrl)) { 23 | return info.linkUrl; 24 | } 25 | 26 | return info.pageUrl; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /chromeifyManifest.impl.js: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "fs/promises"; 2 | 3 | export function chromeifyManifest(manifest) { 4 | manifest["minimum_chrome_version"] = "97"; 5 | manifest["action"] = manifest["page_action"]; 6 | manifest["commands"]["_execute_action"] = manifest["commands"]["_execute_page_action"]; 7 | manifest["background"]["service_worker"] = manifest["background"]["scripts"][0]; 8 | 9 | delete manifest["page_action"]; 10 | delete manifest["commands"]["_execute_page_action"]; 11 | delete manifest["background"]["scripts"]; 12 | delete manifest["browser_specific_settings"]; 13 | 14 | return manifest; 15 | } 16 | 17 | export async function chromeifyManifestFile(path) { 18 | const options = {"encoding": "utf-8"}; 19 | const manifest = chromeifyManifest(JSON.parse(await readFile(path, options))); 20 | 21 | await writeFile(path, JSON.stringify(manifest, null, 4), options); 22 | } 23 | -------------------------------------------------------------------------------- /src/uploadURLGenerator.js: -------------------------------------------------------------------------------- 1 | export class NotImplementedError extends Error {} 2 | 3 | export class UploadURLGenerator { 4 | // eslint-disable-next-line no-unused-vars 5 | generate(url, ref) { 6 | throw new NotImplementedError(); 7 | } 8 | } 9 | 10 | export class UploadURLGeneratorImpl extends UploadURLGenerator { 11 | constructor(prefix) { 12 | super(); 13 | 14 | this.prefix = prefix; 15 | } 16 | 17 | generate(url, ref) { 18 | const uploadUrl = new URL("uploads/new", this.prefix); 19 | 20 | if (url.startsWith(this.prefix) || ref?.startsWith(this.prefix)) { 21 | return uploadUrl; 22 | } 23 | 24 | if (!/^https?:\/\//.test(url)) { 25 | return uploadUrl; 26 | } 27 | 28 | uploadUrl.searchParams.set("url", url); 29 | 30 | if (ref) { 31 | uploadUrl.searchParams.set("ref", ref); 32 | } 33 | 34 | return uploadUrl; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upload-to-danbooru", 3 | "version": "3.4.0", 4 | "description": "Add a context menu option for images to upload to Danbooru.", 5 | "type": "module", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "mocha", 11 | "coverage": "c8 mocha", 12 | "build": "node build.js", 13 | "buildChrome": "node build.js --chrome" 14 | }, 15 | "exports": { 16 | "./*": "./src/*" 17 | }, 18 | "devDependencies": { 19 | "c8": "^10.1.3", 20 | "eslint": "^9.32.0", 21 | "mocha": "^11.7.1", 22 | "should": "^13.2.3", 23 | "web-ext": "^8.9.0" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/ZipFile/upload-to-danbooru.git" 28 | }, 29 | "keywords": [ 30 | "danbooru", 31 | "webextension" 32 | ], 33 | "author": "ZipFile", 34 | "license": "BSD-2-Clause", 35 | "bugs": { 36 | "url": "https://github.com/ZipFile/upload-to-danbooru/issues" 37 | }, 38 | "webExt": { 39 | "sourceDir": "dist" 40 | }, 41 | "c8": { 42 | "all": true, 43 | "exclude": [ 44 | "dist", 45 | "src/contentScripts" 46 | ] 47 | }, 48 | "homepage": "https://github.com/ZipFile/upload-to-danbooru#readme" 49 | } 50 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserContextMenuManager, 3 | ContextMenuSetupperImpl, 4 | } from "./contextMenuManager.js"; 5 | import { BrowserStorageSettings } from "./settings.js"; 6 | import { UploadToDanbooru } from "./uploadToDanbooru.js"; 7 | import { getAPI } from "./utils.js"; 8 | import { AndroidURLOpener, BrowserURLOpener, ChromeURLOpener } from "./urlOpener.js"; 9 | import { RefererGetterImpl } from "./refererGetter.js"; 10 | import { UploadURLGeneratorImpl } from "./uploadURLGenerator.js"; 11 | import { UploadURLServiceImpl } from "./uploadURLService.js"; 12 | 13 | const [api, isChrome, isAndroid] = getAPI(globalThis); 14 | const settings = new BrowserStorageSettings(api); 15 | const cm = new BrowserContextMenuManager(api); 16 | const cms = new ContextMenuSetupperImpl(cm, settings); 17 | const urlOpenerClass = isChrome ? ChromeURLOpener : (isAndroid ? AndroidURLOpener : BrowserURLOpener); 18 | 19 | function uploadURLServiceFactory(danbooruUrl, pageActionRegex) { 20 | return new UploadURLServiceImpl( 21 | new UploadURLGeneratorImpl(danbooruUrl), 22 | new RefererGetterImpl(pageActionRegex), 23 | ); 24 | } 25 | 26 | const uploadToDanbooru = new UploadToDanbooru( 27 | api, 28 | isChrome, 29 | settings, 30 | cms, 31 | uploadURLServiceFactory, 32 | urlOpenerClass, 33 | ); 34 | 35 | uploadToDanbooru.init(); 36 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserContextMenuManager, 3 | ContextMenuSetupperImpl, 4 | } from "./contextMenuManager.js"; 5 | import {BrowserStorageSettings, FormManager} from "./settings.js"; 6 | import { DanbooruURL, getAPI } from "./utils.js"; 7 | 8 | const [api, isChrome] = getAPI(globalThis); 9 | const form = document.forms.settings; 10 | const settings = new BrowserStorageSettings(api); 11 | const cm = new BrowserContextMenuManager(api); 12 | const cms = new ContextMenuSetupperImpl(cm, settings); 13 | const defaults = { 14 | url: "", 15 | pageActionOpenIn: "current", 16 | contextMenuOpenIn: "background", 17 | contextMenuEnabled: "yes", 18 | }; 19 | const formManager = new FormManager(form, settings, defaults); 20 | 21 | async function saveOptions(e) { 22 | e.preventDefault(); 23 | 24 | await formManager.save("url", "pageActionOpenIn", "contextMenuOpenIn", "contextMenuEnabled"); 25 | 26 | cms.setup(); 27 | 28 | if (isChrome) { 29 | window.close(); 30 | } else { 31 | alert("Saved."); 32 | } 33 | } 34 | 35 | async function restoreOptions() { 36 | await formManager.load("url", "pageActionOpenIn", "contextMenuOpenIn", "contextMenuEnabled"); 37 | } 38 | 39 | document.addEventListener("DOMContentLoaded", restoreOptions); 40 | form.addEventListener("submit", saveOptions); 41 | form.url.placeholder = DanbooruURL; 42 | -------------------------------------------------------------------------------- /src/uploadURLService.js: -------------------------------------------------------------------------------- 1 | import { defaultUrlFixuper } from "./urlFixuper.js"; 2 | 3 | export class NotImplementedError extends Error {} 4 | 5 | export class UploadURLService { 6 | // eslint-disable-next-line no-unused-vars 7 | fromOnClickData(info) { 8 | throw new NotImplementedError(); 9 | } 10 | 11 | // eslint-disable-next-line no-unused-vars 12 | fromTab(tab) { 13 | throw new NotImplementedError(); 14 | } 15 | } 16 | 17 | export class UploadURLServiceImpl extends UploadURLService { 18 | constructor(uploadUrlGenerator, refererGetter, urlFixuper) { 19 | super(); 20 | 21 | this.uploadUrlGenerator = uploadUrlGenerator; 22 | this.refererGetter = refererGetter; 23 | this.urlFixuper = urlFixuper || defaultUrlFixuper; 24 | } 25 | 26 | fromOnClickData(info) { 27 | if (info.srcUrl) { 28 | return this.uploadUrlGenerator.generate( 29 | this.urlFixuper.fix(info.srcUrl), 30 | this.refererGetter.fromOnClickData(info), 31 | ); 32 | } 33 | 34 | if (info.linkUrl) { 35 | return this.uploadUrlGenerator.generate(info.linkUrl); 36 | } 37 | 38 | throw Error("info object must contain either srcUrl or linkUrl"); 39 | } 40 | 41 | fromTab(tab) { 42 | return this.uploadUrlGenerator.generate(tab.url); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/test_refererGetter.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { RefererGetterImpl } from "upload-to-danbooru/refererGetter.js"; 4 | 5 | describe("RefererGetterImpl", function() { 6 | const refererGetter = new RefererGetterImpl(); 7 | const prefix = "http://example.com/"; 8 | const pageUrl = "http://example.net/post/123"; 9 | const frameUrl = "http://example.org/"; 10 | const srcUrl = "http://cdn.example.net/xxx.jpg"; 11 | 12 | it("fromOnClickData() page", function() { 13 | const ref = refererGetter.fromOnClickData({srcUrl, pageUrl}); 14 | 15 | should(ref).equal(pageUrl); 16 | }); 17 | 18 | it("fromOnClickData() frame", function() { 19 | const ref = refererGetter.fromOnClickData({srcUrl, pageUrl: srcUrl, frameUrl}); 20 | 21 | should(ref).equal(frameUrl); 22 | }); 23 | 24 | it("fromOnClickData() no frame", function() { 25 | const ref = refererGetter.fromOnClickData({srcUrl, pageUrl: srcUrl}); 26 | 27 | should(ref).equal(srcUrl); 28 | }); 29 | 30 | it("fromOnClickData() link", function() { 31 | const refererGetter = new RefererGetterImpl(/^http:\/\/example.net\/post\//); 32 | 33 | const ref = refererGetter.fromOnClickData( 34 | {srcUrl, pageUrl: prefix, linkUrl: pageUrl}, 35 | ); 36 | 37 | should(ref).equal(pageUrl); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2020~2022, ZipFile 4 | Copyright (c) 2022, Danbooru Project 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all, 13 | }); 14 | 15 | export default [ 16 | { 17 | ignores: ["**/browser-polyfill.js", "**/dist/"], 18 | }, 19 | ...compat.extends("eslint:recommended"), 20 | { 21 | languageOptions: { 22 | globals: { 23 | ...globals.browser, 24 | ...globals.mocha, 25 | ...globals.webextensions, 26 | }, 27 | 28 | ecmaVersion: 11, 29 | sourceType: "module", 30 | }, 31 | 32 | rules: { 33 | indent: ["error", 4], 34 | "linebreak-style": ["error", "unix"], 35 | quotes: ["error", "double"], 36 | semi: ["error", "always"], 37 | }, 38 | }, 39 | { 40 | files: ["chromeifyManifest.js", "chromeifyManifest.impl.js"], 41 | languageOptions: { 42 | globals: { 43 | ...globals.mocha, 44 | ...globals.node, 45 | }, 46 | ecmaVersion: 11, 47 | sourceType: "module", 48 | }, 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /src/urlFixuper.js: -------------------------------------------------------------------------------- 1 | export class NotImplementedError extends Error {} 2 | 3 | export class URLFixuper { 4 | // eslint-disable-next-line no-unused-vars 5 | fix(url) { 6 | throw new NotImplementedError(); 7 | } 8 | } 9 | 10 | export function regexFixup(matchURLRegex, ...args) { 11 | return { 12 | match(url) { 13 | return matchURLRegex.test(url); 14 | }, 15 | fix(url) { 16 | for (let [match, replaceWith] of args) { 17 | url = url.replace(match, replaceWith); 18 | } 19 | return url; 20 | }, 21 | }; 22 | } 23 | 24 | export const defaultUrlFixups = [ 25 | regexFixup(/\.hdslb\.com\//, [/@.*/, ""]), 26 | regexFixup(/:\/\/media.discordapp.net\//, [/\?.*/, ""]), 27 | regexFixup(/\.pinimg\.com\//, [/\/\d+x\//, "/originals/"]), 28 | regexFixup(/(pixiv|booth)\.pximg\.net\//, [/\/c\/\d+x\d+.*?\//, "/"], [/_base_resized/, ""]), 29 | regexFixup(/:\/\/c\.fantia.jp\//, [/(\d+)\/.*?_/, "$1/"]), 30 | regexFixup(/.*\.imgix.net\//, [/\?(?!.*s=).*/, ""]), 31 | ]; 32 | 33 | export class URLFixuperImpl extends URLFixuper { 34 | constructor(urlFixups) { 35 | super(); 36 | 37 | this.urlFixups = urlFixups || defaultUrlFixups; 38 | } 39 | 40 | fix(url) { 41 | for (let fixup of this.urlFixups) { 42 | if (fixup.match(url)) { 43 | url = fixup.fix(url); 44 | } 45 | } 46 | 47 | return url; 48 | } 49 | } 50 | 51 | export const defaultUrlFixuper = new URLFixuperImpl(); 52 | -------------------------------------------------------------------------------- /test/test_uploadURLGenerator.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { UploadURLGeneratorImpl } from "upload-to-danbooru/uploadURLGenerator.js"; 4 | 5 | describe("UploadURLGeneratorImpl", function() { 6 | const prefix = "http://example.com/"; 7 | const pageUrl = "http://example.net/post/123"; 8 | const srcUrl = "http://cdn.example.net/xxx.jpg"; 9 | const uploadURLGenerator = new UploadURLGeneratorImpl(prefix); 10 | 11 | describe("generate() same prefix", function() { 12 | it("src", function() { 13 | const url = uploadURLGenerator.generate("http://example.com/data/360x360/aa/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg"); 14 | 15 | should(url.href).equal("http://example.com/uploads/new"); 16 | }); 17 | 18 | it("ref", function() { 19 | const url = uploadURLGenerator.generate( 20 | "http://cdn.example.com/data/360x360/aa/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg", 21 | "http://example.com/posts/123456789", 22 | ); 23 | 24 | should(url.href).equal("http://example.com/uploads/new"); 25 | }); 26 | }); 27 | 28 | it("generate() non http", function() { 29 | const url = uploadURLGenerator.generate("chrome://newtab"); 30 | 31 | should(url.href).equal("http://example.com/uploads/new"); 32 | }); 33 | 34 | it("generate() no ref", function() { 35 | const url = uploadURLGenerator.generate(srcUrl); 36 | 37 | should(url.href).equal("http://example.com/uploads/new?url=http%3A%2F%2Fcdn.example.net%2Fxxx.jpg"); 38 | }); 39 | 40 | it("generate() with ref", function() { 41 | const url = uploadURLGenerator.generate(srcUrl, pageUrl); 42 | 43 | should(url.href).equal("http://example.com/uploads/new?url=http%3A%2F%2Fcdn.example.net%2Fxxx.jpg&ref=http%3A%2F%2Fexample.net%2Fpost%2F123"); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Upload to Danbooru Web Extension 2 | 3 | Add a page action and a context menu option (for images) to upload to  Danbooru. Replacement for [bookmarklets](https://danbooru.donmai.us/static/bookmarklet). 4 | 5 | ## Install 6 | 7 | * [Firefox](https://addons.mozilla.org/en-US/firefox/addon/upload-to-danbooru/) 8 | * [Chrome](https://chrome.google.com/webstore/detail/upload-to-danbooru/faoifiojjmdkjpgkjpikkjdfocdjjpla) 9 | 10 | ## Usage 11 | 12 | ### Page Action 13 | 14 |  15 | 16 | Just visit any Danbooru-supported page, then click  icon in the address bar. 17 | 18 | ### Context Menu 19 | 20 |  21 | 22 | Right click on any image, select "Upload to Danbooru". 23 | 24 | ## Build 25 | 26 | ```sh 27 | npm i 28 | npm run build # Firefox 29 | npm run buildChrome # Chrome 30 | ``` 31 | 32 | Extension package will be located in `web-ext-artifacts/` folder. 33 | 34 | ## Run Tests 35 | 36 | ```sh 37 | npm test 38 | ``` 39 | 40 | ## Install In Developer Mode 41 | 42 | ### Firefox 43 | 44 | * Open `about:debugging#/runtime/this-firefox` 45 | * Click `Load Temporary Add-on` 46 | * Select `src/manifest.json` file 47 | 48 | ### Chrome 49 | 50 | Make sure you have built extension. 51 | 52 | * Open Chrome Settings 53 | * Select Extensions 54 | * Enable developer mode 55 | * Click Load Unpacked 56 | * Select `dist/` folder 57 | 58 | ### web-ext 59 | 60 | Firefox (sandbox): 61 | 62 | ```sh 63 | npx web-ext run 64 | ``` 65 | 66 | [Android](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/#testing-in-firefox-for-android): 67 | 68 | ```sh 69 | npx web-ext run --firefox-apk org.mozilla.fenix --target=firefox-android --android-device=DEVICE_ID 70 | ``` 71 | 72 | Replace `DEVICE_ID` with id of the device from `adb devices`. 73 | -------------------------------------------------------------------------------- /src/contextMenuManager.js: -------------------------------------------------------------------------------- 1 | import { asBool } from "./utils.js"; 2 | 3 | export class NotImplementedError extends Error {} 4 | 5 | export class ContextMenuManager { 6 | add() { 7 | throw new NotImplementedError(); 8 | } 9 | 10 | remove() { 11 | throw new NotImplementedError(); 12 | } 13 | } 14 | 15 | export class BrowserContextMenuManager extends ContextMenuManager { 16 | constructor(browser, menuID) { 17 | super(); 18 | this.browser = browser; 19 | this.menuID = menuID || "upload-to-danbooru"; 20 | } 21 | 22 | add() { 23 | if (!this.browser.contextMenus) { 24 | return; 25 | } 26 | 27 | this.browser.contextMenus.create({ 28 | id: this.menuID, 29 | title: "Upload to &Danbooru", 30 | contexts: ["image"], 31 | targetUrlPatterns: ["https://*/*", "http://*/*"], 32 | }); 33 | } 34 | 35 | remove() { 36 | if (!this.browser.contextMenus) { 37 | return; 38 | } 39 | 40 | return this.browser.contextMenus.removeAll(); 41 | } 42 | } 43 | 44 | export class ContextMenuSetupper { 45 | async setup() { 46 | throw new NotImplementedError(); 47 | } 48 | } 49 | 50 | export class ContextMenuSetupperImpl extends ContextMenuSetupper { 51 | constructor(contextMenuManager, settings, settingsKey) { 52 | super(); 53 | this.contextMenuManager = contextMenuManager; 54 | this.settings = settings; 55 | this._settingsKey = settingsKey || "contextMenuEnabled"; 56 | } 57 | 58 | async setup() { 59 | this.contextMenuManager.remove(); 60 | 61 | const settings = await this.settings.get(this._settingsKey); 62 | const contextMenuEnabled = asBool(settings[this._settingsKey], true); 63 | 64 | if (contextMenuEnabled) { 65 | this.contextMenuManager.add(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/urlOpener.js: -------------------------------------------------------------------------------- 1 | export class NotImplementedError extends Error {} 2 | 3 | export class URLOpener { 4 | // eslint-disable-next-line no-unused-vars 5 | async current(url) { 6 | throw new NotImplementedError(); 7 | } 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | async new(url, background) { 11 | throw new NotImplementedError(); 12 | } 13 | 14 | open(url, openIn) { 15 | switch (openIn) { 16 | case "new": return this.new(url, false); 17 | case "background": return this.new(url, true); 18 | default: return this.current(url); 19 | } 20 | } 21 | } 22 | 23 | export class GenericURLOpener extends URLOpener { 24 | constructor(browser, tab) { 25 | super(); 26 | 27 | this.browser = browser; 28 | this.tab = tab; 29 | } 30 | 31 | current(url) { 32 | return this.browser.tabs.update(this.tab.id, {url}); 33 | } 34 | 35 | get newTabParams() { 36 | throw new NotImplementedError(); 37 | } 38 | 39 | new(url, background) { 40 | return this.browser.tabs.create({ 41 | active: !background, 42 | url: url, 43 | ...this.newTabParams, 44 | }); 45 | } 46 | } 47 | 48 | export class BrowserURLOpener extends GenericURLOpener { 49 | get newTabParams() { 50 | return {openerTabId: this.tab.id}; 51 | } 52 | } 53 | 54 | export class AndroidURLOpener extends GenericURLOpener { 55 | get newTabParams() { 56 | return {}; 57 | } 58 | } 59 | 60 | export class ChromeURLOpener extends GenericURLOpener { 61 | get newTabParams() { 62 | return {index: this.tab.index + 1, openerTabId: this.tab.id}; 63 | } 64 | } 65 | 66 | export class NoopURLOpener extends URLOpener { 67 | // eslint-disable-next-line no-unused-vars 68 | async current(url) {} 69 | // eslint-disable-next-line no-unused-vars 70 | async new(url, background) {} 71 | } 72 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | export class NotImplementedError extends Error {} 2 | 3 | export class Settings { 4 | // eslint-disable-next-line no-unused-vars 5 | async get(...keys) { 6 | throw new NotImplementedError(); 7 | } 8 | 9 | // eslint-disable-next-line no-unused-vars 10 | async set(mapping) { 11 | throw new NotImplementedError(); 12 | } 13 | } 14 | 15 | export class BrowserStorageSettings extends Settings { 16 | constructor(browser) { 17 | super(); 18 | this.browser = browser; 19 | } 20 | 21 | get(...keys) { 22 | return this.browser.storage.sync.get(keys); 23 | } 24 | 25 | set(mapping) { 26 | return this.browser.storage.sync.set(mapping); 27 | } 28 | } 29 | 30 | export class InMemorySettings extends Settings { 31 | constructor() { 32 | super(); 33 | this.storage = {}; 34 | } 35 | 36 | async get(...keys) { 37 | const out = {}; 38 | for (const key of keys) { 39 | out[key] = this.storage[key]; 40 | } 41 | return out; 42 | } 43 | 44 | async set(mapping) { 45 | for (const [key, value] of Object.entries(mapping)) { 46 | this.storage[key] = value; 47 | } 48 | } 49 | } 50 | 51 | export class FormManager { 52 | constructor(form, settings, defaults) { 53 | this.form = form; 54 | this.settings = settings; 55 | this.defaults = defaults || {}; 56 | } 57 | 58 | async save(...keys) { 59 | const settings = {}; 60 | 61 | for (const key of keys) { 62 | settings[key] = this.form[key].value; 63 | } 64 | 65 | await this.settings.set(settings); 66 | } 67 | 68 | async load(...keys) { 69 | const settings = await this.settings.get(...keys); 70 | 71 | for (const key of keys) { 72 | this.form[key].value = settings[key] || this.defaults[key] || ""; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /test/test_utils.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { 4 | asBool, 5 | getPageActionMatchRegExp, 6 | getAPI, 7 | } from "upload-to-danbooru/utils.js"; 8 | 9 | describe("getPageActionMatchRegExp()", function() { 10 | it("", function() { 11 | const globs = [ 12 | "https://x.com/*/status/*", 13 | "https://www.pixiv.net/artworks/*", 14 | "https://*.tumblr.com/post/*", 15 | ]; 16 | const result = getPageActionMatchRegExp(globs); 17 | 18 | should(result).equal("^https://x\\.com/.*/status/.*|^https://www\\.pixiv\\.net/artworks/.*|^https://.*\\.tumblr\\.com/post/.*"); 19 | }); 20 | }); 21 | 22 | describe("getAPI()", function() { 23 | const chrome = new Object(); 24 | 25 | it("chrome", function() { 26 | const [api, isChrome, isAndroid] = getAPI({chrome}); 27 | 28 | should(api).equal(chrome); 29 | should(isChrome).equal(true); 30 | should(isAndroid).equal(false); 31 | }); 32 | 33 | it("browser", function() { 34 | const browser = {contextMenus: {}}; 35 | const [api, isChrome, isAndroid] = getAPI({chrome, browser}); 36 | 37 | should(api).equal(browser); 38 | should(isChrome).equal(false); 39 | should(isAndroid).equal(false); 40 | }); 41 | 42 | it("android", function() { 43 | const browser = {}; 44 | const [api, isChrome, isAndroid] = getAPI({chrome, browser}); 45 | 46 | should(api).equal(browser); 47 | should(isChrome).equal(false); 48 | should(isAndroid).equal(true); 49 | }); 50 | }); 51 | 52 | describe("asBool()", function() { 53 | const fallback = new Object(); 54 | const values = [ 55 | [undefined, fallback], 56 | ["", fallback], 57 | ["yes", true], 58 | ["True", true], 59 | ["ON", true], 60 | ["t", true], 61 | ["y", true], 62 | ["no", false], 63 | ["off", false], 64 | ]; 65 | 66 | for (let [value, expected] of values) { 67 | it(`${value} -> ${expected}`, function() { 68 | should(asBool(value, fallback)).equal(expected); 69 | }); 70 | } 71 | 72 | it("fallback false", function() { 73 | should(asBool(undefined, false)).equal(false); 74 | }); 75 | 76 | it("fallback true", function() { 77 | should(asBool(undefined, true)).equal(true); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/test_urlFixuper.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { URLFixuperImpl } from "upload-to-danbooru/urlFixuper.js"; 4 | 5 | describe("URLFixuperImpl()", function() { 6 | const testCases = [ 7 | { 8 | name: "bilibili", 9 | url: "https://i0.hdslb.com/bfs/album/7cebff5e5f45b17a7aba554bef68b6e84a5f483a.jpg@240w_320h_1e_1c.webp", 10 | expected: "https://i0.hdslb.com/bfs/album/7cebff5e5f45b17a7aba554bef68b6e84a5f483a.jpg", 11 | }, 12 | { 13 | name: "discord", 14 | url: "https://media.discordapp.net/attachments/310432830138089472/722011243862556772/omegalbert_2.png?width=400&height=274", 15 | expected: "https://media.discordapp.net/attachments/310432830138089472/722011243862556772/omegalbert_2.png", 16 | }, 17 | { 18 | name: "pinterest", 19 | url: "https://i.pinimg.com/736x/73/e8/2d/73e82d272de705bb8fad33e89c0543e5.jpg", 20 | expected: "https://i.pinimg.com/originals/73/e8/2d/73e82d272de705bb8fad33e89c0543e5.jpg", 21 | }, 22 | { 23 | name: "fanbox", 24 | url: "https://pixiv.pximg.net/c/1620x580_90_a2_g5/fanbox/public/images/creator/228078/cover/tHi9VtLFvJW4RS1h1DVpttRQ.jpeg", 25 | expected: "https://pixiv.pximg.net/fanbox/public/images/creator/228078/cover/tHi9VtLFvJW4RS1h1DVpttRQ.jpeg", 26 | }, 27 | { 28 | name: "booth", 29 | url: "https://booth.pximg.net/c/300x300_a2_g5/14df0a03-2f5f-4292-bb5a-94a3881df4f0/i/2926394/24c0b971-8807-4d40-8089-bdbf34089056_base_resized.jpg", 30 | expected: "https://booth.pximg.net/14df0a03-2f5f-4292-bb5a-94a3881df4f0/i/2926394/24c0b971-8807-4d40-8089-bdbf34089056.jpg", 31 | }, 32 | { 33 | name: "fantia", 34 | url: "https://c.fantia.jp/uploads/post/file/709449/main_324b4503-c64b-428b-875c-eaa273861268.png", 35 | expected: "https://c.fantia.jp/uploads/post/file/709449/324b4503-c64b-428b-875c-eaa273861268.png", 36 | }, 37 | { 38 | name: "imgix without signature", 39 | url: "https://anifty.imgix.net/creation/0x961d09077b4a9f7a27f6b7ee78cb4c26f0e72c18/20d5ce5b5163a71258e1d0ee152a0347bf40c7da.png?w=1200", 40 | expected: "https://anifty.imgix.net/creation/0x961d09077b4a9f7a27f6b7ee78cb4c26f0e72c18/20d5ce5b5163a71258e1d0ee152a0347bf40c7da.png", 41 | }, 42 | { 43 | name: "imgix with signature", 44 | url: "https://skeb.imgix.net/uploads/origins/b1dd4098-687b-4a91-a345-bb34248e6d8e?bg=%23fff&auto=format&w=800&s=55a47927c4ab3f399ca4bfacd092f617", 45 | expected: "https://skeb.imgix.net/uploads/origins/b1dd4098-687b-4a91-a345-bb34248e6d8e?bg=%23fff&auto=format&w=800&s=55a47927c4ab3f399ca4bfacd092f617", 46 | }, 47 | ]; 48 | 49 | for (let t of testCases) { 50 | it("fix() " + t.name, function() { 51 | const uf = new URLFixuperImpl(); 52 | 53 | should(uf.fix(t.url)).equal(t.expected); 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /test/test_contextMenuManager.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { 4 | BrowserContextMenuManager, 5 | ContextMenuManager, 6 | ContextMenuSetupperImpl, 7 | } from "upload-to-danbooru/contextMenuManager.js"; 8 | import { InMemorySettings } from "upload-to-danbooru/settings.js"; 9 | 10 | describe("BrowserContextMenuManager", function() { 11 | it("add()", function() { 12 | const menus = []; 13 | const browser = { 14 | contextMenus: { 15 | create(params) { 16 | menus.push(params); 17 | } 18 | }, 19 | }; 20 | const cm = new BrowserContextMenuManager(browser); 21 | 22 | cm.add(); 23 | 24 | should(menus.length).equal(1); 25 | should(menus[0]).deepEqual({ 26 | id: "upload-to-danbooru", 27 | title: "Upload to &Danbooru", 28 | contexts: ["image"], 29 | targetUrlPatterns: ["https://*/*", "http://*/*"], 30 | }); 31 | }); 32 | 33 | it("add() not implemented", function() { 34 | const cm = new BrowserContextMenuManager({}); 35 | 36 | cm.add(); 37 | }); 38 | 39 | it("remove()", function() { 40 | let removedCount = 0; 41 | const browser = { 42 | contextMenus: { 43 | removeAll() { 44 | removedCount++; 45 | } 46 | }, 47 | }; 48 | const cm = new BrowserContextMenuManager(browser); 49 | 50 | cm.remove(); 51 | 52 | should(removedCount).equal(1); 53 | }); 54 | 55 | it("remove() not implemented", function() { 56 | const cm = new BrowserContextMenuManager({}); 57 | 58 | cm.remove(); 59 | }); 60 | }); 61 | 62 | describe("ContextMenuSetupperImpl", function() { 63 | class TestContextMenuManager extends ContextMenuManager { 64 | constructor() { 65 | super(); 66 | this.addCallCount = 0; 67 | this.removeCallCount = 0; 68 | } 69 | 70 | add() { 71 | this.addCallCount++; 72 | } 73 | 74 | remove() { 75 | this.removeCallCount++; 76 | } 77 | 78 | get menuID() { 79 | return "testMenuId"; 80 | } 81 | } 82 | 83 | it("setup() default enabled", async function() { 84 | const cm = new TestContextMenuManager(); 85 | const settings = new InMemorySettings(); 86 | const cms = new ContextMenuSetupperImpl(cm, settings); 87 | 88 | await cms.setup(); 89 | 90 | should(cm.removeCallCount).equal(1); 91 | should(cm.addCallCount).equal(1); 92 | }); 93 | 94 | it("setup() enabled", async function() { 95 | const cm = new TestContextMenuManager(); 96 | const settings = new InMemorySettings(); 97 | const cms = new ContextMenuSetupperImpl(cm, settings, "testKey"); 98 | 99 | await settings.set({"testKey": "on"}); 100 | 101 | await cms.setup(); 102 | 103 | should(cm.removeCallCount).equal(1); 104 | should(cm.addCallCount).equal(1); 105 | }); 106 | 107 | it("setup() disabled", async function() { 108 | const cm = new TestContextMenuManager(); 109 | const settings = new InMemorySettings(); 110 | const cms = new ContextMenuSetupperImpl(cm, settings, "testKey"); 111 | 112 | await settings.set({"testKey": "off"}); 113 | 114 | await cms.setup(); 115 | 116 | should(cm.removeCallCount).equal(1); 117 | should(cm.addCallCount).equal(0); 118 | }); 119 | }); 120 | 121 | -------------------------------------------------------------------------------- /test/test_settings.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { 4 | NotImplementedError, 5 | Settings, 6 | BrowserStorageSettings, 7 | InMemorySettings, 8 | FormManager, 9 | } from "upload-to-danbooru/settings.js"; 10 | 11 | 12 | describe("Settings", function() { 13 | const settings = new Settings(); 14 | 15 | it("get()", function() { 16 | return should(settings.get("test")).rejectedWith(NotImplementedError); 17 | }); 18 | 19 | it("set()", function() { 20 | return should(settings.set({test: "value"})).rejectedWith(NotImplementedError); 21 | }); 22 | }); 23 | 24 | describe("BrowserStorageSettings", function() { 25 | it("get()", async function() { 26 | const storage = {a: "x", b: "y", c: "z"}; 27 | const browser = { 28 | storage: { 29 | sync: { 30 | async get(keys) { 31 | const out = {}; 32 | for (const key of keys) { 33 | out[key] = storage[key]; 34 | } 35 | return out; 36 | } 37 | } 38 | }, 39 | }; 40 | const settings = new BrowserStorageSettings(browser); 41 | 42 | should(await settings.get("a", "b")).deepEqual({ 43 | a: "x", 44 | b: "y", 45 | }); 46 | }); 47 | 48 | it("set()", async function() { 49 | const storage = {a: "x", b: "y", c: "z"}; 50 | const browser = { 51 | storage: { 52 | sync: { 53 | async set(mapping) { 54 | for (const [key, value] of Object.entries(mapping)) { 55 | storage[key] = value; 56 | } 57 | } 58 | } 59 | }, 60 | }; 61 | const settings = new BrowserStorageSettings(browser); 62 | 63 | await settings.set({a: "0", b: "1"}); 64 | 65 | should(storage).deepEqual({a: "0", b: "1", c: "z"}); 66 | }); 67 | }); 68 | 69 | describe("InMemorySettings", function() { 70 | it("set() then get()", async function() { 71 | const settings = new InMemorySettings(); 72 | const expected = {a: "x", b: "y", c: undefined}; 73 | 74 | await settings.set({a: "x", b: "y"}); 75 | 76 | const result = await settings.get("a", "b", "c"); 77 | 78 | should(result).deepEqual(expected); 79 | }); 80 | }); 81 | 82 | describe("FormManager", function() { 83 | it("save()", async function() { 84 | const form = { 85 | a: {value: "x"}, 86 | b: {value: "y"}, 87 | c: {value: "z"}, 88 | }; 89 | const settings = new InMemorySettings(); 90 | const formManager = new FormManager(form, settings); 91 | 92 | await formManager.save("a", "b"); 93 | 94 | should(await settings.get("a", "b", "c")).deepEqual({a: "x", b: "y", c: undefined}); 95 | }); 96 | 97 | it("load()", async function() { 98 | const form = { 99 | a: {value: "discard"}, 100 | b: {value: "discard"}, 101 | c: {value: "discard"}, 102 | }; 103 | const defaults = {b: "test"}; 104 | const settings = new InMemorySettings(); 105 | const formManager = new FormManager(form, settings, defaults); 106 | 107 | await settings.set({a: "x"}); 108 | await formManager.load("a", "b", "c"); 109 | 110 | should(form).deepEqual({ 111 | a: {value: "x"}, 112 | b: {value: "test"}, 113 | c: {value: ""}, 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/uploadToDanbooru.js: -------------------------------------------------------------------------------- 1 | import { 2 | DanbooruURL, 3 | getPageActionMatchRegExp, 4 | } from "./utils.js"; 5 | 6 | import { NoopURLOpener } from "./urlOpener.js"; 7 | 8 | export class UploadToDanbooru { 9 | constructor( 10 | browser, 11 | isChrome, 12 | settings, 13 | contextMenuSetupper, 14 | getUploadURLService, 15 | urlOpenerClass, 16 | ) { 17 | this.browser = browser; 18 | this.settings = settings; 19 | this.contextMenuSetupper = contextMenuSetupper; 20 | this.getUploadURLService = getUploadURLService; 21 | this.urlOpenerClass = urlOpenerClass || NoopURLOpener; 22 | this.manifest = browser.runtime.getManifest(); 23 | this.isChrome = isChrome; 24 | this.menuID = "upload-to-danbooru"; 25 | this.defaultDanbooruURL = DanbooruURL; 26 | 27 | this.onInstalled = this.onInstalled.bind(this); 28 | this.onContextMenuClicked = this.onContextMenuClicked.bind(this); 29 | this.onPageActionClicked = this.onPageActionClicked.bind(this); 30 | } 31 | 32 | get pageActionAPI() { 33 | if (this.isChrome) { 34 | return this.browser.action; 35 | } 36 | 37 | return this.browser.pageAction; 38 | } 39 | 40 | get pageActionRegexString() { 41 | const key = this.isChrome ? "action" : "page_action"; 42 | 43 | return getPageActionMatchRegExp(this.manifest[key]["show_matches"]); 44 | } 45 | 46 | get pageActionRegex() { 47 | return new RegExp(this.pageActionRegexString); 48 | } 49 | 50 | getUrlOpener(tab) { 51 | return new this.urlOpenerClass(this.browser, tab); 52 | } 53 | 54 | async init() { 55 | this.browser.runtime.onInstalled.addListener(this.onInstalled); 56 | this.pageActionAPI.onClicked.addListener(this.onPageActionClicked); 57 | 58 | if (this.browser.contextMenus) { 59 | this.browser.contextMenus.onClicked.addListener(this.onContextMenuClicked); 60 | } 61 | 62 | if (!this.isChrome) { 63 | await this.contextMenuSetupper.setup(); 64 | } 65 | } 66 | 67 | async onInstalled() { 68 | if (this.isChrome) { 69 | await this.contextMenuSetupper.setup(); 70 | } 71 | } 72 | 73 | async onContextMenuClicked(info, tab) { 74 | if (info.menuItemId !== this.menuID) { 75 | return; 76 | } 77 | 78 | const settings = await this.settings.get("url", "openIn", "contextMenuOpenIn"); 79 | const danbooruUrl = settings.url || this.defaultDanbooruURL; 80 | const uploadURLService = this.getUploadURLService(danbooruUrl, this.pageActionRegex); 81 | 82 | const url = uploadURLService.fromOnClickData(info); 83 | const urlOpener = this.getUrlOpener(tab); 84 | // TODO: remove settings.openIn after next major version update (>=4) 85 | const openIn = settings.contextMenuOpenIn || settings.openIn || "background"; 86 | 87 | await urlOpener.open(url.href, openIn); 88 | } 89 | 90 | async onPageActionClicked(tab) { 91 | const settings = await this.settings.get("url", "openIn", "pageActionOpenIn"); 92 | const danbooruUrl = settings.url || this.defaultDanbooruURL; 93 | const uploadURLService = this.getUploadURLService(danbooruUrl, this.pageActionRegex); 94 | 95 | const url = uploadURLService.fromTab(tab); 96 | const urlOpener = this.getUrlOpener(tab); 97 | // TODO: remove settings.openIn after next major version update (>=4) 98 | const openIn = settings.pageActionOpenIn || settings.openIn || "current"; 99 | 100 | await urlOpener.open(url.href, openIn); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/test_chromeifyManifest.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | import { join } from "path"; 3 | import { tmpdir } from "os"; 4 | import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; 5 | import { chromeifyManifest, chromeifyManifestFile } from "../chromeifyManifest.impl.js"; 6 | 7 | function makeManifest() { 8 | return { 9 | "manifest_version": 3, 10 | "name": "Test", 11 | "version": "0.0.1", 12 | "icons": { 13 | "16": "test.png", 14 | }, 15 | "page_action": { 16 | "default_icon": { 17 | "16": "test.png", 18 | }, 19 | "default_title": "Test", 20 | "show_matches": [ 21 | "https://example.com/*", 22 | "https://example.net/*", 23 | "https://example.org/*", 24 | ], 25 | }, 26 | "options_ui": { 27 | "page": "options.html", 28 | }, 29 | "background": { 30 | "scripts": ["background.js"], 31 | "type": "module", 32 | }, 33 | "permissions": [ 34 | "activeTab", 35 | "contextMenus", 36 | "storage", 37 | ], 38 | "commands": { 39 | "_execute_page_action": { 40 | "suggested_key": { 41 | "default": "Alt+Shift+D" 42 | } 43 | } 44 | }, 45 | "browser_specific_settings": { 46 | "gecko": { 47 | "id": "admin@localhost", 48 | "strict_min_version": "106.0", 49 | }, 50 | }, 51 | }; 52 | } 53 | 54 | function makeChromeManifest() { 55 | return { 56 | "manifest_version": 3, 57 | "name": "Test", 58 | "version": "0.0.1", 59 | "icons": { 60 | "16": "test.png", 61 | }, 62 | "options_ui": { 63 | "page": "options.html", 64 | }, 65 | "background": { 66 | "service_worker": "background.js", 67 | "type": "module", 68 | }, 69 | "permissions": [ 70 | "activeTab", 71 | "contextMenus", 72 | "storage", 73 | ], 74 | "commands": { 75 | "_execute_action": { 76 | "suggested_key": { 77 | "default": "Alt+Shift+D" 78 | } 79 | } 80 | }, 81 | "minimum_chrome_version": "97", 82 | "action": { 83 | "default_icon": { 84 | "16": "test.png", 85 | }, 86 | "default_title": "Test", 87 | "show_matches": [ 88 | "https://example.com/*", 89 | "https://example.net/*", 90 | "https://example.org/*", 91 | ], 92 | }, 93 | }; 94 | } 95 | 96 | describe("chromeifyManifest()", function() { 97 | it("", function() { 98 | const result = chromeifyManifest(makeManifest()); 99 | 100 | should(result).deepEqual(makeChromeManifest()); 101 | }); 102 | }); 103 | 104 | describe("chromeifyManifestFile()", function() { 105 | let tmp; 106 | 107 | before(async function () { 108 | tmp = await mkdtemp(join(tmpdir(), "test-chromeifyManifestFile-")); 109 | }); 110 | 111 | after(async function () { 112 | await rm(tmp, {"recursive": true, "force": true}); 113 | }); 114 | 115 | it("", async function() { 116 | const manifest = makeManifest(); 117 | const path = join(tmp, "manifest.json"); 118 | 119 | await writeFile(path, JSON.stringify(manifest), {"encoding": "utf8"}); 120 | await chromeifyManifestFile(path, manifest); 121 | 122 | const result = JSON.parse(await readFile(path, {"encoding": "utf8"})); 123 | 124 | should(result).deepEqual(makeChromeManifest()); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/test_uploadURLService.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { UploadURLServiceImpl } from "upload-to-danbooru/uploadURLService.js"; 4 | import { RefererGetter } from "upload-to-danbooru/refererGetter.js"; 5 | import { UploadURLGenerator } from "upload-to-danbooru/uploadURLGenerator.js"; 6 | import { URLFixuper } from "upload-to-danbooru/urlFixuper.js"; 7 | 8 | class TestUploadURLGenerator extends UploadURLGenerator { 9 | constructor() { 10 | super(); 11 | 12 | this.generateCalls = []; 13 | } 14 | 15 | generate(url, ref) { 16 | this.generateCalls.push([url, ref]); 17 | 18 | return `http://example.com?url=${url}&ref=${ref}`; 19 | } 20 | } 21 | 22 | class TestRefererGetter extends RefererGetter { 23 | constructor(fromOnClickDataResult) { 24 | super(); 25 | 26 | this.fromOnClickDataResult = fromOnClickDataResult; 27 | this.fromOnClickDataCalls = []; 28 | } 29 | 30 | fromOnClickData(info) { 31 | this.fromOnClickDataCalls.push(info); 32 | 33 | return this.fromOnClickDataResult; 34 | } 35 | } 36 | 37 | class TestURLFixuper extends URLFixuper { 38 | constructor(fixResult) { 39 | super(); 40 | 41 | this.fixResult = fixResult; 42 | this.fixCalls = []; 43 | } 44 | 45 | fix(url) { 46 | this.fixCalls.push(url); 47 | 48 | return this.fixResult; 49 | } 50 | } 51 | 52 | describe("UploadURLServiceImpl", function() { 53 | it("fromOnClickData() info.srcUrl", function() { 54 | const uploadURLGenerator = new TestUploadURLGenerator("test url"); 55 | const refererGetter = new TestRefererGetter("test referer"); 56 | const urlFixuper = new TestURLFixuper("test fixed url"); 57 | const uploadURLService = new UploadURLServiceImpl( 58 | uploadURLGenerator, 59 | refererGetter, 60 | urlFixuper, 61 | ); 62 | const srcUrl = "http://example.com/x.jpg"; 63 | const info = {srcUrl}; 64 | 65 | const url = uploadURLService.fromOnClickData(info); 66 | 67 | should(url).equal("http://example.com?url=test fixed url&ref=test referer"); 68 | should(uploadURLGenerator.generateCalls).deepEqual([ 69 | ["test fixed url", "test referer"], 70 | ]); 71 | should(urlFixuper.fixCalls).deepEqual([srcUrl]); 72 | should(refererGetter.fromOnClickDataCalls).deepEqual([info]); 73 | }); 74 | 75 | it("fromOnClickData() info.linkUrl", function() { 76 | const uploadURLGenerator = new TestUploadURLGenerator("test url"); 77 | const uploadURLService = new UploadURLServiceImpl( 78 | uploadURLGenerator, 79 | ); 80 | const linkUrl = "http://example.com/x.jpg"; 81 | const info = {linkUrl}; 82 | 83 | const url = uploadURLService.fromOnClickData(info); 84 | 85 | should(url).equal("http://example.com?url=http://example.com/x.jpg&ref=undefined"); 86 | should(uploadURLGenerator.generateCalls).deepEqual([ 87 | [linkUrl, undefined], 88 | ]); 89 | }); 90 | 91 | it("fromOnClickData() error", function() { 92 | const uploadURLService = new UploadURLServiceImpl(); 93 | 94 | should(function () {uploadURLService.fromOnClickData({});} ).throw("info object must contain either srcUrl or linkUrl"); 95 | }); 96 | 97 | it("fromTab()", function() { 98 | const uploadURLGenerator = new TestUploadURLGenerator("test url"); 99 | const uploadURLService = new UploadURLServiceImpl( 100 | uploadURLGenerator, 101 | ); 102 | const tabUrl = "http://example.com/x.html"; 103 | const tab = {url: tabUrl}; 104 | 105 | const url = uploadURLService.fromTab(tab); 106 | 107 | should(url).equal("http://example.com?url=http://example.com/x.html&ref=undefined"); 108 | should(uploadURLGenerator.generateCalls).deepEqual([ 109 | [tabUrl, undefined], 110 | ]); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/test_uploadToDanbooru.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { ContextMenuSetupper } from "upload-to-danbooru/contextMenuManager.js"; 4 | import { UploadToDanbooru } from "upload-to-danbooru/uploadToDanbooru.js"; 5 | import { InMemorySettings } from "upload-to-danbooru/settings.js"; 6 | 7 | 8 | describe("UploadToDanbooru", function() { 9 | class TestContextMenuSetupper extends ContextMenuSetupper { 10 | constructor() { 11 | super(); 12 | this.setupCallCount = 0; 13 | } 14 | 15 | setup() { 16 | this.setupCallCount++; 17 | } 18 | } 19 | 20 | const chromeManifest = { 21 | "minimum_chrome_version": "97", 22 | "action": { 23 | "show_matches": [ 24 | "https://x.com/*/status/*", 25 | "https://www.pixiv.net/artworks/*", 26 | "https://*.tumblr.com/post/*", 27 | ], 28 | }, 29 | }; 30 | 31 | it("pageActionAPI browser", function() { 32 | const pageAction = new Object(); 33 | const browser = { 34 | pageAction, 35 | runtime: { 36 | getManifest() { 37 | return {}; 38 | } 39 | } 40 | }; 41 | const uploadToDanbooru = new UploadToDanbooru(browser); 42 | 43 | should(uploadToDanbooru.pageActionAPI).equal(pageAction); 44 | }); 45 | 46 | it("pageActionAPI chrome", function() { 47 | const action = new Object(); 48 | const chrome = { 49 | action, 50 | runtime: { 51 | getManifest() { 52 | return chromeManifest; 53 | } 54 | } 55 | }; 56 | const uploadToDanbooru = new UploadToDanbooru(chrome, true); 57 | 58 | should(uploadToDanbooru.pageActionAPI).equal(action); 59 | }); 60 | 61 | 62 | it("init()", async function() { 63 | let onInstalled, onContextMenuClicked, onPageActionClicked; 64 | const settings = new InMemorySettings(); 65 | const cms = new TestContextMenuSetupper(); 66 | const browser = { 67 | isChrome: false, 68 | contextMenus: { 69 | onClicked: { 70 | addListener(callback) { 71 | onContextMenuClicked = callback; 72 | } 73 | }, 74 | }, 75 | pageAction: { 76 | onClicked: { 77 | addListener(callback) { 78 | onPageActionClicked = callback; 79 | } 80 | } 81 | }, 82 | runtime: { 83 | onInstalled: { 84 | async addListener(callback) { 85 | onInstalled = callback; 86 | } 87 | }, 88 | getManifest() { 89 | return {}; 90 | } 91 | }, 92 | }; 93 | const uploadToDanbooru = new UploadToDanbooru(browser, false, settings, cms); 94 | 95 | await uploadToDanbooru.init(); 96 | 97 | should(onInstalled).equal(uploadToDanbooru.onInstalled); 98 | should(onContextMenuClicked).equal(uploadToDanbooru.onContextMenuClicked); 99 | should(onPageActionClicked).equal(uploadToDanbooru.onPageActionClicked); 100 | should(cms.setupCallCount).equal(1); 101 | }); 102 | 103 | it("onInstalled() browser", async function() { 104 | const cms = new TestContextMenuSetupper(); 105 | const settings = new InMemorySettings(); 106 | const browser = { 107 | runtime: { 108 | getManifest() { 109 | return {}; 110 | } 111 | } 112 | }; 113 | const uploadToDanbooru = new UploadToDanbooru(browser, false, settings, cms); 114 | 115 | await uploadToDanbooru.onInstalled(); 116 | 117 | should(cms.setupCallCount).equal(0); 118 | }); 119 | 120 | it("onInstalled() chrome", async function() { 121 | const cms = new TestContextMenuSetupper(); 122 | const settings = new InMemorySettings(); 123 | const chrome = { 124 | runtime: { 125 | getManifest() { 126 | return chromeManifest; 127 | } 128 | } 129 | }; 130 | const uploadToDanbooru = new UploadToDanbooru(chrome, true, settings, cms); 131 | 132 | await uploadToDanbooru.onInstalled(); 133 | 134 | should(cms.setupCallCount).equal(1); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/test_urlOpener.js: -------------------------------------------------------------------------------- 1 | import should from "should/as-function.js"; 2 | 3 | import { 4 | NotImplementedError, 5 | URLOpener, 6 | GenericURLOpener, 7 | BrowserURLOpener, 8 | AndroidURLOpener, 9 | ChromeURLOpener, 10 | NoopURLOpener, 11 | } from "upload-to-danbooru/urlOpener.js"; 12 | 13 | describe("URLOpener", function() { 14 | const url = "https://example.com"; 15 | const urlOpener = new URLOpener(); 16 | 17 | it("current()", function() { 18 | should(urlOpener.current(url)).rejectedWith(NotImplementedError); 19 | }); 20 | 21 | it("new()", function() { 22 | should(urlOpener.new(url, true)).rejectedWith(NotImplementedError); 23 | }); 24 | 25 | class TestURLOpener extends URLOpener { 26 | async current(url) { 27 | return {url}; 28 | } 29 | 30 | async new(url, background) { 31 | return {url, background}; 32 | } 33 | } 34 | 35 | const testUrlOpener = new TestURLOpener(); 36 | 37 | it("open() new", async function() { 38 | const result = await testUrlOpener.open(url, "new"); 39 | 40 | should(result).deepEqual({url, background: false}); 41 | }); 42 | 43 | it("open() background", async function() { 44 | const result = await testUrlOpener.open(url, "background"); 45 | 46 | should(result).deepEqual({url, background: true}); 47 | }); 48 | 49 | it("open() current", async function() { 50 | const result = await testUrlOpener.open(url, "current"); 51 | 52 | should(result).deepEqual({url}); 53 | }); 54 | 55 | it("open() default", async function() { 56 | const result = await testUrlOpener.open(url); 57 | 58 | should(result).deepEqual({url}); 59 | }); 60 | }); 61 | 62 | describe("GenericURLOpener", function() { 63 | const url = "https://example.com"; 64 | const tab = {id: 123}; 65 | 66 | class TestURLOpener extends GenericURLOpener { 67 | get newTabParams() { 68 | return {test: `x${this.tab.id}`}; 69 | } 70 | } 71 | 72 | it("current()", async function() { 73 | const update = []; 74 | const browser = { 75 | tabs: { 76 | async update(tabId, params) { 77 | update.push([tabId, params]); 78 | } 79 | }, 80 | }; 81 | const urlOpener = new TestURLOpener(browser, tab); 82 | 83 | await urlOpener.current(url); 84 | 85 | should(update).deepEqual([[tab.id, {url}]]); 86 | }); 87 | 88 | it("new() active", async function() { 89 | const create = []; 90 | const browser = { 91 | tabs: { 92 | async create(params) { 93 | create.push(params); 94 | } 95 | }, 96 | }; 97 | const urlOpener = new TestURLOpener(browser, tab); 98 | 99 | await urlOpener.new(url, false); 100 | 101 | should(create).deepEqual([{url, active: true, test: "x123"}]); 102 | }); 103 | 104 | it("new() background", async function() { 105 | const create = []; 106 | const browser = { 107 | tabs: { 108 | async create(params) { 109 | create.push(params); 110 | } 111 | }, 112 | }; 113 | const urlOpener = new TestURLOpener(browser, tab); 114 | 115 | await urlOpener.new(url, false); 116 | 117 | should(create).deepEqual([{url, active: true, test: "x123"}]); 118 | }); 119 | }); 120 | 121 | describe("BrowserURLOpener", function() { 122 | const urlOpener = new BrowserURLOpener(undefined, {id: 123}); 123 | 124 | it("subclass of GenericURLOpener", function() { 125 | should(urlOpener).instanceof(GenericURLOpener); 126 | }); 127 | 128 | it("get newTabParams()", function() { 129 | should(urlOpener.newTabParams).deepEqual({openerTabId: 123}); 130 | }); 131 | }); 132 | 133 | describe("AndroidURLOpener", function() { 134 | const urlOpener = new AndroidURLOpener(); 135 | 136 | it("subclass of GenericURLOpener", function() { 137 | should(urlOpener).instanceof(GenericURLOpener); 138 | }); 139 | 140 | it("get newTabParams()", function() { 141 | should(urlOpener.newTabParams).deepEqual({}); 142 | }); 143 | }); 144 | 145 | describe("ChromeURLOpener", function() { 146 | const urlOpener = new ChromeURLOpener(undefined, {id: 123, index: 5}); 147 | 148 | it("subclass of GenericURLOpener", function() { 149 | should(urlOpener).instanceof(GenericURLOpener); 150 | }); 151 | 152 | it("get newTabParams()", function() { 153 | should(urlOpener.newTabParams).deepEqual({openerTabId: 123, index: 6}); 154 | }); 155 | }); 156 | 157 | describe("NoopURLOpener", function() { 158 | const url = "https://example.com"; 159 | const urlOpener = new NoopURLOpener(); 160 | 161 | it("current()", async function() { 162 | await urlOpener.current(url); 163 | }); 164 | 165 | it("new() active", async function() { 166 | await urlOpener.new(url, false); 167 | }); 168 | 169 | it("new() background", async function() { 170 | await urlOpener.new(url, false); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Upload to Danbooru", 4 | "description": "Add a page action and a context menu option (for images) to upload to Danbooru.", 5 | "version": "3.4.1", 6 | "icons": { 7 | "16": "icons/icon16.png", 8 | "32": "icons/icon32.png", 9 | "48": "icons/icon48.png", 10 | "96": "icons/icon96.png", 11 | "128": "icons/icon128.png", 12 | "256": "icons/icon256.png" 13 | }, 14 | "page_action": { 15 | "default_icon": { 16 | "16": "icons/icon16.png", 17 | "19": "icons/icon19.png", 18 | "32": "icons/icon32.png", 19 | "38": "icons/icon38.png" 20 | }, 21 | "default_title": "Upload to Danbooru", 22 | "show_matches": [ 23 | "https://x.com/*/status/*", 24 | "https://twitter.com/*/status/*", 25 | "https://twitpic.com/*", 26 | 27 | "https://www.pixiv.net/artworks/*", 28 | "https://www.pixiv.net/en/artworks/*", 29 | "https://sketch.pixiv.net/items/*", 30 | "https://*.fanbox.cc/posts/*", 31 | "https://www.fanbox.cc/@*/posts/*", 32 | "https://booth.pm/*/items/*", 33 | "https://*.booth.pm/items/*", 34 | 35 | "https://fantia.jp/posts/*", 36 | 37 | "https://www.patreon.com/posts/*", 38 | 39 | "https://skeb.jp/@*/works/*", 40 | 41 | "https://enty.jp/posts/*", 42 | "https://enty.jp/*/posts/*", 43 | 44 | "https://seiga.nicovideo.jp/seiga/im*", 45 | "https://seiga.nicovideo.jp/watch/mg*", 46 | 47 | "https://*.deviantart.com/art/*", 48 | "https://www.deviantart.com/*/art/*", 49 | "https://sta.sh/*", 50 | 51 | "https://nijie.info/view.php?*id=*", 52 | "https://nijie.info/view_popup.php?*id=*", 53 | "https://sp.nijie.info/view.php?*id=*", 54 | "https://sp.nijie.info/view_popup.php?*id=*", 55 | 56 | "https://www.tinami.com/view/*", 57 | "https://www.tinami.com/view/tweet/card/*", 58 | 59 | "https://picdig.net/ema/projects/*", 60 | 61 | "https://poipiku.com/*/*.html", 62 | 63 | "https://pawoo.net/@*/*", 64 | "https://pawoo.net/web/statuses/*", 65 | "https://baraag.net/@*/*", 66 | "https://baraag.net/web/statuses/*", 67 | 68 | "https://bsky.app/profile/*/post/*", 69 | 70 | "https://www.bilibili.com/opus/*", 71 | "https://www.bilibili.com/read/*", 72 | "https://t.bilibili.com/*", 73 | 74 | "https://m.weibo.cn/detail/*", 75 | "https://m.weibo.cn/status/*", 76 | "https://weibo.com/*/*", 77 | "https://www.weibo.com/*/*", 78 | "https://tw.weibo.com/*/*", 79 | 80 | "https://misskey.io/notes/*", 81 | "https://misskey.art/notes/*", 82 | "https://misskey.design/notes/*", 83 | 84 | "https://www.hentai-foundry.com/pictures/user/*", 85 | 86 | "https://www.newgrounds.com/art/view/*", 87 | 88 | "https://www.furaffinity.net/view/*", 89 | 90 | "https://inkbunny.net/s/*", 91 | "https://inkbunny.net/submissionview.php?*id=*", 92 | 93 | "https://www.artstation.com/artwork/*", 94 | "https://*.artstation.com/projects/*", 95 | 96 | "https://foundation.app/@*/*/*", 97 | 98 | "https://opensea.io/assets/*/*/*", 99 | 100 | "https://anifty.jp/creations/*", 101 | "https://anifty.jp/*/creations/*", 102 | 103 | "https://xfolio.jp/portfolio/*/works/*", 104 | "https://xfolio.jp/*/portfolio/*/works/*", 105 | 106 | "https://ci-en.net/creator/*/article/*", 107 | "https://ci-en.dlsite.com/creator/*/article/*", 108 | 109 | "https://arca.live/b/*/*", 110 | 111 | "https://*.lofter.com/post/*", 112 | 113 | "https://www.plurk.com/p/*", 114 | 115 | "https://*.fandom.com/wiki/Gallery?*file=*", 116 | 117 | "https://yande.re/post/show/*", 118 | "https://konachan.com/post/show/*", 119 | 120 | "https://boards.4channel.org/*/thread/*", 121 | "https://boards.4channel.org/*/res/*", 122 | "https://boards.4chan.org/*/thread/*", 123 | "https://boards.4chan.org/*/res/*", 124 | 125 | "https://gelbooru.com/index.php?*id=*", 126 | "https://safebooru.org/index.php?*id=*", 127 | "https://rule34.xxx/index.php?*id=*", 128 | "https://e621.net/posts/*", 129 | 130 | "https://www.instagram.com/p/*", 131 | "https://www.instagram.com/reel/*", 132 | "https://www.instagram.com/tv/*", 133 | 134 | "https://*.reddit.com/r/*/comments/*", 135 | "https://www.reddit.com/gallery/*", 136 | "https://www.reddit.com/media?url=*", 137 | "https://www.redditmedia.com/mediaembed/*", 138 | 139 | "https://vk.com/wall-*_*", 140 | "https://vk.com/photo-*_*", 141 | "https://vk.com/doc*_*", 142 | "https://m.vk.com/wall-*_*", 143 | "https://m.vk.com/photo-*_*", 144 | 145 | "https://medibang.com/picture/*/", 146 | 147 | "https://www.behance.net/gallery/*/*", 148 | 149 | "https://dotpict.net/works/*", 150 | "https://*.dotpict.net/works/*", 151 | 152 | "https://www.foriio.com/works/*", 153 | 154 | "https://galleria.emotionflow.com/*/*.html", 155 | 156 | "https://www.xiaohongshu.com/explore/*", 157 | 158 | "https://*.tumblr.com/post/*" 159 | ] 160 | }, 161 | "options_ui": { 162 | "page": "options.html" 163 | }, 164 | "background": { 165 | "scripts": ["background.js"], 166 | "type": "module" 167 | }, 168 | "permissions": [ 169 | "activeTab", 170 | "contextMenus", 171 | "storage" 172 | ], 173 | "commands": { 174 | "_execute_page_action": { 175 | "suggested_key": { 176 | "default": "Alt+Shift+D" 177 | } 178 | } 179 | }, 180 | "browser_specific_settings": { 181 | "gecko": { 182 | "id": "upload-to-danbooru@zipfiled.info", 183 | "strict_min_version": "112.0" 184 | } 185 | } 186 | } 187 | --------------------------------------------------------------------------------