├── .gitattributes ├── static ├── icons │ ├── icon16.png │ ├── icon19.png │ ├── icon24.png │ ├── icon32.png │ ├── icon38.png │ ├── icon48.png │ └── icon128.png └── img │ ├── bubble_meta.png │ └── context_menu.png ├── .prettierrc ├── src ├── types │ ├── chrome.ts │ └── citation.d.ts ├── lib │ ├── global.ts │ ├── notification.ts │ ├── resolve.ts │ ├── background.ts │ ├── logger.ts │ ├── omnibox.ts │ ├── autolink.ts │ ├── metadata.ts │ ├── permissions.ts │ ├── context_menu.ts │ ├── history.ts │ ├── utils.ts │ ├── messaging.ts │ └── storage.ts ├── offscreen.html ├── notification.scss ├── bubble.scss ├── qrcodegen │ ├── note.md │ └── Readme.markdown ├── notification.html ├── context_match.ts ├── qr.scss ├── offscreen.ts ├── csl │ ├── locales │ │ ├── README.md │ │ └── locales.json │ └── styles │ │ └── README.md ├── notification.ts ├── sw.ts ├── options.scss ├── background.ts ├── citation.scss ├── autolink.ts ├── citation.html ├── utils.ts ├── bubble.html ├── qr.html └── bubble.ts ├── .prettierignore ├── tests ├── tsconfig.json ├── sw.spec.ts ├── global.teardown.ts ├── notification.spec.ts ├── global.setup.ts ├── autolink.spec.ts ├── fixtures.ts ├── utils.ts └── citation.spec.ts ├── .editorconfig ├── tsconfig.json ├── playwright.config.ts ├── resources └── icon │ ├── icon-optimized.svg │ └── icon-inkscape.svg ├── NOTICE ├── .gitignore ├── Readme.md ├── package.json ├── eslint.config.js └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary 3 | -------------------------------------------------------------------------------- /static/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon16.png -------------------------------------------------------------------------------- /static/icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon19.png -------------------------------------------------------------------------------- /static/icons/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon24.png -------------------------------------------------------------------------------- /static/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon32.png -------------------------------------------------------------------------------- /static/icons/icon38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon38.png -------------------------------------------------------------------------------- /static/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon48.png -------------------------------------------------------------------------------- /static/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/icons/icon128.png -------------------------------------------------------------------------------- /static/img/bubble_meta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/img/bubble_meta.png -------------------------------------------------------------------------------- /static/img/context_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdmower/doi-resolver/HEAD/static/img/context_menu.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /src/types/chrome.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | export type TargetTab = `${chrome.omnibox.OnInputEnteredDisposition}`; 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | csl-styles/** 2 | dist/** 3 | package-lock.json 4 | pkg/** 5 | resources/** 6 | src/csl/** 7 | src/qrcodegen/** 8 | static/icons/** 9 | static/img/** 10 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022", "DOM", "WebWorker"] 4 | }, 5 | "extends": "../tsconfig.json", 6 | "include": ["**/*", "../src/lib/options.ts"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [{*.md}] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/lib/global.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | export type DoiBrowser = 'chrome' | 'edge' | 'firefox'; 6 | 7 | // From webpack defines 8 | declare const G_DOI_BROWSER: DoiBrowser; 9 | 10 | export const doiBrowser = G_DOI_BROWSER; 11 | -------------------------------------------------------------------------------- /src/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/notification.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | @import '~bootstrap/scss/bootstrap'; 6 | 7 | body { 8 | overflow: hidden; 9 | } 10 | 11 | .modal-dialog { 12 | height: calc(100% - 2 * $modal-dialog-margin); 13 | overflow: hidden; 14 | } 15 | -------------------------------------------------------------------------------- /src/bubble.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | @import '~bootstrap/scss/bootstrap'; 6 | 7 | #containerDiv { 8 | width: 600px; 9 | padding: 0.5 * $container-padding-x 0; 10 | } 11 | 12 | #metaButtons svg { 13 | width: $font-size-base; 14 | height: $font-size-base; 15 | } 16 | 17 | #doiHistory option[disabled] { 18 | font-size: 50%; 19 | } 20 | -------------------------------------------------------------------------------- /tests/sw.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from './fixtures'; 2 | import {getDefaultOptions} from '../src/lib/options'; 3 | 4 | test.describe('Service Worker', () => { 5 | test('set default options on install', async ({serviceWorker}) => { 6 | const stg = await serviceWorker.evaluate(() => chrome.storage.local.get()); 7 | expect(stg).toEqual(getDefaultOptions()); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/qrcodegen/note.md: -------------------------------------------------------------------------------- 1 | `qrcodegen` is not available as an npm package. This local copy of `qrcodegen.ts` is from a 2 | tagged release at https://github.com/nayuki/QR-Code-generator/tree/master/typescript-javascript. 3 | The only modifications made to the source are: 4 | 5 | 1. Change each 6 | ``` 7 | namespace ____ {} 8 | ``` 9 | to 10 | ``` 11 | export namespace ____ {} 12 | ``` 13 | so that the classes can be imported into other modules. 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2023", "DOM", "WebWorker"], 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "target": "ES2022", 7 | "strict": true, 8 | "isolatedModules": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "outDir": "dist", 12 | "sourceMap": true, 13 | "importHelpers": true, 14 | "types": ["node", "chrome"] 15 | }, 16 | "include": ["src/**/*", "playwright.config.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, devices} from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './tests', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | reporter: 'line', 9 | use: { 10 | trace: 'on-first-retry', 11 | }, 12 | projects: [ 13 | { 14 | name: 'modify manifest', 15 | testMatch: /global\.setup\.ts/, 16 | }, 17 | { 18 | name: 'chromium', 19 | use: {...devices['Desktop Chrome'], channel: 'chromium'}, 20 | dependencies: ['modify manifest'], 21 | }, 22 | { 23 | name: 'restore manifest', 24 | testMatch: /global\.teardown\.ts/, 25 | dependencies: ['chromium'], 26 | }, 27 | ], 28 | }); 29 | -------------------------------------------------------------------------------- /src/lib/notification.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logError} from './logger'; 6 | 7 | /** 8 | * Open a minimal popup window to display a notification. 9 | * @param title Title 10 | * @param message Message 11 | */ 12 | export function showNotification(title: string, message: string): void { 13 | const encodedTitle = encodeURIComponent(title); 14 | const encodedMessage = encodeURIComponent(message); 15 | 16 | chrome.windows 17 | .create({ 18 | url: `notification.html?title=${encodedTitle}&message=${encodedMessage}`, 19 | focused: true, 20 | height: 250, 21 | width: 500, 22 | type: 'popup', 23 | }) 24 | .catch((error) => { 25 | logError('Failed to open notification window\n', error); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /tests/global.teardown.ts: -------------------------------------------------------------------------------- 1 | import {test as teardown} from '@playwright/test'; 2 | import {fileURLToPath} from 'node:url'; 3 | import {rename} from 'node:fs/promises'; 4 | import {existsSync} from 'node:fs'; 5 | import path from 'node:path'; 6 | 7 | /** 8 | * Restore original manifest.json from .bak backup 9 | * @param path Path to manifest.json 10 | */ 11 | async function restoreManifest(path: string) { 12 | if (!existsSync(path + '.bak')) { 13 | return; 14 | } 15 | await rename(path + '.bak', path); 16 | } 17 | 18 | teardown('restore manifest', async ({}) => { 19 | const pathToExtension = fileURLToPath(import.meta.resolve('../dist/chrome')); 20 | const pathToManifest = path.join(pathToExtension, 'manifest.json'); 21 | await restoreManifest(pathToManifest); 22 | }); 23 | -------------------------------------------------------------------------------- /src/notification.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/types/citation.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | // Minimal declarations needed by this extension. 6 | 7 | declare module '@citation-js/core' { 8 | interface CiteOptions { 9 | format?: string; 10 | template?: string; 11 | lang?: string; 12 | } 13 | 14 | export class Cite { 15 | constructor(data: Record); 16 | 17 | /** 18 | * Generate a formatted citation 19 | */ 20 | public format(output: 'bibliography', options: CiteOptions): string; 21 | } 22 | 23 | interface PluginConfig { 24 | has(name: string): boolean; 25 | add(name: string, value: string): void; 26 | delete(name: string): void; 27 | list(): string[]; 28 | } 29 | 30 | export const plugins: { 31 | config: { 32 | get(name: '@csl'): { 33 | templates: PluginConfig; 34 | locales: PluginConfig; 35 | }; 36 | }; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/context_match.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logInfo} from './lib/logger'; 6 | import {ContextMenuToggleMessage, MessageCmd, sendInternalMessage} from './lib/messaging'; 7 | import {debounce, findDoiInString} from './lib/utils'; 8 | 9 | (function () { 10 | const selectHandler = debounce(() => { 11 | const selection = document.getSelection()?.toString() || ''; 12 | const doi = findDoiInString(selection); 13 | sendInternalMessage({ 14 | cmd: MessageCmd.ContextMenuToggle, 15 | data: { 16 | enable: !!doi, 17 | doi, 18 | }, 19 | }); 20 | }, 50); 21 | 22 | try { 23 | document.addEventListener('selectionchange', selectHandler); 24 | // Handle scenario: doi is selected in a window, enabling context menu, 25 | // then window loses focus and selection in another window disables 26 | // context menu, and finally original window focused with original DOI 27 | // text selection still in place. 28 | window.addEventListener('focus', selectHandler); 29 | } catch (ex) { 30 | logInfo('DOI context menu selection detection encountered an exception', ex); 31 | } 32 | })(); 33 | -------------------------------------------------------------------------------- /tests/notification.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from './fixtures'; 2 | import {DisplayTheme} from '../src/lib/options'; 3 | 4 | test.describe('Notification', () => { 5 | const title = 'C0mplic@ted titlé?'; 6 | const message = 'This "content & stuff"™ should not fail — not even 1% of the time!® Right?'; 7 | 8 | test.beforeEach(async ({page, extension}) => { 9 | const search = '?' + new URLSearchParams({title, message}).toString(); 10 | await page.goto(extension.urls.notification + search); 11 | }); 12 | 13 | test('use display theme', async ({page}) => { 14 | const htmlThemes: string[] = [DisplayTheme.Dark, DisplayTheme.Light]; 15 | for (const theme of Object.values(DisplayTheme)) { 16 | await page.evaluate((s) => chrome.storage.local.set(s), {theme}); 17 | await page.reload({waitUntil: 'load'}); 18 | const htmlTheme = await page.locator('html').evaluate((html) => html.dataset.bsTheme); 19 | expect( 20 | theme === DisplayTheme.System ? htmlThemes.includes(htmlTheme ?? '') : htmlTheme === theme 21 | ).toBe(true); 22 | } 23 | }); 24 | 25 | test('content display', async ({page}) => { 26 | await page.reload({waitUntil: 'load'}); 27 | await expect(page.getByRole('heading', {name: title})).toBeVisible(); 28 | await expect(page.getByText(message)).toBeVisible(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /resources/icon/icon-optimized.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2016 Matthew D. Mower 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | This product includes software developed by 16 | Matthew D. Mower (https://github.com/mdmower). 17 | 18 | Portions of this software depend on 19 | 20 | iro.js: 21 | https://iro.js.org/ 22 | Mozilla Public License 2.0 23 | https://github.com/jaames/iro.js/blob/master/LICENSE.txt 24 | 25 | QR Code generator library: 26 | https://www.nayuki.io/page/qr-code-generator-library 27 | MIT license 28 | https://github.com/nayuki/QR-Code-generator#license 29 | 30 | citeproc-js: 31 | https://citeproc-js.readthedocs.io/ 32 | Common Public Attribution License Version 1.0 33 | https://github.com/Juris-M/citeproc-js/blob/master/LICENSE 34 | 35 | Citation Style Language (CSL) citation styles: 36 | https://citationstyles.org/ 37 | Creative Commons Attribution-ShareAlike 3.0 Unported 38 | https://creativecommons.org/licenses/by-sa/3.0/ 39 | -------------------------------------------------------------------------------- /src/qr.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | @import '~bootstrap/scss/bootstrap'; 6 | 7 | body { 8 | background: $light; 9 | padding: 3rem 0; 10 | } 11 | 12 | @include color-mode(dark) { 13 | body { 14 | background: $black; 15 | } 16 | } 17 | 18 | .page-title { 19 | color: $blue; 20 | } 21 | 22 | @include color-mode(dark) { 23 | .page-title { 24 | color: $headings-color; 25 | } 26 | } 27 | 28 | .page-content { 29 | width: 950px; 30 | padding: 2rem; 31 | background: $white; 32 | border-radius: 1rem; 33 | } 34 | 35 | @include color-mode(dark) { 36 | .page-content { 37 | background: $dark; 38 | } 39 | } 40 | 41 | .icon { 42 | width: $font-size-base; 43 | height: $font-size-base; 44 | 45 | &.bigger { 46 | width: 1.25 * $font-size-base; 47 | height: 1.25 * $font-size-base; 48 | } 49 | } 50 | 51 | .modal-dialog { 52 | max-width: 700px; 53 | } 54 | 55 | #doiHistory option[disabled] { 56 | text-align: center; 57 | } 58 | 59 | .transparentInput { 60 | color: transparent; 61 | background-image: 62 | linear-gradient(45deg, $gray-300 25%, transparent 25%), 63 | linear-gradient(-45deg, $gray-300 25%, transparent 25%), 64 | linear-gradient(45deg, transparent 75%, $gray-300 75%), 65 | linear-gradient(-45deg, transparent 75%, $gray-300 75%); 66 | background-size: 20px 20px; 67 | background-position: 68 | 0 0, 69 | 0 10px, 70 | 10px -10px, 71 | -10px 0px; 72 | pointer-events: none; 73 | } 74 | -------------------------------------------------------------------------------- /src/offscreen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {OffscreenAction, isOffscreenDocMessage, MessageCmd} from './lib/messaging'; 6 | import {parseTitles} from './utils'; 7 | 8 | document.addEventListener( 9 | 'DOMContentLoaded', 10 | function () { 11 | new DoiOffscreen().init(); 12 | }, 13 | false 14 | ); 15 | 16 | class DoiOffscreen { 17 | /** 18 | * Initialize offscreen document. 19 | */ 20 | public init() { 21 | this.startListeners(); 22 | } 23 | 24 | /** 25 | * Attach window/element listeners. 26 | */ 27 | private startListeners(): void { 28 | chrome.runtime.onMessage.addListener(this.runtimeMessageHandler.bind(this)); 29 | } 30 | 31 | /** 32 | * Handle runtime messages 33 | * @param message Internal message 34 | * @param sender Message sender 35 | * @param sendResponse Response callback 36 | */ 37 | private runtimeMessageHandler( 38 | message: unknown, 39 | sender: chrome.runtime.MessageSender, 40 | sendResponse: (response?: unknown) => void 41 | ): boolean | void { 42 | if (!isOffscreenDocMessage(message) || !message.data) { 43 | return; 44 | } 45 | 46 | switch (message.data.action) { 47 | case OffscreenAction.ParseTitles: 48 | sendResponse({ 49 | cmd: MessageCmd.OffscreenDoc, 50 | data: { 51 | action: OffscreenAction.ParseTitles, 52 | data: parseTitles(message.data?.data), 53 | }, 54 | }); 55 | break; 56 | default: 57 | break; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/global.setup.ts: -------------------------------------------------------------------------------- 1 | import {test as setup} from '@playwright/test'; 2 | import {fileURLToPath} from 'node:url'; 3 | import {copyFile, readFile, writeFile} from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | 6 | // Playwright developers refuse to support optional permissions, so all permissions must be required. 7 | // https://github.com/microsoft/playwright/issues/32755 8 | 9 | /** 10 | * Make all optional permissions in manifest.json required and save a copy of the original manifest with .bak extension 11 | * @param path Path to manifest.json 12 | */ 13 | async function patchManifest(path: string) { 14 | const originalManifest = await readFile(path, 'utf8'); 15 | const manifest = JSON.parse(originalManifest) as chrome.runtime.ManifestV3; 16 | const {permissions, optional_permissions, host_permissions, optional_host_permissions} = manifest; 17 | if (!optional_permissions && !optional_host_permissions) { 18 | return; 19 | } 20 | 21 | await copyFile(path, path + '.bak'); 22 | manifest.permissions = [...(permissions ?? []), ...(optional_permissions ?? [])]; 23 | manifest.host_permissions = [...(host_permissions ?? []), ...(optional_host_permissions ?? [])]; 24 | delete manifest.optional_permissions; 25 | delete manifest.optional_host_permissions; 26 | await writeFile(path, JSON.stringify(manifest, undefined, 2)); 27 | } 28 | 29 | setup('patch manifest', async ({}) => { 30 | const pathToExtension = fileURLToPath(import.meta.resolve('../dist/chrome')); 31 | const pathToManifest = path.join(pathToExtension, 'manifest.json'); 32 | await patchManifest(pathToManifest); 33 | }); 34 | -------------------------------------------------------------------------------- /src/lib/resolve.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {TargetTab} from '../types/chrome'; 6 | import {getOptions, getDefaultOptions} from './options'; 7 | 8 | /** 9 | * Resolve DOI in the specified tab 10 | * @param doi DOI 11 | * @param useCustomResolver Whether to use a custom resolver 12 | * @param targetTab Target tab 13 | */ 14 | export async function resolveDoi( 15 | doi: string, 16 | useCustomResolver: boolean, 17 | targetTab?: TargetTab 18 | ): Promise { 19 | const stg = await getOptions('local', ['doi_resolver', 'shortdoi_resolver']); 20 | const defaultResolver = getDefaultOptions()['doi_resolver']; 21 | 22 | let doiUrl: string; 23 | if (useCustomResolver) { 24 | if (doi.startsWith('10/')) { 25 | const shortDoiResolver = stg.shortdoi_resolver || defaultResolver; 26 | doiUrl = shortDoiResolver + doi.replace(/^10\//, ''); 27 | } else { 28 | const doiResolver = stg.doi_resolver || defaultResolver; 29 | doiUrl = doiResolver + doi; 30 | } 31 | } else { 32 | if (doi.startsWith('10/')) { 33 | doiUrl = defaultResolver + doi.replace(/^10\//, ''); 34 | } else { 35 | doiUrl = defaultResolver + doi; 36 | } 37 | } 38 | 39 | switch (targetTab) { 40 | case 'newForegroundTab': 41 | await chrome.tabs.create({url: doiUrl, active: true}); 42 | break; 43 | case 'newBackgroundTab': 44 | await chrome.tabs.create({url: doiUrl, active: false}); 45 | break; 46 | default: { 47 | // "currentTab" 48 | const tabs = await chrome.tabs.query({active: true, currentWindow: true}); 49 | const tabId = tabs[0]?.id; 50 | if (tabId !== undefined) { 51 | await chrome.tabs.update(tabId, {url: doiUrl}); 52 | } 53 | break; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/csl/locales/README.md: -------------------------------------------------------------------------------- 1 |

CSL logo

2 | 3 |

Citation Style Language - Locales Repository

4 | 5 |

6 | License 7 | Build Status 8 | Supported Languages 9 |

10 | 11 | 12 | Introduction 13 | ------------ 14 | 15 | [github.com/citation-style-language/locales](https://github.com/citation-style-language/locales) is the official repository for Citation Style Language (CSL) locale files and is maintained by CSL project members. 16 | For more information, check out [CitationStyles.org](https://citationstyles.org/) and the [repository wiki](https://github.com/citation-style-language/locales/wiki). 17 | 18 | Licensing 19 | --------- 20 | 21 | All the locale files in this repository are released under the [Creative Commons Attribution-ShareAlike 3.0 Unported license](https://creativecommons.org/licenses/by-sa/3.0/). 22 | For attribution, any software using CSL locale files from this repository must include a clear mention of the CSL project and a link to [CitationStyles.org](https://citationstyles.org/). 23 | When distributing these locale files, the listings of translators in the locale metadata must be kept as is. 24 | -------------------------------------------------------------------------------- /src/lib/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import { 6 | createContextMenu, 7 | ContextMenuId, 8 | updateContextMenu, 9 | removeContextMenu, 10 | } from './context_menu'; 11 | import {recordDois} from './history'; 12 | import {logInfo, logWarn} from './logger'; 13 | import {HistoryDoi, getOptions, setOptions} from './options'; 14 | 15 | /** 16 | * Add context menu item 17 | */ 18 | export function addContextMenu(): void { 19 | createContextMenu(ContextMenuId.ResolveDoi); 20 | } 21 | 22 | /** 23 | * Reinit (remove and create) context menu 24 | * 25 | * TODO: This exists only to work around a bug in Firefox. Remove when 26 | * https://bugzilla.mozilla.org/show_bug.cgi?id=1817287 is fixed. 27 | */ 28 | export async function reinitContextMenu(): Promise { 29 | await removeContextMenu(ContextMenuId.ResolveDoi); 30 | createContextMenu(ContextMenuId.ResolveDoi); 31 | } 32 | 33 | /** 34 | * Update context menu item 35 | */ 36 | export async function resetContextMenu(): Promise { 37 | const stg = await getOptions('local', ['context_menu', 'context_menu_match']); 38 | updateContextMenu(ContextMenuId.ResolveDoi, !!stg.context_menu && !stg.context_menu_match); 39 | } 40 | 41 | /** 42 | * Process history DOI queue 43 | */ 44 | export async function processHistoryDoiQueue(): Promise { 45 | const stg = await getOptions('local', ['history_doi_queue']); 46 | const queue = stg.history_doi_queue || []; 47 | if (queue.length > 0) { 48 | await setOptions('local', {history_doi_queue: []}); 49 | logInfo(`DOI(s) queued for history: ${queue.join(', ')}`); 50 | try { 51 | const historyEntries = queue.map((item) => ({doi: item, title: '', save: false})); 52 | await recordDois(historyEntries); 53 | } catch (ex) { 54 | logWarn(`Unable to record dois in history: ${queue.join(', ')}`, ex); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | export enum LogLevel { 6 | None, 7 | Trace, 8 | Debug, 9 | Info, 10 | Warn, 11 | Error, 12 | } 13 | 14 | /** 15 | * Verify an item is an instance of LogLevel 16 | * @param val Unverified item 17 | */ 18 | export function isLogLevel(val: unknown): val is LogLevel { 19 | return typeof val === 'number' && val in LogLevel; 20 | } 21 | 22 | /** 23 | * Log trace 24 | * @param data Log data 25 | */ 26 | export function logTrace(...data: unknown[]): void { 27 | log(LogLevel.Trace, ...data); 28 | } 29 | 30 | /** 31 | * Log debugging information 32 | * @param data Log data 33 | */ 34 | export function logDebug(...data: unknown[]): void { 35 | log(LogLevel.Debug, ...data); 36 | } 37 | 38 | /** 39 | * Log information 40 | * @param data Log data 41 | */ 42 | export function logInfo(...data: unknown[]): void { 43 | log(LogLevel.Info, ...data); 44 | } 45 | 46 | /** 47 | * Log warning 48 | * @param data Log data 49 | */ 50 | export function logWarn(...data: unknown[]): void { 51 | log(LogLevel.Warn, ...data); 52 | } 53 | 54 | /** 55 | * Log error 56 | * @param data Log data 57 | */ 58 | export function logError(...data: unknown[]): void { 59 | log(LogLevel.Error, ...data); 60 | } 61 | 62 | /** 63 | * Log data to console 64 | * @param level Log level 65 | * @param data Log data 66 | */ 67 | export function log(level: LogLevel, ...data: unknown[]): void { 68 | switch (level) { 69 | case LogLevel.Trace: 70 | console.trace(...data); 71 | break; 72 | case LogLevel.Debug: 73 | console.debug(...data); 74 | break; 75 | case LogLevel.Info: 76 | console.info(...data); 77 | break; 78 | case LogLevel.Warn: 79 | console.warn(...data); 80 | break; 81 | case LogLevel.Error: 82 | console.error(...data); 83 | break; 84 | default: 85 | break; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/notification.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logError} from './lib/logger'; 6 | import {applyTheme, getMessageNodes} from './utils'; 7 | 8 | document.addEventListener( 9 | 'DOMContentLoaded', 10 | function () { 11 | new DoiNotification().init().catch((error) => { 12 | logError('Init failed', error); 13 | }); 14 | }, 15 | false 16 | ); 17 | 18 | class DoiNotification { 19 | private elements_: { 20 | title: HTMLHeadingElement; 21 | message: HTMLParagraphElement; 22 | close: HTMLButtonElement; 23 | }; 24 | 25 | constructor() { 26 | const elementMissing = (selector: string) => { 27 | throw new Error(`Required element is missing from the page: ${selector}`); 28 | }; 29 | this.elements_ = { 30 | close: 31 | document.querySelector('button#close') || elementMissing('button#close'), 32 | title: document.querySelector('h5#title') || elementMissing('h5#title'), 33 | message: 34 | document.querySelector('p#message') || elementMissing('p#message'), 35 | }; 36 | } 37 | 38 | /** 39 | * Initialize notification. 40 | */ 41 | public async init() { 42 | this.renderNotification(); 43 | await applyTheme(window); 44 | this.startListeners(); 45 | } 46 | 47 | /** 48 | * Attach window/element listeners. 49 | */ 50 | private startListeners(): void { 51 | this.elements_.close.addEventListener('click', close.bind(window)); 52 | } 53 | 54 | /** 55 | * Render a notification 56 | */ 57 | private renderNotification(): void { 58 | const url = new URL(location.href); 59 | const title = url.searchParams.get('title') ?? ''; 60 | const message = url.searchParams.get('message') ?? ''; 61 | 62 | document.title = title; 63 | this.elements_.close.append(...getMessageNodes('closeButton')); 64 | 65 | this.elements_.title.textContent = title; 66 | this.elements_.message.textContent = message; 67 | this.elements_.close.focus(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/autolink.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from './fixtures'; 2 | 3 | test.describe('Autolink', () => { 4 | const url = 'https://doi.org/10.1000/182'; 5 | 6 | test('DOI autolink', async ({page, serviceWorker}) => { 7 | await serviceWorker.evaluate((s) => chrome.storage.local.set(s), {auto_link: true}); 8 | await page.goto(url); 9 | await expect(page.getByRole('heading', {name: 'DOI Handbook'})).toBeVisible(); 10 | await expect(page.getByText('DOI https://doi.org/10.1000/182 identifies')).toBeVisible(); 11 | await expect(page.getByRole('link', {name: '10.1000/182'})).toHaveAttribute('href', url); 12 | }); 13 | 14 | test('DOI autolink normal exclusion', async ({page, serviceWorker}) => { 15 | await serviceWorker.evaluate((s) => chrome.storage.local.set(s), { 16 | auto_link: true, 17 | autolink_exclusions: ['www.doi.org'], 18 | }); 19 | await page.goto(url); 20 | await expect(page.getByText('DOI https://doi.org/10.1000/182 identifies')).toBeVisible(); 21 | await expect(page.getByRole('link', {name: '10.1000/182'})).toHaveCount(0); 22 | }); 23 | 24 | test('DOI autolink regex exclusion', async ({page, serviceWorker}) => { 25 | await serviceWorker.evaluate((s) => chrome.storage.local.set(s), { 26 | auto_link: true, 27 | autolink_exclusions: ['/www\\.doi\\.org/.*/'], 28 | }); 29 | await page.goto(url); 30 | await expect(page.getByText('DOI https://doi.org/10.1000/182 identifies')).toBeVisible(); 31 | await expect(page.getByRole('link', {name: '10.1000/182'})).toHaveCount(0); 32 | }); 33 | 34 | test('DOI autolink with custom resolver', async ({page, serviceWorker}) => { 35 | await serviceWorker.evaluate((s) => chrome.storage.local.set(s), { 36 | auto_link: true, 37 | custom_resolver: true, 38 | cr_autolink: 'custom', 39 | doi_resolver: 'https://example.com/', 40 | }); 41 | await page.goto(url); 42 | await expect(page.getByRole('link', {name: '10.1000/182'})).toHaveAttribute( 43 | 'href', 44 | 'https://example.com/10.1000/182' 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/sw.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {autolinkHandler} from './lib/autolink'; 6 | import {addContextMenu, resetContextMenu} from './lib/background'; 7 | import {contextMenuHandler, contextMenuMatchHandler} from './lib/context_menu'; 8 | import {logError, logInfo} from './lib/logger'; 9 | import {runtimeMessageHandler} from './lib/messaging'; 10 | import {omniHandler} from './lib/omnibox'; 11 | import { 12 | checkForNewOptions, 13 | removeDeprecatedOptions, 14 | storageChangeHandler, 15 | verifySyncState, 16 | } from './lib/storage'; 17 | 18 | // Reference: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle 19 | // TODO: W3C is discussing new listeners (names subject to change): onEnabled, onExtensionLoaded 20 | // https://github.com/w3c/webextensions/issues/353 21 | chrome.runtime.onInstalled.addListener(install); 22 | chrome.runtime.onStartup.addListener(startup); 23 | 24 | chrome.storage.onChanged.addListener(storageChangeHandler); 25 | chrome.omnibox.onInputEntered.addListener(omniHandler); 26 | chrome.tabs.onUpdated.addListener(autolinkHandler); 27 | chrome.tabs.onUpdated.addListener(contextMenuMatchHandler); 28 | chrome.runtime.onMessage.addListener(runtimeMessageHandler); 29 | chrome.contextMenus.onClicked.addListener(contextMenuHandler); 30 | 31 | /** 32 | * Handle extension install event 33 | * @param details Extension install information 34 | */ 35 | function install(details: chrome.runtime.InstalledDetails) { 36 | // Only need to create context menu when extension is installed or updated, 37 | // not when the browser is updated. 38 | if (details.reason !== 'install' && details.reason !== 'update') { 39 | return; 40 | } 41 | 42 | addContextMenu(); 43 | startup(); 44 | logInfo('Extension installed'); 45 | } 46 | 47 | /** 48 | * Handle browser profile startup event 49 | */ 50 | function startup() { 51 | removeDeprecatedOptions() 52 | .then(verifySyncState) 53 | .then(checkForNewOptions) 54 | .then(resetContextMenu) 55 | .then(() => logInfo('Startup event handled')) 56 | .catch((error) => { 57 | logError('Error encountered while processing startup handler\n', error); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/omnibox.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {resolveDoi} from './resolve'; 6 | import {getOptions, OmniboxTab} from './options'; 7 | import {isValidDoi, trimDoi} from './utils'; 8 | import {logError} from './logger'; 9 | import {showNotification} from './notification'; 10 | import {queueRecordDoi} from './history'; 11 | import {TargetTab} from '../types/chrome'; 12 | 13 | /** 14 | * Handle omnibox input entered events 15 | * @param text Text from omnibox 16 | * @param disposition Default tab where action should occur 17 | */ 18 | export function omniHandler(text: string, disposition: TargetTab): void { 19 | omniHandlerAsync(text, disposition).catch((error) => 20 | logError('Failed to handle omnibox input', error) 21 | ); 22 | } 23 | 24 | /** 25 | * Async handler for omnibox input entered events 26 | * @param text Text from omnibox 27 | * @param disposition Default tab where action should occur 28 | */ 29 | async function omniHandlerAsync(text: string, disposition: TargetTab) { 30 | const doiInput = encodeURI(trimDoi(text)); 31 | if (!isValidDoi(doiInput)) { 32 | const notificationTitle = chrome.i18n.getMessage('invalidDoiTitle'); 33 | const notificationMessage = chrome.i18n.getMessage('invalidDoiAlert'); 34 | showNotification(notificationTitle, notificationMessage); 35 | return; 36 | } 37 | 38 | // This action doesn't qualify as a user gesture, so we can't request meta 39 | // permissions (i.e. re-establish them for free if they were removed). If 40 | // the permissions happen to be available, they will be used automatically 41 | // by during history update. 42 | await queueRecordDoi(doiInput); 43 | 44 | const stg = await getOptions('local', ['omnibox_tab', 'custom_resolver', 'cr_omnibox']); 45 | let tab: TargetTab; 46 | switch (stg.omnibox_tab) { 47 | case OmniboxTab.NewForegroundTab: 48 | tab = 'newForegroundTab'; 49 | break; 50 | case OmniboxTab.NewBackgroundTab: 51 | tab = 'newBackgroundTab'; 52 | break; 53 | case OmniboxTab.CurrentTab: 54 | tab = 'currentTab'; 55 | break; 56 | default: 57 | tab = disposition; 58 | break; 59 | } 60 | 61 | await resolveDoi(doiInput, !!stg.custom_resolver && stg.cr_omnibox === 'custom', tab); 62 | } 63 | -------------------------------------------------------------------------------- /src/options.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | @import '~bootstrap/scss/bootstrap'; 6 | 7 | body { 8 | background: $light; 9 | padding: 3rem 0; 10 | } 11 | 12 | @include color-mode(dark) { 13 | body { 14 | background: $black; 15 | } 16 | } 17 | 18 | .page-title { 19 | color: $blue; 20 | } 21 | 22 | @include color-mode(dark) { 23 | .page-title { 24 | color: $headings-color; 25 | } 26 | } 27 | 28 | .page-content { 29 | width: 950px; 30 | padding: 2rem; 31 | background: $white; 32 | border-radius: 1rem; 33 | } 34 | 35 | @include color-mode(dark) { 36 | .page-content { 37 | background: $dark; 38 | } 39 | } 40 | 41 | .icon { 42 | width: $font-size-base; 43 | height: $font-size-base; 44 | 45 | &.bigger { 46 | width: 1.25 * $font-size-base; 47 | height: 1.25 * $font-size-base; 48 | } 49 | } 50 | 51 | .form-check-icon { 52 | color: $gray-600; 53 | } 54 | 55 | .historyEntrySave, 56 | .historyEntryDelete { 57 | width: 6rem; 58 | } 59 | 60 | .historyEntryTitle { 61 | cursor: default; 62 | } 63 | 64 | .modal-dialog { 65 | max-width: 700px; 66 | } 67 | 68 | #alExclusions { 69 | .match { 70 | color: $green; 71 | } 72 | 73 | .nomatch { 74 | color: $red; 75 | } 76 | } 77 | 78 | th.historyEntryDoi { 79 | position: relative; 80 | } 81 | 82 | @keyframes history-spinner { 83 | to { 84 | transform: rotate(360deg); 85 | } 86 | } 87 | 88 | #historySpinner { 89 | position: absolute; 90 | display: inline-block; 91 | width: 1em; 92 | height: 1em; 93 | top: calc(50% - 0.5em); 94 | right: $table-cell-padding-x; 95 | 96 | &.show-spinner::before { 97 | content: ''; 98 | box-sizing: border-box; 99 | position: absolute; 100 | top: 50%; 101 | left: 50%; 102 | width: 1em; 103 | height: 1em; 104 | margin-top: -0.5em; 105 | margin-left: -0.5em; 106 | border-radius: 50%; 107 | border: 2px solid $body-color; 108 | border-top-color: transparent; 109 | animation: history-spinner 0.8s linear infinite; 110 | } 111 | } 112 | 113 | @include color-mode(dark) { 114 | #historySpinner.show-spinner::before { 115 | border-color: $body-color-dark; 116 | border-top-color: transparent; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Custom 107 | .vscode/ 108 | pkg 109 | csl-styles 110 | local.manifest.json 111 | /test-results/ 112 | /playwright-report/ 113 | /blob-report/ 114 | /playwright/.cache/ 115 | -------------------------------------------------------------------------------- /src/qrcodegen/Readme.markdown: -------------------------------------------------------------------------------- 1 | QR Code generator library - TypeScript 2 | ====================================== 3 | 4 | 5 | Introduction 6 | ------------ 7 | 8 | This project aims to be the best, clearest QR Code generator library. The primary goals are flexible options and absolute correctness. Secondary goals are compact implementation size and good documentation comments. 9 | 10 | Home page with live JavaScript demo, extensive descriptions, and competitor comparisons: https://www.nayuki.io/page/qr-code-generator-library 11 | 12 | 13 | Features 14 | -------- 15 | 16 | Core features: 17 | 18 | * Significantly shorter code but more documentation comments compared to competing libraries 19 | * Supports encoding all 40 versions (sizes) and all 4 error correction levels, as per the QR Code Model 2 standard 20 | * Output format: Raw modules/pixels of the QR symbol 21 | * Detects finder-like penalty patterns more accurately than other implementations 22 | * Encodes numeric and special-alphanumeric text in less space than general text 23 | * Open-source code under the permissive MIT License 24 | 25 | Manual parameters: 26 | 27 | * User can specify minimum and maximum version numbers allowed, then library will automatically choose smallest version in the range that fits the data 28 | * User can specify mask pattern manually, otherwise library will automatically evaluate all 8 masks and select the optimal one 29 | * User can specify absolute error correction level, or allow the library to boost it if it doesn't increase the version number 30 | * User can create a list of data segments manually and add ECI segments 31 | 32 | More information about QR Code technology and this library's design can be found on the project home page. 33 | 34 | 35 | Examples 36 | -------- 37 | 38 | ```typescript 39 | // Name abbreviated for the sake of these examples here 40 | const QRC = qrcodegen.QrCode; 41 | 42 | // Simple operation 43 | const qr0 = QRC.encodeText("Hello, world!", QRC.Ecc.MEDIUM); 44 | const svg = toSvgString(qr0, 4); // See qrcodegen-input-demo 45 | 46 | // Manual operation 47 | const segs = qrcodegen.QrSegment.makeSegments("3141592653589793238462643383"); 48 | const qr1 = QRC.encodeSegments(segs, QRC.Ecc.HIGH, 5, 5, 2, false); 49 | for (let y = 0; y < qr1.size; y++) { 50 | for (let x = 0; x < qr1.size; x++) { 51 | (... paint qr1.getModule(x, y) ...) 52 | } 53 | } 54 | ``` 55 | 56 | More complete set of examples: https://github.com/nayuki/QR-Code-generator/blob/master/typescript-javascript/qrcodegen-output-demo.ts . 57 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # DOI Resolver 2 | 3 | A browser extension to resolve DOIs (digital object identifiers) to their web destinations. Available at: 4 | 5 | - [Chrome Web Store](https://chrome.google.com/webstore/detail/doi-resolver/goanbaknlbojfglcepjnankoobfakbpg) for Google Chrome 6 | - [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/doi-resolver/blinbkglegdjgkpblpbgiemkbmkflgah) for Microsoft Edge 7 | - [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/doi-resolver/) for Firefox 8 | 9 | ## Features 10 | 11 | - Resolve DOIs and ShortDOIs via: 12 | - Extension button 13 | - Selected text context menu 14 | - Address bar with keyword: doi 15 | _Address bar usage:_ Type `doi` and press space. Now you can input a DOI and press enter to navigate to the web destination. 16 | - Maintain a history of visited DOIs along with their automatically fetched titles 17 | - Generate QR codes which can be scanned by smart phones to share a publication 18 | - Generate formatted citations for publications 19 | - Automatically convert DOIs on web pages into links 20 | - Use your own DOI resolver URL – useful if your institution provides a proxy service 21 | 22 | ## Development 23 | 24 | Clone git repository locally and then install npm packages 25 | 26 | ``` 27 | npm install 28 | ``` 29 | 30 | Available npm scripts: 31 | 32 | - `npm run lint` - Lint source using eslint 33 | - `npm run format` - Format source using prettier 34 | - `npm run build [chrome|edge|firefox]` - Build the extension; output to `dist//` 35 | - `npm run build-debug [chrome|edge|firefox]` - Build the extension without HTML/CSS minification and include source maps in transpiled JS; output to `dist//` 36 | - `npm run pkg [chrome|edge|firefox]` - Compress built extension from `dist//` into a `.zip` file and output to `pkg/` 37 | - `npm run clean` - Clear out the contents of `dist/` 38 | - `npm run release` - Run lint, clean, build, and pkg scripts, in that order (builds for all browsers) 39 | 40 | ## Languages 41 | 42 | Internationalization is supported and contributions are welcome. Reference English UI strings and descriptions in [messages.json](/static/_locales/en/messages.json). Create `static/_locales/xx/manifest.json` where `xx` is a supported locale code from [chrome.i18n: Locales](https://developer.chrome.com/docs/extensions/reference/api/i18n#locales) and prepare a pull request. Thanks! 43 | 44 | Languages supported: English 45 | 46 | ## License 47 | 48 | Apache License, Version 2.0. 49 | 50 | Copyright 2016 Matthew D. Mower (mdmower) 51 | -------------------------------------------------------------------------------- /tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | import {test as base, chromium, Worker, type BrowserContext} from '@playwright/test'; 2 | import {fileURLToPath} from 'node:url'; 3 | import {ExtensionPage, extensionPages} from './utils'; 4 | 5 | export const test = base.extend<{ 6 | context: BrowserContext; 7 | serviceWorker: Worker; 8 | extension: { 9 | id: string; 10 | urls: Record; 11 | }; 12 | }>({ 13 | context: async ({}, use) => { 14 | const pathToExtension = fileURLToPath(import.meta.resolve('../dist/chrome')); 15 | const context = await chromium.launchPersistentContext('', { 16 | headless: true, 17 | args: [ 18 | `--disable-extensions-except=${pathToExtension}`, 19 | `--load-extension=${pathToExtension}`, 20 | ], 21 | }); 22 | 23 | // Playwright is not able to capture the initial request when a tab is opened via 24 | // chrome.tabs.create(). There isn't enough incentive to mock responses until this is fixed. 25 | // https://github.com/microsoft/playwright/issues/32865 26 | // await context.route(/^https?:\/\//, (route, request) => {...}); 27 | 28 | await use(context); 29 | await context.close(); 30 | }, 31 | serviceWorker: async ({context}, use) => { 32 | let [worker] = context.serviceWorkers(); 33 | if (!worker) { 34 | worker = await context.waitForEvent('serviceworker'); 35 | } 36 | 37 | for (let i = 0; i < 10; i++) { 38 | const ready = await worker.evaluate( 39 | async () => 40 | typeof self !== 'undefined' && 41 | self instanceof ServiceWorkerGlobalScope && 42 | self.serviceWorker.state === 'activated' && 43 | typeof chrome !== 'undefined' && 44 | !!chrome.storage?.local && 45 | Object.keys((await chrome.storage.local.get()) ?? {}).length > 0 46 | ); 47 | if (!ready) { 48 | await new Promise((resolve) => setTimeout(resolve, 50)); 49 | } else { 50 | break; 51 | } 52 | } 53 | 54 | await use(worker); 55 | }, 56 | extension: async ({serviceWorker}, use) => { 57 | const id = serviceWorker.url().split('/')[2]; 58 | const urls = await serviceWorker.evaluate( 59 | ({pages}) => 60 | pages.reduce( 61 | (prev, curr) => { 62 | prev[curr] = chrome.runtime.getURL(`${curr}.html`); 63 | return prev; 64 | }, 65 | {} as Record 66 | ), 67 | {pages: extensionPages} 68 | ); 69 | await use({id, urls}); 70 | }, 71 | }); 72 | 73 | export const expect = test.expect; 74 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {autolinkHandler} from './lib/autolink'; 6 | import {addContextMenu, reinitContextMenu, resetContextMenu} from './lib/background'; 7 | import {contextMenuHandler, contextMenuMatchHandler} from './lib/context_menu'; 8 | import {logError, logInfo} from './lib/logger'; 9 | import {runtimeMessageHandler} from './lib/messaging'; 10 | import {omniHandler} from './lib/omnibox'; 11 | import { 12 | checkForNewOptions, 13 | removeDeprecatedOptions, 14 | storageChangeHandler, 15 | verifySyncState, 16 | } from './lib/storage'; 17 | 18 | // TODO: W3C is discussing new listeners (names subject to change): onEnabled, onExtensionLoaded 19 | // https://github.com/w3c/webextensions/issues/353 20 | chrome.runtime.onInstalled.addListener(install); 21 | chrome.runtime.onStartup.addListener(startup); 22 | 23 | chrome.storage.onChanged.addListener(storageChangeHandler); 24 | chrome.omnibox.onInputEntered.addListener(omniHandler); 25 | chrome.tabs.onUpdated.addListener(autolinkHandler); 26 | chrome.tabs.onUpdated.addListener(contextMenuMatchHandler); 27 | chrome.runtime.onMessage.addListener(runtimeMessageHandler); 28 | chrome.contextMenus.onClicked.addListener(contextMenuHandler); 29 | 30 | /** 31 | * Handle extension install event 32 | * @param details Extension install information 33 | */ 34 | function install(details: chrome.runtime.InstalledDetails) { 35 | // Only need to create context menu when extension is installed or updated, 36 | // not when the browser is updated. 37 | if (details.reason !== 'install' && details.reason !== 'update') { 38 | return; 39 | } 40 | 41 | addContextMenu(); 42 | startup({applyWorkaround: false}); 43 | logInfo('Extension installed'); 44 | } 45 | 46 | /** 47 | * Handle browser profile startup event 48 | * @param options Startup options 49 | * @param options.applyWorkaround Whether context menu reinit workaround should apply 50 | */ 51 | function startup(options: {applyWorkaround: boolean} = {applyWorkaround: true}) { 52 | // Partly work around known issue: if the add-on is disabled and reenabled in Firefox, the context menu will disappear. 53 | // Remove and create the context menu after the browser restarts. 54 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1817287 55 | const {applyWorkaround} = options; 56 | const workaround = applyWorkaround ? reinitContextMenu() : Promise.resolve(); 57 | 58 | workaround 59 | .then(removeDeprecatedOptions) 60 | .then(verifySyncState) 61 | .then(checkForNewOptions) 62 | .then(resetContextMenu) 63 | .then(() => logInfo('Startup event handled')) 64 | .catch((error) => { 65 | logError('Error encountered while processing startup handler\n', error); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /src/csl/locales/locales.json: -------------------------------------------------------------------------------- 1 | {"primary-dialects":{"af":"af-ZA","ar":"ar","bal":"bal-PK","bg":"bg-BG","brh":"brh-PK","ca":"ca-AD","cs":"cs-CZ","cy":"cy-GB","da":"da-DK","de":"de-DE","el":"el-GR","en":"en-US","es":"es-ES","et":"et-EE","eu":"eu","fa":"fa-IR","fi":"fi-FI","fr":"fr-FR","gl":"gl-ES","he":"he-IL","hi":"hi-IN","hr":"hr-HR","hu":"hu-HU","id":"id-ID","is":"is-IS","it":"it-IT","ja":"ja-JP","km":"km-KH","ko":"ko-KR","la":"la","lij":"lij-IT","lt":"lt-LT","lv":"lv-LV","mn":"mn-MN","ms":"ms-MY","nb":"nb-NO","nl":"nl-NL","nn":"nn-NO","pa":"pa-PK","pl":"pl-PL","pt":"pt-PT","ro":"ro-RO","ru":"ru-RU","sk":"sk-SK","sl":"sl-SI","sr":"sr-Latn-RS","sv":"sv-SE","th":"th-TH","tr":"tr-TR","uk":"uk-UA","vi":"vi-VN","zh":"zh-CN"},"language-names":{"af-ZA":["Afrikaans","Afrikaans"],"ar":["العربية","Arabic"],"bal-PK":["بلوچی (پاکستان)","Balochi (Pakistan)"],"bg-BG":["Български","Bulgarian"],"brh-PK":["براہوئی","Brahui"],"ca-AD":["Català","Catalan"],"cs-CZ":["Čeština","Czech"],"cy-GB":["Cymraeg","Welsh"],"da-DK":["Dansk","Danish"],"de-AT":["Deutsch (Österreich)","German (Austria)"],"de-CH":["Deutsch (Schweiz)","German (Switzerland)"],"de-DE":["Deutsch (Deutschland)","German (Germany)"],"el-GR":["Ελληνικά","Greek"],"en-GB":["English (UK)","English (UK)"],"en-US":["English (US)","English (US)"],"es-CL":["Español (Chile)","Spanish (Chile)"],"es-ES":["Español (España)","Spanish (Spain)"],"es-MX":["Español (México)","Spanish (Mexico)"],"et-EE":["Eesti keel","Estonian"],"eu":["Euskara","Basque"],"fa-IR":["فارسی","Persian"],"fi-FI":["Suomi","Finnish"],"fr-CA":["Français (Canada)","French (Canada)"],"fr-FR":["Français (France)","French (France)"],"gl-ES":["Galego (Spain)","Galician (Spain)"],"he-IL":["עברית","Hebrew"],"hi-IN":["हिंदी","Hindi"],"hr-HR":["Hrvatski","Croatian"],"hu-HU":["Magyar","Hungarian"],"id-ID":["Bahasa Indonesia","Indonesian"],"is-IS":["Íslenska","Icelandic"],"it-IT":["Italiano","Italian"],"ja-JP":["日本語","Japanese"],"km-KH":["ភាសាខ្មែរ","Khmer"],"ko-KR":["한국어","Korean"],"la":["Latina","Latin"],"lij-IT":["Lìgure","Ligurian"],"lt-LT":["Lietuvių kalba","Lithuanian"],"lv-LV":["Latviešu","Latvian"],"mn-MN":["Монгол","Mongolian"],"ms-MY":["Bahasa Melayu","Malay"],"nb-NO":["Norsk bokmål","Norwegian (Bokmål)"],"nl-NL":["Nederlands","Dutch"],"nn-NO":["Norsk nynorsk","Norwegian (Nynorsk)"],"pa-PK":["پنجابی (شاہ‌مکھی)","Punjabi (Shahmukhi)"],"pl-PL":["Polski","Polish"],"pt-BR":["Português (Brasil)","Portuguese (Brazil)"],"pt-PT":["Português (Portugal)","Portuguese (Portugal)"],"ro-RO":["Română","Romanian"],"ru-RU":["Русский","Russian"],"sk-SK":["Slovenčina","Slovak"],"sl-SI":["Slovenščina","Slovenian"],"sr-Cyrl-RS":["Српски","Serbian (Cyrllic)"],"sr-Latn-RS":["Srpski","Serbian (Latin)"],"sv-SE":["Svenska","Swedish"],"th-TH":["ไทย","Thai"],"tr-TR":["Türkçe","Turkish"],"uk-UA":["Українська","Ukrainian"],"vi-VN":["Tiếng Việt","Vietnamese"],"zh-CN":["中文 (中国大陆)","Chinese (PRC)"],"zh-TW":["中文 (台灣)","Chinese (Taiwan)"]}} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doi-resolver", 3 | "version": "7.3.1", 4 | "description": "A browser extension to quickly resolve digital object identifiers to their web destinations", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "check-types": "tsc --noEmit --skipLibCheck && tsc --noEmit --skipLibCheck --project build/tsconfig.json && tsc --noEmit --skipLibCheck --project tests/tsconfig.json", 10 | "format": "prettier --write .", 11 | "build": "tsx build/build.ts", 12 | "build-debug": "tsx build/build.ts --debug", 13 | "update-csl": "tsx build/update-csl.ts", 14 | "pkg": "tsx build/pkg.ts", 15 | "clean": "tsx build/clean.ts", 16 | "release": "npm run lint && npm run check-types && npm run clean && npm run build && npm run pkg", 17 | "test": "playwright test" 18 | }, 19 | "repository": "github:mdmower/doi-resolver", 20 | "keywords": [ 21 | "doi", 22 | "resolve", 23 | "resolver", 24 | "qr", 25 | "citation", 26 | "addon", 27 | "extension" 28 | ], 29 | "author": "Matthew D. Mower ", 30 | "license": "Apache-2.0", 31 | "bugs": { 32 | "url": "https://github.com/mdmower/doi-resolver/issues" 33 | }, 34 | "homepage": "https://github.com/mdmower/doi-resolver#readme", 35 | "dependencies": { 36 | "@citation-js/core": "^0.7.21", 37 | "@citation-js/plugin-csl": "^0.7.21", 38 | "@jaames/iro": "^5.5.2", 39 | "@popperjs/core": "^2.11.8", 40 | "bootstrap": "^5.3.8", 41 | "csv-stringify": "^6.6.0" 42 | }, 43 | "devDependencies": { 44 | "@eslint/js": "^9.39.2", 45 | "@fullhuman/postcss-purgecss": "^7.0.2", 46 | "@playwright/test": "^1.57.0", 47 | "@types/archiver": "^7.0.0", 48 | "@types/bootstrap": "^5.2.10", 49 | "@types/chrome": "^0.1.32", 50 | "@types/minimist": "^1.2.5", 51 | "@types/node": "^22.19.3", 52 | "@types/sortablejs": "^1.15.9", 53 | "@xmldom/xmldom": "^0.9.8", 54 | "archiver": "^7.0.1", 55 | "clean-css": "^5.3.3", 56 | "colors": "^1.4.0", 57 | "copy-webpack-plugin": "^13.0.1", 58 | "css-loader": "^7.1.2", 59 | "deepmerge": "^4.3.1", 60 | "dompurify": "^3.3.1", 61 | "esbuild-loader": "^4.4.0", 62 | "eslint": "^9.39.2", 63 | "eslint-config-prettier": "^10.1.8", 64 | "eslint-plugin-jsdoc": "^61.5.0", 65 | "eslint-plugin-no-unsanitized": "^4.1.4", 66 | "eslint-plugin-playwright": "^2.4.0", 67 | "eslint-plugin-prettier": "^5.5.4", 68 | "fast-deep-equal": "^3.1.3", 69 | "html-bundler-webpack-plugin": "^4.22.0", 70 | "minimist": "^1.2.8", 71 | "postcss-loader": "^8.2.0", 72 | "prettier": "^3.7.4", 73 | "sass": "^1.96.0", 74 | "sass-loader": "^16.0.6", 75 | "tsx": "^4.21.0", 76 | "typescript": "~5.9.3", 77 | "typescript-eslint": "^8.49.0", 78 | "webpack": "^5.103.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/citation.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | @import '~bootstrap/scss/bootstrap'; 6 | 7 | body { 8 | background: $light; 9 | padding: 3rem 0; 10 | } 11 | 12 | @include color-mode(dark) { 13 | body { 14 | background: $black; 15 | } 16 | } 17 | 18 | .page-title { 19 | color: $blue; 20 | } 21 | 22 | @include color-mode(dark) { 23 | .page-title { 24 | color: $headings-color; 25 | } 26 | } 27 | 28 | .page-content { 29 | width: 950px; 30 | padding: 2rem; 31 | background: $white; 32 | border-radius: 1rem; 33 | } 34 | 35 | @include color-mode(dark) { 36 | .page-content { 37 | background: $dark; 38 | } 39 | } 40 | 41 | .icon { 42 | width: $font-size-base; 43 | height: $font-size-base; 44 | 45 | &.bigger { 46 | width: 1.25 * $font-size-base; 47 | height: 1.25 * $font-size-base; 48 | } 49 | } 50 | 51 | .modal-dialog { 52 | max-width: 700px; 53 | } 54 | 55 | #doiHistory option[disabled] { 56 | text-align: center; 57 | } 58 | 59 | /* citeproc-js styles */ 60 | /* https://github.com/Juris-M/citeproc-js/blob/master/demo/local.css */ 61 | 62 | /*! purgecss start ignore */ 63 | #citeDiv { 64 | li { 65 | margin-bottom: 0.5em; 66 | } 67 | 68 | .footnote { 69 | text-indent: -1.3em; 70 | margin-left: 1.3em; 71 | margin-bottom: 0.2em; 72 | } 73 | 74 | .pagebreak { 75 | page-break-before: always; 76 | } 77 | 78 | .hangindent { 79 | text-indent: -2em; 80 | margin-left: 2em; 81 | } 82 | 83 | h2 { 84 | font-size: 110%; 85 | margin-top: 1em; 86 | margin-bottom: 0em; 87 | padding-top: 0em; 88 | padding-bottom: 0em; 89 | } 90 | 91 | h3 { 92 | font-size: 95%; 93 | margin-top: 0.9em; 94 | margin-bottom: 0em; 95 | padding-top: 0em; 96 | padding-bottom: 0em; 97 | } 98 | 99 | .csl-left-margin { 100 | width: 2em; 101 | float: left; 102 | text-indent: 1em; 103 | padding: 0.4em 0px 0.4em 0em; 104 | } 105 | 106 | .csl-right-inline { 107 | margin-left: 2em; 108 | padding: 0.4em 0.4em 0.4em 1em; 109 | } 110 | 111 | .csl-indent { 112 | margin-top: 0.5em; 113 | margin-left: 2em; 114 | padding-bottom: 0.2em; 115 | padding-left: 0.5em; 116 | border-left: 5px solid #ccc; 117 | } 118 | 119 | .dateindent { 120 | .csl-block { 121 | font-weight: bold; 122 | } 123 | 124 | .csl-left-margin { 125 | width: 3em; 126 | float: left; 127 | text-indent: 1em; 128 | padding: 0.4em 0px 0.4em 0em; 129 | } 130 | 131 | .csl-right-inline { 132 | margin-left: 3em; 133 | padding: 0.4em 0.4em 0.4em 1em; 134 | } 135 | } 136 | 137 | .heading { 138 | border-top: 5px solid #ccc; 139 | border-bottom: 5px solid #ccc; 140 | } 141 | 142 | .csl-bib-body { 143 | padding: 0.5em 0; 144 | } 145 | } 146 | /*! purgecss end ignore */ 147 | -------------------------------------------------------------------------------- /resources/icon/icon-inkscape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 31 | 40 | 44 | 48 | 52 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | import jsdoc from 'eslint-plugin-jsdoc'; 7 | import playwright from 'eslint-plugin-playwright'; 8 | import prettierConfigRecommended from 'eslint-plugin-prettier/recommended'; 9 | import noUnsanitized from 'eslint-plugin-no-unsanitized'; 10 | 11 | export default tseslint.config( 12 | { 13 | ignores: ['dist/', 'pkg/', 'src/csl/', 'src/qrcodegen/'], 14 | }, 15 | eslint.configs.recommended, 16 | { 17 | files: ['**/*.[jt]s'], 18 | languageOptions: {ecmaVersion: 2022}, 19 | rules: { 20 | 'no-undef': 'error', 21 | 'no-var': 'error', 22 | }, 23 | }, 24 | ...tseslint.configs.recommendedTypeChecked, 25 | ...tseslint.configs.stylisticTypeChecked, 26 | { 27 | files: ['eslint.config.js'], 28 | ...tseslint.configs.disableTypeChecked, 29 | }, 30 | { 31 | files: ['**/*.ts'], 32 | languageOptions: { 33 | parserOptions: { 34 | sourceType: 'module', 35 | projectService: true, 36 | }, 37 | }, 38 | rules: { 39 | '@typescript-eslint/dot-notation': 'off', 40 | '@typescript-eslint/explicit-module-boundary-types': 'warn', 41 | '@typescript-eslint/no-explicit-any': 'warn', 42 | '@typescript-eslint/no-inferrable-types': 'off', 43 | '@typescript-eslint/no-unsafe-assignment': 'warn', 44 | '@typescript-eslint/no-unsafe-enum-comparison': 'off', 45 | '@typescript-eslint/no-unsafe-return': 'warn', 46 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 47 | }, 48 | }, 49 | { 50 | files: ['**/*.ts'], 51 | ...jsdoc.configs['flat/recommended-typescript'], 52 | }, 53 | { 54 | files: ['**/*.ts'], 55 | rules: { 56 | 'jsdoc/require-jsdoc': [ 57 | 'error', 58 | { 59 | checkConstructors: false, 60 | contexts: ['MethodDefinition', 'FunctionDeclaration'], 61 | }, 62 | ], 63 | 'jsdoc/check-syntax': 'error', 64 | 'jsdoc/newline-after-description': 'off', 65 | 'jsdoc/check-types': 'off', 66 | 'jsdoc/require-returns': 'off', 67 | 'jsdoc/require-returns-description': 'off', 68 | 'jsdoc/require-param-type': 'off', 69 | }, 70 | }, 71 | { 72 | files: ['tests/**/*.ts'], 73 | rules: { 74 | 'jsdoc/require-jsdoc': 'off', 75 | }, 76 | }, 77 | { 78 | files: ['**/*.ts'], 79 | ...noUnsanitized.configs.recommended, 80 | }, 81 | { 82 | files: ['src/**/*.ts'], 83 | rules: { 84 | 'no-unsanitized/method': [ 85 | 'error', 86 | { 87 | escape: {methods: ['DOMPurify.sanitize']}, 88 | }, 89 | ], 90 | 'no-unsanitized/property': [ 91 | 'error', 92 | { 93 | escape: {methods: ['DOMPurify.sanitize']}, 94 | }, 95 | ], 96 | }, 97 | }, 98 | { 99 | files: ['src/**/*.ts', 'tests/**/*.ts'], 100 | languageOptions: { 101 | globals: {chrome: 'readonly'}, 102 | }, 103 | }, 104 | { 105 | files: ['src/*.ts'], 106 | ignores: ['src/sw.ts'], 107 | languageOptions: { 108 | globals: {...globals.browser}, 109 | }, 110 | }, 111 | { 112 | files: ['src/sw.ts', 'src/lib/*.ts'], 113 | languageOptions: { 114 | globals: {...globals.serviceworker}, 115 | }, 116 | }, 117 | { 118 | files: ['build/**/*.ts'], 119 | languageOptions: { 120 | globals: {...globals.node}, 121 | }, 122 | }, 123 | { 124 | files: ['playwright.config.ts', 'tests/**/*.ts'], 125 | ...playwright.configs['flat/recommended'], 126 | }, 127 | { 128 | files: ['playwright.config.ts', 'tests/**/*.ts'], 129 | languageOptions: { 130 | globals: { 131 | ...globals.node, 132 | ...globals.browser, 133 | }, 134 | parserOptions: {projectService: true}, 135 | }, 136 | }, 137 | prettierConfigRecommended 138 | ); 139 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import {Page, Worker} from '@playwright/test'; 2 | import { 3 | CustomResolverSelection, 4 | DisplayTheme, 5 | HistorySort, 6 | OmniboxTab, 7 | QrImageType, 8 | StorageOptions, 9 | } from '../src/lib/options'; 10 | 11 | export const handbookDoi = '10.1000/182'; 12 | export const handbookShortDoi = '10/aabbd'; 13 | 14 | export const extensionPages = [ 15 | 'bubble', 16 | 'citation', 17 | 'notification', 18 | 'offscreen', 19 | 'options', 20 | 'qr', 21 | ] as const; 22 | export type ExtensionPage = (typeof extensionPages)[number]; 23 | 24 | export const optionLabels: Record< 25 | Exclude< 26 | keyof StorageOptions, 27 | 'cite_style' | 'cr_bubble_last' | 'history_doi_queue' | 'qr_imgtype' | 'recorded_dois' 28 | >, 29 | string 30 | > = { 31 | auto_link: 'Automatically turn DOI codes on web pages into links', 32 | auto_link_rewrite: 'Rewrite existing doi.org and dx.doi.org links to use the Custom DOI Resolver', 33 | autolink_exclusions: 'Exclude URLs and URL patterns from autolink', 34 | cite_locale: 'Locale', 35 | // cite_style 36 | context_menu: 'Enable right-click context menu for selected text', 37 | context_menu_match: 'Only show context menu entry if a DOI is selected', 38 | cr_autolink: 'Autolink', 39 | cr_bubble: 'Popup', 40 | // cr_bubble_last 41 | cr_context: 'Context Menu', 42 | cr_history: 'History', 43 | cr_omnibox: 'Address Bar', 44 | custom_resolver: 'Use a custom DOI resolver', 45 | doi_resolver: 'URL for DOI resolver', 46 | history: 'Retain history of DOIs', 47 | // history_doi_queue 48 | history_fetch_title: 'Automatically fetch title when a new DOI is recorded', 49 | history_length: 'Number of history entries to retain (1 ≤ N ≤ 5000)', 50 | history_showsave: 'Only show saved entries in text input box drop-downs', 51 | history_showtitles: 'Show titles instead of DOIs in text input box drop-downs', 52 | history_sortby: 'Sort by', 53 | meta_buttons: 'Enable additional features in the browser action bubble', 54 | omnibox_tab: 'Address bar entry should by default open result in:', 55 | qr_bgcolor: 'Background color', 56 | qr_bgtrans: 'Transparent background', 57 | qr_border: 'Border', 58 | qr_fgcolor: 'Foreground color', 59 | // qr_imgtype 60 | qr_message: 'Include message in QR code', 61 | qr_size: 'Size', 62 | qr_title: 'Include title of reference in QR code', 63 | // recorded_dois 64 | shortdoi_resolver: 'URL for ShortDOI resolver', 65 | sync_data: "Synchronize this extension's settings with your profile", 66 | theme: 'Color theme for pages', 67 | }; 68 | 69 | export function getStorageValue(handler: Page | Worker, key: string): Promise { 70 | return handler.evaluate(async (key) => { 71 | const stg = await chrome.storage.local.get(key); 72 | return stg[key]; 73 | }, key); 74 | } 75 | 76 | export function getCustomizedOptions(): Required { 77 | return { 78 | auto_link: true, 79 | auto_link_rewrite: true, 80 | autolink_exclusions: ['example.com/abc', '/example\\.com/def'], 81 | cite_locale: 'en-US', 82 | cite_style: 'american-physics-society', 83 | context_menu: true, // Same as default 84 | context_menu_match: true, 85 | cr_autolink: CustomResolverSelection.Default, 86 | cr_bubble: CustomResolverSelection.Default, 87 | cr_bubble_last: CustomResolverSelection.Default, 88 | cr_context: CustomResolverSelection.Default, 89 | cr_history: CustomResolverSelection.Default, 90 | cr_omnibox: CustomResolverSelection.Default, 91 | custom_resolver: true, 92 | doi_resolver: 'https://dx.doi.org/', 93 | history: true, 94 | history_doi_queue: [], // Same as default 95 | history_fetch_title: true, 96 | history_length: 100, 97 | history_showsave: true, 98 | history_showtitles: true, 99 | history_sortby: HistorySort.Title, 100 | meta_buttons: false, 101 | omnibox_tab: OmniboxTab.CurrentTab, 102 | qr_bgcolor: '#ddeeb5', 103 | qr_bgtrans: true, 104 | qr_border: 1, 105 | qr_fgcolor: '#00193d', 106 | qr_imgtype: QrImageType.Svg, 107 | qr_message: false, // Same as default 108 | qr_size: 500, 109 | qr_title: true, 110 | recorded_dois: [ 111 | {doi: '10.1000/1', save: true, title: 'DOI Home'}, 112 | {doi: '10.1000/182', save: true, title: 'DOI Handbook'}, 113 | ], 114 | shortdoi_resolver: 'https://dx.doi.org/', 115 | sync_data: false, // Same as default 116 | theme: DisplayTheme.Dark, 117 | }; 118 | } 119 | -------------------------------------------------------------------------------- /src/lib/autolink.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logError, logInfo, logWarn} from './logger'; 6 | import {AutolinkVarsMessage, MessageCmd} from './messaging'; 7 | import {getDefaultOptions, getOptions, setOptions} from './options'; 8 | import {checkContentScriptPermissions} from './permissions'; 9 | 10 | /** 11 | * Send autolink option values via internal messaging 12 | * @param sendResponse Messaging method 13 | */ 14 | export async function sendAutolinkOptions( 15 | sendResponse: (response: AutolinkVarsMessage) => void 16 | ): Promise { 17 | const stg = await getOptions('local', [ 18 | 'cr_autolink', 19 | 'auto_link_rewrite', 20 | 'custom_resolver', 21 | 'doi_resolver', 22 | ]); 23 | 24 | let doiResolver: string; 25 | if (stg.custom_resolver && stg.cr_autolink == 'custom' && stg.doi_resolver) { 26 | doiResolver = stg.doi_resolver; 27 | } else { 28 | doiResolver = getDefaultOptions()['doi_resolver']; 29 | } 30 | const rewriteAnchorHref = !!stg.auto_link_rewrite; 31 | 32 | sendResponse({ 33 | cmd: MessageCmd.AutolinkVars, 34 | data: {doiResolver, rewriteAnchorHref}, 35 | }); 36 | } 37 | 38 | /** 39 | * Test whether a URL should be excluded from DOI autolinking 40 | * @param url URL 41 | * @param autolinkExclusions Exclusion URLs and URL patterns 42 | * (will be retrieved from storage if undefined) 43 | */ 44 | export async function testAutolinkExclusion( 45 | url: string, 46 | autolinkExclusions?: string[] 47 | ): Promise { 48 | const testExclusion = (exclusions: string[]): boolean => { 49 | if (!/^https?:\/\//i.test(url) || /^https:?\/\/chrome\.google\.com\/webstore[/$]/i.test(url)) { 50 | return true; 51 | } 52 | 53 | const urlNoProtocol = url.replace(/^https?:\/\//i, '').toLowerCase(); 54 | return exclusions.some((exclusion) => { 55 | if (exclusion.startsWith('/') && exclusion.endsWith('/')) { 56 | try { 57 | const re = new RegExp(exclusion.slice(1, -1), 'i'); 58 | return re.test(urlNoProtocol); 59 | } catch (ex) { 60 | logWarn('Invalid regular expression', exclusion, ex); 61 | } 62 | } else if (urlNoProtocol.startsWith(exclusion.toLowerCase())) { 63 | return true; 64 | } 65 | return false; 66 | }); 67 | }; 68 | 69 | if (autolinkExclusions === undefined) { 70 | const stg = await getOptions('local', ['autolink_exclusions']); 71 | autolinkExclusions = stg['autolink_exclusions'] || []; 72 | } 73 | 74 | return testExclusion(autolinkExclusions); 75 | } 76 | 77 | /** 78 | * Handle tab update reports for autolink content script 79 | * @param tabId Tab ID 80 | * @param changeInfo Tab change info 81 | * @param tab Tab data 82 | */ 83 | export function autolinkHandler( 84 | tabId: number, 85 | changeInfo: chrome.tabs.OnUpdatedInfo, 86 | tab: chrome.tabs.Tab 87 | ): void { 88 | autolinkHandlerAsync(tabId, changeInfo, tab).catch((error) => { 89 | logError('Failed to handle autolink request', error); 90 | }); 91 | } 92 | 93 | /** 94 | * Async handler for tab update reports for autolink content script 95 | * @param tabId Tab ID 96 | * @param changeInfo Tab change info 97 | * @param tab Tab data 98 | */ 99 | async function autolinkHandlerAsync( 100 | tabId: number, 101 | changeInfo: chrome.tabs.OnUpdatedInfo, 102 | tab: chrome.tabs.Tab 103 | ): Promise { 104 | if (changeInfo.status !== 'complete') { 105 | return; 106 | } 107 | 108 | const stg = await getOptions('local', ['auto_link']); 109 | if (!stg.auto_link) { 110 | return; 111 | } 112 | 113 | const permissionResult = await checkContentScriptPermissions(); 114 | if (!permissionResult) { 115 | await setOptions('local', {auto_link: false}); 116 | logWarn( 117 | 'Autolink handling is enabled but has insufficient content script permissions, disabling.' 118 | ); 119 | return; 120 | } 121 | 122 | // tab.url requires optional tabs permission, so this needs to come after the check for 123 | // content script permissions. 124 | const exclude = await testAutolinkExclusion(tab.url || ''); 125 | if (exclude) { 126 | return; 127 | } 128 | 129 | // Apply autolink content script 130 | const injectionResults = await chrome.scripting.executeScript({ 131 | target: {tabId: tabId, allFrames: true}, 132 | files: ['autolink.js'], 133 | }); 134 | 135 | if (chrome.runtime.lastError || injectionResults === undefined) { 136 | logInfo(`Autolink failed to run on ${tab.url || ''}`); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/lib/metadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {checkMetaPermissions} from './permissions'; 6 | import {getDefaultOptions} from './options'; 7 | import {isRecord} from './utils'; 8 | import { 9 | MessageCmd, 10 | OffscreenAction, 11 | OffscreenDocMessage, 12 | sendInternalMessageAsync, 13 | } from './messaging'; 14 | import {logError, logInfo} from './logger'; 15 | import {parseTitles} from '../utils'; 16 | 17 | /** 18 | * Fetch the unsanitized title associated with a DOI 19 | * @param doi DOI 20 | */ 21 | async function fetchRawDoiTitle(doi: string): Promise { 22 | const fetchHeaders = new Headers(); 23 | fetchHeaders.append('Accept', 'application/vnd.citationstyles.csl+json'); 24 | 25 | const fetchInit: RequestInit = { 26 | method: 'GET', 27 | headers: fetchHeaders, 28 | cache: 'no-cache', 29 | }; 30 | 31 | const jsonUrl = getDefaultOptions()['doi_resolver'] + doi; 32 | const fetchRequest = new Request(jsonUrl, fetchInit); 33 | 34 | try { 35 | const fetchResponse = await fetch(fetchRequest, {credentials: 'omit'}); 36 | if (!fetchResponse.ok) { 37 | if (fetchResponse.status === 404) { 38 | logInfo(`Title not found for DOI: ${doi}`); 39 | return; 40 | } 41 | throw new Error(`Bad status code: ${fetchResponse.status}`); 42 | } 43 | 44 | const json: unknown = await fetchResponse.json(); 45 | if (!isRecord(json) || typeof json.title !== 'string') { 46 | throw new Error('Invalid JSON response'); 47 | } 48 | 49 | return json.title; 50 | } catch (ex) { 51 | logError(`Title fetch failed for DOI: ${doi}`, ex); 52 | } 53 | } 54 | 55 | /** 56 | * Fetch the titles associated with DOIs 57 | * @param dois DOIs 58 | */ 59 | export async function fetchDoiTitles( 60 | dois: string[] 61 | ): Promise | undefined> { 62 | if (!(await checkMetaPermissions())) { 63 | return; 64 | } 65 | 66 | if (!dois.length) { 67 | return {}; 68 | } 69 | 70 | const uniqueDois = dois.filter((doi, i) => dois.indexOf(doi) === i); 71 | const rawTitles: Record = {}; 72 | 73 | const rawDoiTitlePromise = async (doi: string): Promise => { 74 | rawTitles[doi] = await fetchRawDoiTitle(doi); 75 | const nextDoi = uniqueDois.pop(); 76 | if (nextDoi) { 77 | return rawDoiTitlePromise(nextDoi); 78 | } 79 | }; 80 | 81 | // Allow up to 4 simultaneous title fetches 82 | const rawTitleQueues: Promise[] = []; 83 | for (let i = 0; i < 4; i++) { 84 | const nextDoi = uniqueDois.pop(); 85 | if (nextDoi) { 86 | rawTitleQueues.push(rawDoiTitlePromise(nextDoi)); 87 | } else { 88 | break; 89 | } 90 | } 91 | 92 | await Promise.all(rawTitleQueues); 93 | 94 | const {permissions} = await chrome.permissions.getAll(); 95 | const needsOffscreen = !!permissions?.includes('offscreen'); 96 | return needsOffscreen ? parseTitlesOffscreen(rawTitles) : parseTitles(rawTitles); 97 | } 98 | 99 | /** 100 | * Send raw titles to offscreen page for HTML parsing 101 | * @param rawTitles Raw DOI titles 102 | */ 103 | async function parseTitlesOffscreen(rawTitles: Record) { 104 | try { 105 | await chrome.offscreen.createDocument({ 106 | justification: 'Fetched titles sometimes contain HTML markup that needs to be parsed.', 107 | reasons: [chrome.offscreen.Reason.DOM_PARSER], 108 | url: 'offscreen.html', 109 | }); 110 | 111 | try { 112 | const messageResponse = await sendInternalMessageAsync< 113 | OffscreenDocMessage>, 114 | OffscreenDocMessage> 115 | >({ 116 | cmd: MessageCmd.OffscreenDoc, 117 | data: { 118 | action: OffscreenAction.ParseTitles, 119 | data: rawTitles, 120 | }, 121 | }); 122 | 123 | return messageResponse?.data?.data; 124 | } catch (ex) { 125 | logError('Failed to communicate with offscreen document', ex); 126 | } finally { 127 | await chrome.offscreen.closeDocument(); 128 | // In the event that this method does get called in rapid succession, 129 | // add a delay here in hopes that it will avoid a "doc not closed" error. 130 | await new Promise((resolve) => setTimeout(resolve, 50)); 131 | } 132 | } catch (ex) { 133 | logError('Failed to parse DOI titles', ex); 134 | } 135 | } 136 | 137 | /** 138 | * Fetch the title associated with a DOI 139 | * @param doi DOI 140 | * 141 | * Do not call this function multiple times in rapid succession. Offscreen 142 | * documents do not close fast enough (even when awaited), and an error will 143 | * be thrown if a new offscreen document is requested before the last one is 144 | * completely closed. 145 | */ 146 | export async function fetchDoiTitle(doi: string): Promise { 147 | const titlesRef = await fetchDoiTitles([doi]); 148 | return titlesRef?.[doi]; 149 | } 150 | -------------------------------------------------------------------------------- /src/autolink.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logError, logWarn} from './lib/logger'; 6 | import { 7 | AutolinkVarsMessage, 8 | MessageCmd, 9 | isAutolinkVarsMessage, 10 | sendInternalMessageAsync, 11 | } from './lib/messaging'; 12 | 13 | const definitions = { 14 | // https://stackoverflow.com/questions/27910/finding-a-doi-in-a-document-or-page 15 | findDoi: /\b(10\.[0-9]{4,}(?:\.[0-9]+)*\/(?:(?!["&'<>])\S)+)\b/gi, 16 | findUrl: /^(?:https?:\/\/)(?:dx\.)?doi\.org\/(10\.[0-9]{4,}(?:\.[0-9]+)*\/(?:(?!["&'<>])\S)+)$/i, 17 | rewriteAnchorHref: false, 18 | doiResolver: '', 19 | }; 20 | 21 | // Do not attempt text --> anchor replacement within these elements 22 | const forbiddenTags = ['A', 'BUTTON', 'INPUT', 'SCRIPT', 'SELECT', 'STYLE', 'TEXTAREA']; 23 | 24 | /** 25 | * Send an internal message to request autolink option values from storage 26 | */ 27 | async function setDefinitions(): Promise { 28 | const response = await sendInternalMessageAsync({ 29 | cmd: MessageCmd.AutolinkVars, 30 | }); 31 | 32 | if (!isAutolinkVarsMessage(response) || !response.data) { 33 | throw new Error('Invalid autolink message response'); 34 | } 35 | 36 | definitions.rewriteAnchorHref = response.data.rewriteAnchorHref; 37 | definitions.doiResolver = response.data.doiResolver; 38 | } 39 | 40 | /** 41 | * Determine whether a child node is an anchor element 42 | * @param node HTML child node 43 | */ 44 | function isAnchorElement(node: ChildNode): node is HTMLAnchorElement { 45 | return node.nodeName === 'A'; 46 | } 47 | 48 | /** 49 | * Determine whether a child node is a text node 50 | * @param node HTML child node 51 | */ 52 | function isTextNode(node: ChildNode): node is Text { 53 | return node.nodeType === Node.TEXT_NODE; 54 | } 55 | 56 | /** 57 | * Search document for text DOIs and linked DOIs. Depending on options, 58 | * replace them with custom resolver linked DOIs. 59 | * 60 | * https://stackoverflow.com/questions/1444409/in-javascript-how-can-i-replace-text-in-an-html-page-without-affecting-the-tags 61 | */ 62 | function replaceDOIsWithLinks() { 63 | try { 64 | replaceInElement(document.body, definitions.findDoi, (match) => { 65 | const link = document.createElement('a'); 66 | link.href = definitions.doiResolver + match[0]; 67 | link.appendChild(document.createTextNode(match[0])); 68 | return link; 69 | }); 70 | } catch (ex) { 71 | logError('DOI autolink encountered an exception', ex); 72 | } 73 | } 74 | 75 | /** 76 | * Recursively search element for DOI text and links and replace them as 77 | * they are identified. 78 | * @param node HTML child node 79 | * @param findDoi Regex for DOI matching 80 | * @param replaceText Method to replace matched DOI text with anchor 81 | */ 82 | function replaceInElement( 83 | node: ChildNode, 84 | findDoi: RegExp, 85 | replaceText: (match: RegExpExecArray) => HTMLAnchorElement 86 | ) { 87 | // iterate over child nodes in reverse, as replacement may increase length of child node list. 88 | for (let i = node.childNodes.length - 1; i >= 0; i--) { 89 | const child = node.childNodes[i]; 90 | if (child.nodeType === Node.ELEMENT_NODE) { 91 | if (!forbiddenTags.includes(child.nodeName)) { 92 | replaceInElement(child, findDoi, replaceText); 93 | } else if (definitions.rewriteAnchorHref && isAnchorElement(child)) { 94 | const urlMatch = definitions.findUrl.exec(child.href); 95 | if (urlMatch) { 96 | child.href = definitions.doiResolver + urlMatch[1]; 97 | } 98 | } 99 | } else if (isTextNode(child)) { 100 | replaceInText(child, findDoi, replaceText); 101 | } 102 | } 103 | } 104 | 105 | /** 106 | * Split a text node into text + anchor + text as many times as necessary 107 | * to replace all text DOIs with linked DOIs. 108 | * @param text Text node 109 | * @param findDoi Regex for DOI matching 110 | * @param replaceText Method to replace matched DOI text with anchor 111 | */ 112 | function replaceInText( 113 | text: Text, 114 | findDoi: RegExp, 115 | replaceText: (match: RegExpExecArray) => HTMLAnchorElement 116 | ) { 117 | const parentNode = text.parentNode; 118 | if (!parentNode) { 119 | return; 120 | } 121 | 122 | const matches: RegExpExecArray[] = []; 123 | let match = findDoi.exec(text.data); 124 | while (match !== null) { 125 | matches.push(match); 126 | match = findDoi.exec(text.data); 127 | } 128 | for (let i = matches.length - 1; i >= 0; i--) { 129 | match = matches[i]; 130 | const remainder = text.splitText(match.index); 131 | remainder.splitText(match[0].length); 132 | parentNode.replaceChild(replaceText(match), remainder); 133 | } 134 | } 135 | 136 | (async function () { 137 | await setDefinitions(); 138 | if (definitions.doiResolver) { 139 | replaceDOIsWithLinks(); 140 | } else { 141 | logWarn('DOI autolink disabled because DOI resolver unset'); 142 | } 143 | })().catch((error) => { 144 | logError('DOI autolink could not run', error); 145 | }); 146 | -------------------------------------------------------------------------------- /src/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {doiBrowser} from './global'; 6 | import {log, LogLevel} from './logger'; 7 | 8 | /** 9 | * Get the permissions request object for meta 10 | */ 11 | function getMetaPermissionsReq(): chrome.permissions.Permissions { 12 | const permissionsReq: chrome.permissions.Permissions = { 13 | origins: [ 14 | 'https://*.doi.org/', 15 | 'https://*.crossref.org/', 16 | 'https://*.datacite.org/', 17 | 'https://*.medra.org/', 18 | ], 19 | }; 20 | 21 | if (doiBrowser != 'firefox') { 22 | permissionsReq.permissions = ['offscreen']; 23 | } 24 | 25 | return permissionsReq; 26 | } 27 | 28 | /** 29 | * Get the permissions request object for citations 30 | */ 31 | function getCitationPermissionsReq(): chrome.permissions.Permissions { 32 | const permissionsReq: chrome.permissions.Permissions = { 33 | origins: [ 34 | 'https://*.doi.org/', 35 | 'https://*.crossref.org/', 36 | 'https://*.datacite.org/', 37 | 'https://*.medra.org/', 38 | 'https://raw.githubusercontent.com/', 39 | ], 40 | }; 41 | 42 | if (doiBrowser != 'firefox') { 43 | permissionsReq.permissions = ['offscreen']; 44 | } 45 | 46 | return permissionsReq; 47 | } 48 | 49 | /** 50 | * Get the permissions request object for content scripts 51 | */ 52 | function getContentScriptPermissionsReq(): chrome.permissions.Permissions { 53 | return { 54 | permissions: ['scripting', 'tabs'], 55 | origins: ['http://*/*', 'https://*/*'], 56 | }; 57 | } 58 | 59 | /** 60 | * Request meta permissions. 61 | */ 62 | export function requestMetaPermissions(): Promise { 63 | return chrome.permissions.request(getMetaPermissionsReq()).catch((error) => { 64 | log(LogLevel.Error, error); 65 | return false; 66 | }); 67 | } 68 | 69 | /** 70 | * Check whether meta permissions are allowed. 71 | */ 72 | export function checkMetaPermissions(): Promise { 73 | return chrome.permissions.contains(getMetaPermissionsReq()).catch((error) => { 74 | log(LogLevel.Error, error); 75 | return false; 76 | }); 77 | } 78 | 79 | /** 80 | * Remove meta permissions. 81 | */ 82 | export function removeMetaPermissions(): Promise { 83 | return chrome.permissions.remove(getMetaPermissionsReq()).catch((error) => { 84 | log(LogLevel.Error, error); 85 | return false; 86 | }); 87 | } 88 | 89 | /** 90 | * Request citation permissions. 91 | */ 92 | export function requestCitationPermissions(): Promise { 93 | return chrome.permissions.request(getCitationPermissionsReq()).catch((error) => { 94 | log(LogLevel.Error, error); 95 | return false; 96 | }); 97 | } 98 | 99 | /** 100 | * Check whether citation permissions are allowed. 101 | */ 102 | export function checkCitationPermissions(): Promise { 103 | return chrome.permissions.contains(getCitationPermissionsReq()).catch((error) => { 104 | log(LogLevel.Error, error); 105 | return false; 106 | }); 107 | } 108 | 109 | /** 110 | * Remove citation permissions. 111 | */ 112 | export function removeCitationPermissions(): Promise { 113 | return chrome.permissions.remove(getCitationPermissionsReq()).catch((error) => { 114 | log(LogLevel.Error, error); 115 | return false; 116 | }); 117 | } 118 | 119 | /** 120 | * Request content script permissions. 121 | */ 122 | export function requestContentScriptPermissions(): Promise { 123 | return chrome.permissions.request(getContentScriptPermissionsReq()).catch((error) => { 124 | log(LogLevel.Error, error); 125 | return false; 126 | }); 127 | } 128 | 129 | /** 130 | * Check whether content script permissions are allowed. 131 | */ 132 | export function checkContentScriptPermissions(): Promise { 133 | return chrome.permissions.contains(getContentScriptPermissionsReq()).catch((error) => { 134 | log(LogLevel.Error, error); 135 | return false; 136 | }); 137 | } 138 | 139 | /** 140 | * Remove content script permissions. 141 | */ 142 | export function removeContentScriptPermissions(): Promise { 143 | return chrome.permissions.remove(getContentScriptPermissionsReq()).catch((error) => { 144 | log(LogLevel.Error, error); 145 | return false; 146 | }); 147 | } 148 | 149 | /** 150 | * Remove origin permissions once they are not needed. 151 | * @param removeWildcardOrigins Whether wildcard origins http://* and 152 | * https://* should also be removed. 153 | */ 154 | export function cleanupOriginPermissions(removeWildcardOrigins: boolean): Promise { 155 | const origins = [ 156 | 'https://*.doi.org/', 157 | 'https://*.crossref.org/', 158 | 'https://*.datacite.org/', 159 | 'https://*.medra.org/', 160 | 'https://raw.githubusercontent.com/', 161 | ]; 162 | 163 | if (removeWildcardOrigins) { 164 | origins.push('http://*/*'); 165 | origins.push('https://*/*'); 166 | } 167 | 168 | return chrome.permissions.remove({origins}).catch((error) => { 169 | log(LogLevel.Error, error); 170 | return false; 171 | }); 172 | } 173 | -------------------------------------------------------------------------------- /src/citation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 24 |

25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 | 43 |
44 | 61 |
62 |
63 |
64 | 65 |
66 |
67 | 68 |
69 |
70 |
71 |
72 | 73 |
74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 | 88 |
89 |
90 |
91 | 92 | 93 |
94 |
95 | 96 | 97 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /src/lib/context_menu.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {checkContentScriptPermissions} from './permissions'; 6 | import {resolveDoi} from './resolve'; 7 | import {getOptions, setOptions} from './options'; 8 | import {showNotification} from './notification'; 9 | import {logError, logInfo, logWarn} from './logger'; 10 | import {queueRecordDoi} from './history'; 11 | import {findDoiInString} from './utils'; 12 | 13 | export enum ContextMenuId { 14 | ResolveDoi = 'resolve_doi', 15 | } 16 | 17 | /** 18 | * Create the context menu feature 19 | * @param id Context menu ID 20 | */ 21 | export function createContextMenu(id: ContextMenuId): void { 22 | if (id !== ContextMenuId.ResolveDoi) { 23 | // TODO: Support multiple context menu items 24 | return; 25 | } 26 | 27 | chrome.contextMenus.create({ 28 | id, 29 | title: chrome.i18n.getMessage('contextText'), 30 | type: 'normal', 31 | contexts: ['selection'], 32 | visible: false, 33 | }); 34 | } 35 | 36 | /** 37 | * Remove the context menu feature 38 | * @param id Context menu ID 39 | * 40 | * This should not be used for visibility changes. Note that .remove() is 41 | * synchronous for Chrome and asynchronous for Firefox, but callback has been 42 | * verified to run in both browsers. Cast to promise. 43 | */ 44 | export function removeContextMenu(id: ContextMenuId): Promise { 45 | if (id !== ContextMenuId.ResolveDoi) { 46 | // TODO: Support multiple context menu items 47 | return Promise.resolve(); 48 | } 49 | 50 | return Promise.resolve( 51 | chrome.contextMenus.remove(id, () => { 52 | if (chrome.runtime.lastError) { 53 | /* do nothing */ 54 | } 55 | }) 56 | ).catch(() => { 57 | // do nothing 58 | }); 59 | } 60 | 61 | /** 62 | * Show/hide the context menu feature 63 | * @param id Context menu ID 64 | * @param visible Whether the context menu item should be visible 65 | * @param doi DOI if available 66 | */ 67 | export function updateContextMenu(id: ContextMenuId, visible: boolean, doi?: string): void { 68 | if (id !== ContextMenuId.ResolveDoi) { 69 | // TODO: Support multiple context menu items 70 | return; 71 | } 72 | 73 | const resolveDoiText = chrome.i18n.getMessage('contextText'); 74 | chrome.contextMenus 75 | .update(id, { 76 | title: doi ? `${resolveDoiText} "${doi}"` : resolveDoiText, 77 | visible, 78 | }) 79 | .catch((error) => { 80 | logError('Failed to update context menus', error); 81 | }); 82 | } 83 | 84 | /** 85 | * Handle the context menu feature click 86 | * @param info OnClick event data 87 | */ 88 | export function contextMenuHandler(info: chrome.contextMenus.OnClickData): void { 89 | contextMenuHandlerAsync(info).catch((error) => { 90 | logError('Failed to handle context menu click', error); 91 | }); 92 | } 93 | 94 | /** 95 | * Async handling for the context menu feature click 96 | * @param info OnClick event data 97 | */ 98 | async function contextMenuHandlerAsync(info: chrome.contextMenus.OnClickData): Promise { 99 | if (info.menuItemId !== ContextMenuId.ResolveDoi) { 100 | // TODO: Support multiple context menu items 101 | return; 102 | } 103 | 104 | const doi = findDoiInString(info.selectionText || ''); 105 | if (!doi) { 106 | const notificationTitle = chrome.i18n.getMessage('invalidDoiTitle'); 107 | const notificationMessage = chrome.i18n.getMessage('invalidDoiAlert'); 108 | showNotification(notificationTitle, notificationMessage); 109 | return; 110 | } 111 | 112 | // This action doesn't qualify as a user gesture, so we can't request meta 113 | // permissions (i.e. re-establish them for free if they were removed). If 114 | // the permissions happen to be available, they will be used automatically 115 | // by during history update. 116 | await queueRecordDoi(doi); 117 | 118 | const stg = await getOptions('local', ['custom_resolver', 'cr_context']); 119 | const cr = stg.custom_resolver; 120 | const crc = stg.cr_context; 121 | await resolveDoi(doi, cr === true && crc === 'custom', 'newForegroundTab'); 122 | } 123 | 124 | /** 125 | * Handle tab update reports for context menu match content script 126 | * @param tabId Tab ID 127 | * @param changeInfo Tab change info 128 | * @param tab Tab data 129 | */ 130 | export function contextMenuMatchHandler( 131 | tabId: number, 132 | changeInfo: chrome.tabs.OnUpdatedInfo, 133 | tab: chrome.tabs.Tab 134 | ): void { 135 | contextMenuMatchHandlerAsync(tabId, changeInfo, tab).catch((error) => { 136 | logError('Failed to run context menu match handler', error); 137 | }); 138 | } 139 | 140 | /** 141 | * Handle tab update reports for context menu match content script 142 | * @param tabId Tab ID 143 | * @param changeInfo Tab change info 144 | * @param tab Tab data 145 | */ 146 | async function contextMenuMatchHandlerAsync( 147 | tabId: number, 148 | changeInfo: chrome.tabs.OnUpdatedInfo, 149 | tab: chrome.tabs.Tab 150 | ): Promise { 151 | if (changeInfo.status !== 'complete') { 152 | return; 153 | } 154 | 155 | const stg = await getOptions('local', ['context_menu_match']); 156 | if (!stg.context_menu_match) { 157 | return; 158 | } 159 | 160 | const permissionResult = await checkContentScriptPermissions(); 161 | if (!permissionResult) { 162 | await setOptions('local', {context_menu_match: false}); 163 | logWarn( 164 | 'Context menu match handling is enabled but has insufficient content script permissions, disabling.' 165 | ); 166 | return; 167 | } 168 | 169 | // tab.url requires optional tabs permission, so this needs to come after the check for 170 | // content script permissions. 171 | if ( 172 | !/^https?:\/\//i.test(tab.url || '') || 173 | /^https:?\/\/chrome\.google\.com\/webstore[/$]/i.test(tab.url || '') 174 | ) { 175 | return; 176 | } 177 | 178 | // Apply context menu match content script 179 | const injectionResults = await chrome.scripting.executeScript({ 180 | target: {tabId: tabId, allFrames: true}, 181 | files: ['context_match.js'], 182 | }); 183 | 184 | if (chrome.runtime.lastError || injectionResults === undefined) { 185 | logInfo(`Context menu match listener failed to run on ${tab.url || ''}`); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/lib/history.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {logWarn} from './logger'; 6 | import {fetchDoiTitles} from './metadata'; 7 | import {getOptions, setOptions, getDefaultOptions, HistoryDoi} from './options'; 8 | 9 | /** 10 | * Get the title of a DOI stored in history 11 | * @param doi DOI 12 | */ 13 | export async function getSavedDoiTitle(doi: string): Promise { 14 | const stg = await getOptions('local', ['history', 'recorded_dois']); 15 | if (!stg.history || !stg.recorded_dois) { 16 | return; 17 | } 18 | 19 | const entry = stg.recorded_dois.find((item) => item.doi === doi); 20 | return entry?.title; 21 | } 22 | 23 | /** 24 | * Queue multiple DOIs to record in history (if feature is enabled) 25 | * @param dois DOIs 26 | */ 27 | export async function queueRecordDois(dois: string[]): Promise { 28 | const stg = await getOptions('local', ['history', 'history_doi_queue']); 29 | if (stg.history) { 30 | if (stg.history_doi_queue) { 31 | stg.history_doi_queue.push(...dois); 32 | } else { 33 | stg.history_doi_queue = [...dois]; 34 | } 35 | delete stg.history; 36 | await setOptions('local', stg); 37 | } 38 | } 39 | 40 | /** 41 | * Queue a DOI to record in history (if feature is enabled) 42 | * @param doi DOI 43 | */ 44 | export async function queueRecordDoi(doi: string): Promise { 45 | await queueRecordDois([doi]); 46 | } 47 | 48 | /** 49 | * Record multiple DOIs in history 50 | * @param entries History DOI entries 51 | * @param allowFetch Whether to allow automatically fetching the title (if 52 | * needed and feature is enabled) 53 | */ 54 | export async function recordDois(entries: HistoryDoi[], allowFetch = true): Promise { 55 | const stg = await getOptions('local', [ 56 | 'history', 57 | 'history_length', 58 | 'history_fetch_title', 59 | 'recorded_dois', 60 | ]); 61 | if (!stg.history) { 62 | return; 63 | } 64 | 65 | const defaultOptions = getDefaultOptions(); 66 | if (!stg.recorded_dois) { 67 | stg.recorded_dois = defaultOptions['recorded_dois']; 68 | } 69 | if (!stg.history_length || stg.history_length < 1) { 70 | stg.history_length = defaultOptions['history_length']; 71 | } 72 | 73 | // Remove holes from the storage array (should not occur) 74 | stg.recorded_dois = stg.recorded_dois.filter(Boolean); 75 | 76 | // Deduplicate new DOIs 77 | entries = entries.filter((x, i) => entries.findIndex((y) => x.doi === y.doi) === i); 78 | 79 | // The number of recorded entries may exceed the history length if 80 | // the user has saved N entries and later sets the history length to 81 | // less than N. Check whether the number can be whittled down. 82 | while (stg.recorded_dois.length > stg.history_length) { 83 | // Do not remove saved entries 84 | const unsavedIndex = stg.recorded_dois.findIndex((entry) => !entry.save); 85 | if (unsavedIndex < 0) { 86 | logWarn('Number of saved DOIs exceeds history length option, aborting.'); 87 | return; 88 | } 89 | stg.recorded_dois.splice(unsavedIndex, 1); 90 | } 91 | 92 | // Start by recording the DOIs with their provided titles 93 | const titleFetchDois: string[] = []; 94 | let stgModified = false; 95 | for (const {doi, title, save} of entries) { 96 | // Check whether we already have the DOI and if so, maybe update the title. 97 | // Do not revise the save status here. 98 | const recordedEntry = stg.recorded_dois.find((item) => item.doi === doi); 99 | if (recordedEntry) { 100 | if (title && recordedEntry.title !== title) { 101 | recordedEntry.title = title; 102 | stgModified = true; 103 | } 104 | continue; 105 | } 106 | 107 | // Do not exceed the length of history. Remove the oldest unsaved entry and 108 | // push the new entry. Note that if the import list is long and the max 109 | // history length is short, this will end up pushing out all of the old 110 | // unsaved entries and could even start pushing out the early entries in 111 | // this import list, but this is working as intended and is not expensive 112 | // because we don't save nor perform title lookups until the loop is done. 113 | if (stg.recorded_dois.length === stg.history_length) { 114 | // Do not remove saved entries 115 | const unsavedIndex = stg.recorded_dois.findIndex((entry) => !entry.save); 116 | if (unsavedIndex < 0) { 117 | continue; 118 | } 119 | stg.recorded_dois.splice(unsavedIndex, 1); 120 | } 121 | 122 | // Push the new entry and add to the list of DOIs for which titles should 123 | // be fetched, if necessary. 124 | stg.recorded_dois.push({doi, save, title}); 125 | if (!title) { 126 | titleFetchDois.push(doi); 127 | } 128 | stgModified = true; 129 | } 130 | 131 | if (!stgModified) { 132 | return; 133 | } 134 | await setOptions('local', {recorded_dois: stg.recorded_dois}); 135 | 136 | // If title fetch is not enabled, we're done. 137 | if (!allowFetch || !stg.history_fetch_title) { 138 | return; 139 | } 140 | 141 | // Fetch titles for the new DOIs 142 | stgModified = false; 143 | const fetchedTitles = await fetchDoiTitles(titleFetchDois); 144 | for (const doi of titleFetchDois) { 145 | const recordedEntry = stg.recorded_dois.find((item) => item.doi === doi); 146 | const title = fetchedTitles?.[doi]; 147 | if (recordedEntry && title) { 148 | recordedEntry.title = title; 149 | stgModified = true; 150 | } 151 | } 152 | if (stgModified) { 153 | await setOptions('local', {recorded_dois: stg.recorded_dois}); 154 | } 155 | } 156 | 157 | /** 158 | * Record a DOI in history 159 | * @param entry History DOI entry 160 | * @param allowFetch Whether to allow automatically fetching the title (if 161 | * needed and feature is enabled) 162 | * 163 | * Do not call this function multiple times in rapid succession. Offscreen 164 | * documents do not close fast enough (even when awaited), and an error will 165 | * be thrown if a new offscreen document is requested before the last one is 166 | * completely closed. 167 | */ 168 | export async function recordDoi(entry: HistoryDoi, allowFetch = true): Promise { 169 | return recordDois([entry], allowFetch); 170 | } 171 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {HistoryDoi, HistorySort, getDefaultOptions} from './options'; 6 | 7 | /** 8 | * Determine whether a value is a string. 9 | * @param val Candidate value 10 | */ 11 | export function isString(val: unknown): val is string { 12 | return typeof val === 'string'; 13 | } 14 | 15 | /** 16 | * Determine whether a value is a record (excluding functions, arrays, and null). 17 | * @param val Candidate value 18 | */ 19 | export function isRecord(val: unknown): val is Record { 20 | return typeof val === 'object' && !!val && !Array.isArray(val); 21 | } 22 | 23 | /** 24 | * Get typed object keys. 25 | * @param obj Object with known key types 26 | */ 27 | export function getTypedKeys(obj: T): (keyof T)[] { 28 | return Object.keys(obj) as (keyof typeof obj)[]; 29 | } 30 | 31 | /** 32 | * Remove spaces punctuation from beginning and end of DOI input. 33 | * Also removes "doi:" prefix. 34 | * @param doiStr DOI string 35 | */ 36 | export function trimDoi(doiStr: string): string { 37 | return doiStr.replace(/^\s*doi:?|\s+|[^A-Z0-9)>]+$/gi, ''); 38 | } 39 | 40 | /** 41 | * Perform a basic sanity test that a string appears to be a DOI. 42 | * @param doiStr DOI string 43 | */ 44 | export function isValidDoi(doiStr: string): boolean { 45 | return /^10[./]/.test(doiStr); 46 | } 47 | 48 | /** 49 | * Escape characters that could be identified as HTML markup 50 | * @param unsafe Unsafe string 51 | */ 52 | export function escapeHtml(unsafe: string): string { 53 | return unsafe 54 | .replace(/&/g, '&') 55 | .replace(//g, '>') 57 | .replace(/"/g, '"') 58 | .replace(/'/g, '''); 59 | } 60 | 61 | /** 62 | * Determine whether a string is a valid hex color 63 | * @param str Candidate string 64 | */ 65 | export function isHexColor(str: string): boolean { 66 | return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(str); 67 | } 68 | 69 | /** 70 | * Debounce a function 71 | * @param func Callback 72 | * @param wait Milliseconds to wait 73 | */ 74 | export function debounce void>( 75 | func: T, 76 | wait: number 77 | ): (...args: Parameters) => void { 78 | let timeout: ReturnType; 79 | return (...args: Parameters): void => { 80 | clearTimeout(timeout); 81 | timeout = setTimeout(() => { 82 | func(...args); 83 | }, wait); 84 | }; 85 | } 86 | 87 | /** 88 | * Sort recorded DOIs 89 | * @param entries Recorded DOIs 90 | * @param method Sort method 91 | */ 92 | export function sortHistoryEntries(entries: HistoryDoi[], method?: HistorySort): void { 93 | const doiCompare = (a: HistoryDoi, b: HistoryDoi) => { 94 | if (a.doi.toLowerCase() < b.doi.toLowerCase()) return -1; 95 | if (a.doi.toLowerCase() > b.doi.toLowerCase()) return 1; 96 | return 0; 97 | }; 98 | const titleCompare = (a: HistoryDoi, b: HistoryDoi) => { 99 | // Sort blank titles at end of list 100 | if (!a.title && b.title) return 1; 101 | if (a.title && !b.title) return -1; 102 | 103 | if (a.title.toLowerCase() < b.title.toLowerCase()) return -1; 104 | if (a.title.toLowerCase() > b.title.toLowerCase()) return 1; 105 | return 0; 106 | }; 107 | const saveCompare = (a: HistoryDoi, b: HistoryDoi) => { 108 | if (a.save && !b.save) return -1; 109 | if (!a.save && b.save) return 1; 110 | return 0; 111 | }; 112 | 113 | if (!method) { 114 | method = getDefaultOptions()['history_sortby']; 115 | } 116 | 117 | switch (method) { 118 | case HistorySort.Doi: 119 | entries.sort(doiCompare); 120 | break; 121 | case HistorySort.Title: 122 | entries.sort(titleCompare); 123 | break; 124 | case HistorySort.Save: 125 | entries.reverse(); 126 | entries.sort(saveCompare); 127 | break; 128 | case HistorySort.Date: 129 | entries.reverse(); 130 | break; 131 | default: 132 | break; 133 | } 134 | } 135 | 136 | /** 137 | * Only display in a 17 |
18 | 19 |
20 |
21 | 22 |
23 | 24 | 29 | 60 | 95 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /src/lib/messaging.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {sendAutolinkOptions} from './autolink'; 6 | import {ContextMenuId, updateContextMenu} from './context_menu'; 7 | import {isLogLevel, log, logError, LogLevel} from './logger'; 8 | import {StorageOptions, isStorageOptions, getOptions} from './options'; 9 | import {isRecord} from './utils'; 10 | 11 | /** 12 | * Send an internal message between different parts of the extension 13 | * @param message JSON request object 14 | * sent by the handler of the message 15 | */ 16 | export function sendInternalMessage>(message: T): void { 17 | sendInternalMessageAsync(message).catch((error) => { 18 | logError('Failed to send message', error); 19 | }); 20 | } 21 | 22 | /** 23 | * Send an internal message between different parts of the extension 24 | * @param message JSON request object 25 | * sent by the handler of the message 26 | */ 27 | export async function sendInternalMessageAsync, R>( 28 | message: T 29 | ): Promise { 30 | // Debugging 31 | // logInfo(`Sending message: ${message.cmd}\n`, message); 32 | 33 | // Avoid "context invalidated" errors when extension updates but content 34 | // scripts on page have not been reloaded. 35 | if (!chrome.runtime?.id) { 36 | return; 37 | } 38 | 39 | return chrome.runtime.sendMessage(message).catch((error) => { 40 | // There will not be a message receiver if content scripts are disabled 41 | // and no extension pages are open (aside from the sender page). 42 | if ( 43 | isRecord(error) && 44 | typeof error.message === 'string' && 45 | error.message.includes('Receiving end does not exist.') 46 | ) { 47 | return; 48 | } 49 | 50 | logError('Failed to send message', error); 51 | }); 52 | } 53 | 54 | export enum MessageCmd { 55 | AutolinkVars = 'autolink_vars', 56 | ContextMenuToggle = 'context_menu_toggle', 57 | Logging = 'logging', 58 | OffscreenDoc = 'offscreen_doc', 59 | SettingsUpdated = 'settings_updated', 60 | } 61 | 62 | export enum OffscreenAction { 63 | ParseTitles = 'parse_titles', 64 | } 65 | 66 | export interface InternalMessage { 67 | cmd: MessageCmd; 68 | data?: T; 69 | } 70 | 71 | export interface AutolinkVarsResponse { 72 | doiResolver: string; 73 | rewriteAnchorHref: boolean; 74 | } 75 | 76 | export interface ContextMenuToggle { 77 | enable: boolean; 78 | doi?: string; 79 | } 80 | 81 | export interface LogData { 82 | level: LogLevel; 83 | data: unknown[]; 84 | } 85 | 86 | export interface OffscreenDoc { 87 | action: OffscreenAction; 88 | data: T; 89 | } 90 | 91 | export interface SettingsUpdated { 92 | options: StorageOptions; 93 | forceUpdate: boolean; 94 | } 95 | 96 | export interface AutolinkVarsMessage extends InternalMessage { 97 | cmd: MessageCmd.AutolinkVars; 98 | } 99 | 100 | export interface ContextMenuToggleMessage extends InternalMessage { 101 | cmd: MessageCmd.ContextMenuToggle; 102 | } 103 | 104 | export interface LoggingMessage extends InternalMessage { 105 | cmd: MessageCmd.Logging; 106 | } 107 | 108 | export interface OffscreenDocMessage extends InternalMessage> { 109 | cmd: MessageCmd.OffscreenDoc; 110 | } 111 | 112 | export interface SettingsUpdatedMessage extends InternalMessage { 113 | cmd: MessageCmd.SettingsUpdated; 114 | } 115 | 116 | /** 117 | * Verify a message object is an autolink vars message 118 | * @param message Unverified message 119 | */ 120 | export function isAutolinkVarsMessage(message: unknown): message is AutolinkVarsMessage { 121 | return ( 122 | isRecord(message) && 123 | message.cmd === MessageCmd.AutolinkVars && 124 | (message.data === undefined || 125 | (isRecord(message.data) && 126 | typeof message.data.doiResolver === 'string' && 127 | typeof message.data.rewriteAnchorHref === 'boolean')) 128 | ); 129 | } 130 | 131 | /** 132 | * Verify a message object is a context menu toggled message 133 | * @param message Unverified message 134 | */ 135 | export function isContextMenuToggleMessage(message: unknown): message is ContextMenuToggleMessage { 136 | return ( 137 | isRecord(message) && 138 | message.cmd === MessageCmd.ContextMenuToggle && 139 | isRecord(message.data) && 140 | typeof message.data.enable === 'boolean' && 141 | (typeof message.data.doi === 'string' || message.data.doi === undefined) 142 | ); 143 | } 144 | 145 | /** 146 | * Verify a message object is a logging message 147 | * @param message Unverified message 148 | */ 149 | export function isLoggingMessage(message: unknown): message is LoggingMessage { 150 | return ( 151 | isRecord(message) && 152 | message.cmd === MessageCmd.Logging && 153 | isRecord(message.data) && 154 | isLogLevel(message.data.level) && 155 | Array.isArray(message.data.data) 156 | ); 157 | } 158 | 159 | /** 160 | * Verify a message object is an offscreen document message 161 | * @param message Unverified message 162 | */ 163 | export function isOffscreenDocMessage(message: unknown): message is OffscreenDocMessage { 164 | return ( 165 | isRecord(message) && 166 | message.cmd === MessageCmd.OffscreenDoc && 167 | isRecord(message.data) && 168 | isOffscreenAction(message.data.action) 169 | ); 170 | } 171 | 172 | /** 173 | * Verify an item is an instance of OffscreenAction 174 | * @param val Unverified item 175 | */ 176 | export function isOffscreenAction(val: unknown): val is OffscreenAction { 177 | return typeof val === 'string' && Object.values(OffscreenAction).includes(val); 178 | } 179 | 180 | /** 181 | * Verify a message object is a context menu toggled message 182 | * @param message Unverified message 183 | */ 184 | export function isSettingsUpdatedMessage(message: unknown): message is SettingsUpdatedMessage { 185 | return ( 186 | isRecord(message) && 187 | message.cmd === MessageCmd.SettingsUpdated && 188 | isRecord(message.data) && 189 | typeof message.data.forceUpdate === 'boolean' && 190 | isStorageOptions(message.data.options) 191 | ); 192 | } 193 | 194 | /** 195 | * Verify a message object is an internal message 196 | * @param message Unverified message 197 | */ 198 | export function isInternalMessage(message: unknown): message is InternalMessage { 199 | return ( 200 | isRecord(message) && 201 | typeof message.cmd === 'string' && 202 | Object.values(MessageCmd).includes(message.cmd) 203 | ); 204 | } 205 | 206 | /** 207 | * Handle runtime messages 208 | * @param message Internal message 209 | * @param sender Message sender 210 | * @param sendResponse Response callback 211 | */ 212 | export function runtimeMessageHandler( 213 | message: unknown, 214 | sender: chrome.runtime.MessageSender, 215 | sendResponse: (response?: unknown) => void 216 | ): boolean | void { 217 | if (!isInternalMessage(message)) { 218 | return; 219 | } 220 | 221 | switch (message.cmd) { 222 | case MessageCmd.AutolinkVars: 223 | if (isAutolinkVarsMessage(message)) { 224 | sendAutolinkOptions(sendResponse).catch((error) => { 225 | logError('Failed to send autolink variables', error); 226 | }); 227 | return true; // Required to allow async sendResponse 228 | } 229 | break; 230 | case MessageCmd.ContextMenuToggle: 231 | if (isContextMenuToggleMessage(message)) { 232 | // If context menu match has just been disabled, then any open tabs 233 | // where the content script is still running will continue to send 234 | // messages (e.g. when switching that tab back into focus). Make sure 235 | // updates are only applied if the feature is active. 236 | getOptions('local', ['context_menu_match']) 237 | .then((stg) => { 238 | if (stg.context_menu_match) { 239 | updateContextMenu( 240 | ContextMenuId.ResolveDoi, 241 | !!message.data?.enable, 242 | message.data?.doi 243 | ); 244 | } 245 | }) 246 | .catch((error) => { 247 | logError('Failed to check context menu match status', error); 248 | }); 249 | } 250 | break; 251 | case MessageCmd.Logging: 252 | if (isLoggingMessage(message) && message.data !== undefined) { 253 | log(message.data.level, ...message.data.data); 254 | } 255 | break; 256 | default: 257 | break; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/qr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 24 |

25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | 36 |
37 |
38 | 46 |
47 | 64 |
65 |
66 |
67 | 68 |
69 |
70 |
71 |
72 | 73 |
74 |
75 | px 76 |
77 |
78 |
79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 | 88 |
89 |
90 | module(s) 91 |
92 |
93 |
94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 | 108 | 111 |
112 |
113 | 120 | 123 |
124 |
125 |
126 |
127 |
128 |
129 | 130 | 131 |
132 |
133 |
134 |
135 |
136 |
137 | 138 | 143 |
144 |
145 |
146 | 158 |
159 |
160 |
161 | 162 | 167 |
168 |
169 |
170 |
171 |
172 |
173 | 174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 | 183 |
184 |
185 |
186 | 191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 | 204 |
205 |
206 |
207 | 212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 | 226 | 227 |
228 |
229 | 230 | 231 | 263 | 264 | 265 | 272 | 273 | 274 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import {processHistoryDoiQueue, resetContextMenu} from './background'; 6 | import {logError, logInfo} from './logger'; 7 | import {MessageCmd, SettingsUpdatedMessage, sendInternalMessageAsync} from './messaging'; 8 | import { 9 | StorageOptions, 10 | getAllOptionNames, 11 | getAllSyncOptionNames, 12 | getDefaultOptions, 13 | getDeprecatedOptionNames, 14 | getForceRefreshOptionNames, 15 | getOptions, 16 | removeOptions, 17 | setOptions, 18 | toStorageOptions, 19 | } from './options'; 20 | import {getTypedKeys} from './utils'; 21 | import deepEqual from 'fast-deep-equal/es6'; 22 | 23 | // CAUTION: Each entry point file gets its own copy of this listener status 24 | // instance. So, for example, if setOptions() were to incorporate it, the 25 | // service worker and options page would reference independent instances and 26 | // ultimately, the storage change handler in the service worker would not be 27 | // aware that the options page requested that the change handler be temporarily 28 | // disabled. So long as the use of this instance is restricted to the service 29 | // worker only, it will work as intended. If it becomes necessary to expand the 30 | // scope of this listener status to all entry point files, then refer to version 31 | // 6.0.0 of this extension when setting 'storage_listener_disabled' was used. 32 | const {storageListenerStatus} = new (class { 33 | private status: boolean = true; 34 | private skips: number = 0; 35 | private skipTimeouts: ReturnType[] = []; 36 | 37 | /** 38 | * Get/Set the flag indicating the storage listener status 39 | * @param options Options to update the status of the storage listener or undefined to retrieve the status 40 | * @param options.enable Status to set for the storage listener 41 | * @param options.skip Whether the next status check should be skipped 42 | */ 43 | public storageListenerStatus = (options?: {enable?: boolean; skip?: boolean}): boolean => { 44 | if (!options) { 45 | if (this.skips > 0) { 46 | clearTimeout(this.skipTimeouts.pop()); 47 | this.skips -= 1; 48 | // Debugging 49 | // logInfo( 50 | // `Cleared skip timeout and decremented skips to: ${this.skips} (timeouts remaining: [${this.skipTimeouts.toString()}])` 51 | // ); 52 | return false; 53 | } 54 | return this.status; 55 | } 56 | 57 | const {enable, skip} = options; 58 | if (enable !== undefined) { 59 | this.status = enable; 60 | } 61 | if (skip !== undefined) { 62 | this.skips += 1; 63 | // Debugging 64 | // logInfo('Incremented skips to: ' + this.skips); 65 | this.skipTimeouts.push( 66 | setTimeout(() => { 67 | this.skipTimeouts.pop(); 68 | if (this.skips > 0) { 69 | this.skips -= 1; 70 | } 71 | // Debugging 72 | // logInfo( 73 | // `Skip timeout reached and decremented skips to: ${this.skips} (timeouts remaining: [${this.skipTimeouts.toString()}])` 74 | // ); 75 | }, 100) 76 | ); 77 | } 78 | 79 | return this.status; 80 | }; 81 | })(); 82 | 83 | /** 84 | * Check for new options when the extension is updated 85 | * and update storage with their default values. 86 | */ 87 | export async function checkForNewOptions(): Promise { 88 | const deprecatedOptionNames = getDeprecatedOptionNames(); 89 | const optionNames = getAllOptionNames().filter((name) => !deprecatedOptionNames.includes(name)); 90 | const stg = await getOptions('local', optionNames); 91 | 92 | const newOptions: Record = {}; 93 | let updateSettings = false; 94 | const defaultOptions = getDefaultOptions(); 95 | optionNames.forEach((option) => { 96 | if (stg[option] === undefined) { 97 | newOptions[option] = defaultOptions[option]; 98 | updateSettings = true; 99 | } 100 | }); 101 | 102 | // Decimal history lengths were allowed to be saved in earlier versions of the extension. This fixes those values. 103 | if (stg.history_length !== undefined) { 104 | const floor = Math.floor(stg.history_length); 105 | if (isNaN(floor)) { 106 | newOptions.history_length = defaultOptions.history_length; 107 | updateSettings = true; 108 | } else if (stg.history_length !== floor) { 109 | newOptions.history_length = floor; 110 | updateSettings = true; 111 | } 112 | } 113 | 114 | if (updateSettings) { 115 | await setOptions('local', newOptions); 116 | } 117 | } 118 | 119 | /** 120 | * Remove deprecated options from storage 121 | */ 122 | export async function removeDeprecatedOptions(): Promise { 123 | const deprecatedOptions = getDeprecatedOptionNames(); 124 | await removeOptions('local', deprecatedOptions); 125 | await removeOptions('sync', deprecatedOptions); 126 | } 127 | 128 | /** 129 | * Check whether all sync storageoptions have been cleared, in which case 130 | * sync should be disabled. 131 | */ 132 | export async function verifySyncState(): Promise { 133 | let syncEnabled = (await getOptions('local', ['sync_data']))['sync_data'] ?? false; 134 | if (syncEnabled) { 135 | const stg = await getOptions('sync'); 136 | if (!Object.keys(stg).length) { 137 | logInfo('Sync settings cleared, disabling settings synchronization'); 138 | storageListenerStatus({skip: true}); 139 | await setOptions('local', {sync_data: false}); 140 | syncEnabled = false; 141 | 142 | // If the options page is open, let it know sync should be unchecked. 143 | await sendInternalMessageAsync({ 144 | cmd: MessageCmd.SettingsUpdated, 145 | data: {options: {sync_data: false}, forceUpdate: true}, 146 | }); 147 | } 148 | } 149 | 150 | return syncEnabled; 151 | } 152 | 153 | /** 154 | * Enable sync feature 155 | */ 156 | async function enableSync(): Promise { 157 | // Sync was just toggled 'on', so let sync storage options overwrite 158 | // local storage options, so long as they are defined. If an option 159 | // is not defined in sync storage, copy it from local storage, which 160 | // is guaranteed to exist since checkForNewOptions() runs on install 161 | // and update. 162 | 163 | const toLocal: Record = {}; 164 | const toSync: Record = {}; 165 | 166 | const [stgLocal, stgSync] = await Promise.all([getOptions('local'), getOptions('sync')]); 167 | for (const option of getAllSyncOptionNames()) { 168 | if (stgSync[option] !== undefined) { 169 | toLocal[option] = stgSync[option]; 170 | } else { 171 | toSync[option] = stgLocal[option]; 172 | } 173 | } 174 | 175 | const toSyncOptions = toStorageOptions(toSync); 176 | if (Object.keys(toSyncOptions).length) { 177 | // Debugging 178 | // logInfo('.. toSync: ', toSyncOptions); 179 | storageListenerStatus({skip: true}); 180 | await setOptions('sync', toSyncOptions); 181 | } 182 | 183 | const toLocalOptions = toStorageOptions(toLocal); 184 | if (Object.keys(toLocalOptions).length) { 185 | // Debugging 186 | // logInfo('.. toLocal: ', toLocalOptions); 187 | storageListenerStatus({skip: true}); 188 | await setOptions('local', toLocalOptions); 189 | } 190 | 191 | await sendInternalMessageAsync({ 192 | cmd: MessageCmd.SettingsUpdated, 193 | data: { 194 | options: toLocalOptions, 195 | forceUpdate: true, 196 | }, 197 | }); 198 | } 199 | 200 | /** 201 | * Handle changes reported by the storage listener 202 | * @param changes Storage changes 203 | * @param area Storage area 204 | */ 205 | export function storageChangeHandler( 206 | changes: Record, 207 | area: chrome.storage.AreaName 208 | ): void { 209 | storageChangeHandlerAsync(changes, area).catch((error) => { 210 | logError('Failed to handle storage changes', error); 211 | }); 212 | } 213 | 214 | /** 215 | * Async handler for changes reported by the storage listener 216 | * @param changes Storage changes 217 | * @param area Storage area 218 | */ 219 | async function storageChangeHandlerAsync( 220 | changes: Record, 221 | area: chrome.storage.AreaName 222 | ): Promise { 223 | if (!storageListenerStatus()) { 224 | // Debugging 225 | // logInfo(`Storage listener disabled, skipping changes in ${area}.`); 226 | return; 227 | } 228 | 229 | // Firefox does not filter change reports to values that actually changed 230 | const filteredChanges = Object.entries(changes).reduce((result, [key, chg]) => { 231 | if (chg && !deepEqual(chg.newValue, chg.oldValue)) { 232 | result[key] = chg; 233 | } 234 | return result; 235 | }, {}); 236 | 237 | if (!Object.keys(filteredChanges).length) { 238 | return; 239 | } 240 | 241 | // Debugging 242 | // logInfo(`Change in ${area} storage\n`, filteredChanges); 243 | logInfo(`Processing changes in ${area} storage`); 244 | 245 | const updatedOptions = toStorageOptions( 246 | Object.keys(filteredChanges).reduce>((result, key) => { 247 | result[key] = filteredChanges[key]?.newValue; 248 | return result; 249 | }, {}) 250 | ); 251 | 252 | if (area === 'local') { 253 | await refreshBackgroundFeatures(updatedOptions); 254 | } 255 | 256 | // Note that sync_data is in the sync exclusions list, so is only ever set in the local area. 257 | if (area === 'local' && updatedOptions.sync_data !== undefined) { 258 | if (updatedOptions.sync_data) { 259 | logInfo('Settings synchronization enabled'); 260 | await enableSync(); 261 | // When settings are imported from sync to local, the storage listener is disabled. 262 | // Calculation of which options need to be set in local/sync storage and then 263 | // reported via internal messaging is best handled in enableSync(). Return early. 264 | return; 265 | } else { 266 | logInfo('Settings synchronization disabled'); 267 | } 268 | } 269 | 270 | // Determine whether sync is enabled 271 | let syncEnabled = (await getOptions('local', ['sync_data'])).sync_data ?? false; 272 | 273 | // When sync storage changes are reported, check whether all options have been cleared, 274 | // in which case sync should be disabled. 275 | if (syncEnabled && area === 'sync') { 276 | syncEnabled = await verifySyncState(); 277 | } 278 | 279 | const syncOptionNames = getAllSyncOptionNames(); 280 | const updatedSyncOptions = toStorageOptions( 281 | getTypedKeys(updatedOptions).reduce>((result, key) => { 282 | if (syncOptionNames.includes(key)) { 283 | result[key] = updatedOptions[key]; 284 | } 285 | return result; 286 | }, {}) 287 | ); 288 | const hasUpdatedSyncSettings = Object.keys(updatedSyncOptions).length > 0; 289 | 290 | if (syncEnabled && hasUpdatedSyncSettings) { 291 | const newArea: chrome.storage.AreaName = area === 'local' ? 'sync' : 'local'; 292 | 293 | const currentSettings = await getOptions(newArea); 294 | const filteredUpdates = getTypedKeys(updatedSyncOptions).reduce>( 295 | (result, key) => { 296 | if (!deepEqual(currentSettings[key], updatedSyncOptions[key])) { 297 | result[key] = updatedSyncOptions[key]; 298 | } 299 | return result; 300 | }, 301 | {} 302 | ); 303 | 304 | if (Object.keys(filteredUpdates).length) { 305 | // Debugging 306 | // logInfo(newArea === 'sync' ? '.. toSync: ' : '.. toLocal: ', filteredUpdates); 307 | await setOptions(newArea, filteredUpdates); 308 | } 309 | } 310 | 311 | // Only send messages when the local area is updated. This works because sync 312 | // settings updates are always duplicated to the local area just above and 313 | // thus avoids redundant notices. 314 | // 315 | // We only need to send updatedSyncOptions because local-only option changes 316 | // are handled by local event listeners. 317 | if (area === 'local' && hasUpdatedSyncSettings) { 318 | // Debugging 319 | // logInfo(`Sending updated settings notification\n`, updatedSyncOptions); 320 | 321 | const forceUpdate = getForceRefreshOptionNames().some( 322 | (optionName) => updatedSyncOptions[optionName] !== undefined 323 | ); 324 | 325 | await sendInternalMessageAsync({ 326 | cmd: MessageCmd.SettingsUpdated, 327 | data: {options: updatedSyncOptions, forceUpdate}, 328 | }); 329 | } 330 | } 331 | 332 | /** 333 | * Refresh the state of background features 334 | * @param updatedOptions Updated options from storage change handler 335 | */ 336 | async function refreshBackgroundFeatures(updatedOptions: StorageOptions): Promise { 337 | if (updatedOptions.context_menu !== undefined) { 338 | await resetContextMenu(); 339 | } 340 | if (updatedOptions.history_doi_queue !== undefined) { 341 | await processHistoryDoiQueue(); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /tests/citation.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from './fixtures'; 2 | import {DisplayTheme, HistoryDoi, HistorySort} from '../src/lib/options'; 3 | import {getStorageValue} from './utils'; 4 | 5 | test.describe('Citation', () => { 6 | const doi = '10.1045/january99-bearman'; 7 | const title = 'A Common Model to Support Interoperable Metadata'; 8 | 9 | test.beforeEach(async ({page, extension}) => { 10 | const search = '?' + new URLSearchParams({doi}).toString(); 11 | await page.goto(extension.urls.citation + search); 12 | }); 13 | 14 | test('use display theme', async ({page}) => { 15 | const htmlThemes: string[] = [DisplayTheme.Dark, DisplayTheme.Light]; 16 | for (const theme of Object.values(DisplayTheme)) { 17 | await page.evaluate((s) => chrome.storage.local.set(s), {theme}); 18 | await page.reload({waitUntil: 'load'}); 19 | const htmlTheme = await page.locator('html').evaluate((html) => html.dataset.bsTheme); 20 | expect( 21 | theme === DisplayTheme.System ? htmlThemes.includes(htmlTheme ?? '') : htmlTheme === theme 22 | ).toBe(true); 23 | } 24 | }); 25 | 26 | test('field initialization', async ({page}) => { 27 | await expect(page.getByLabel('DOI')).toHaveValue(doi); 28 | await expect(page.getByLabel('Filter styles')).toHaveValue(''); 29 | await expect(page.locator('#styleList')).toHaveValue('bibtex'); 30 | await expect(page.getByLabel('Locale')).toHaveValue('auto'); 31 | }); 32 | 33 | test('default citation retrieval', async ({page}) => { 34 | await page.getByRole('button', {name: 'Submit'}).click(); 35 | await expect( 36 | page.getByText('title={A Common Model to Support Interoperable Metadata}') 37 | ).toBeVisible(); 38 | }); 39 | 40 | test('ShortDOI citation retrieval', async ({page}) => { 41 | await page.getByLabel('DOI').fill('10/cg7cd4'); 42 | await page.getByRole('button', {name: 'Submit'}).click(); 43 | await expect( 44 | page.getByText('title={A Common Model to Support Interoperable Metadata}') 45 | ).toBeVisible(); 46 | }); 47 | 48 | test('invalid DOI', async ({page}) => { 49 | await page.getByLabel('DOI').clear(); 50 | await page.getByRole('button', {name: 'Submit'}).click(); 51 | await expect(page.getByText('Not a valid DOI')).toBeVisible(); 52 | await page.getByLabel('DOI').fill('abc'); 53 | await page.getByRole('button', {name: 'Submit'}).click(); 54 | await expect(page.getByText('Not a valid DOI')).toBeVisible(); 55 | }); 56 | 57 | test('style filter', async ({page}) => { 58 | await expect(page.locator('#styleList')).toHaveValue('bibtex'); 59 | await page.getByLabel('Filter styles').fill('bibtex'); 60 | await expect(page.locator('#styleList')).toHaveValue('bibtex'); 61 | await page.getByLabel('Filter styles').fill('physical'); 62 | await expect(page.locator('#styleList')).not.toHaveValue(/^(bibtex)?$/); 63 | await expect(page.locator('#styleList option[value="bibtex"]')).toBeHidden(); 64 | await page.locator('#styleList').selectOption('American Physical Society'); 65 | await expect(page.locator('#styleList')).toHaveValue('american-physics-society'); 66 | await page.getByLabel('Filter styles').clear(); 67 | await expect(page.locator('#styleList option[value="bibtex"]')).toBeVisible(); 68 | }); 69 | 70 | test('citation retrieval with APS style', async ({page}) => { 71 | await expect(page.getByLabel('DOI')).toHaveValue(doi); 72 | await page.locator('#styleList').selectOption('American Physical Society'); 73 | await page.getByRole('button', {name: 'Submit'}).click(); 74 | await expect.poll(() => getStorageValue(page, 'cite_style')).toBe('american-physics-society'); 75 | await expect( 76 | page.getByText('D. Bearman, E. Miller, G. Rust, J. Trant, and S. Weibel') 77 | ).toBeVisible(); 78 | }); 79 | 80 | test('citation retrieval with APS style and German locale', async ({page}) => { 81 | await expect(page.getByLabel('DOI')).toHaveValue(doi); 82 | await page.locator('#styleList').selectOption('American Physical Society'); 83 | await page.getByLabel('Locale').selectOption('German (Germany)'); 84 | await page.getByRole('button', {name: 'Submit'}).click(); 85 | await expect.poll(() => getStorageValue(page, 'cite_style')).toBe('american-physics-society'); 86 | await expect.poll(() => getStorageValue(page, 'cite_locale')).toBe('de-DE'); 87 | await expect( 88 | page.getByText('D. Bearman, E. Miller, G. Rust, J. Trant, und S. Weibel') 89 | ).toBeVisible(); 90 | }); 91 | 92 | test.describe('History', () => { 93 | test('record DOI on citation retrieval', async ({page, serviceWorker}) => { 94 | await page.evaluate((s) => chrome.storage.local.set(s), {history: true}); 95 | await page.getByRole('button', {name: 'Submit'}).click(); 96 | await expect 97 | .poll(() => getStorageValue(serviceWorker, 'recorded_dois')) 98 | .toEqual([{doi, title: '', save: false}]); 99 | }); 100 | 101 | test('record ShortDOI on citation retrieval', async ({page, serviceWorker}) => { 102 | await page.evaluate((s) => chrome.storage.local.set(s), {history: true}); 103 | await page.getByLabel('DOI').fill('10/cg7cd4'); 104 | await page.getByRole('button', {name: 'Submit'}).click(); 105 | await expect 106 | .poll(() => getStorageValue(serviceWorker, 'recorded_dois')) 107 | .toEqual([{doi: '10/cg7cd4', title: '', save: false}]); 108 | }); 109 | 110 | test('record DOI with title on citation retrieval', async ({page, serviceWorker}) => { 111 | await page.evaluate((s) => chrome.storage.local.set(s), { 112 | history: true, 113 | history_fetch_title: true, 114 | }); 115 | await page.getByRole('button', {name: 'Submit'}).click(); 116 | await expect 117 | .poll(() => getStorageValue(serviceWorker, 'recorded_dois')) 118 | .toEqual([{doi, title, save: false}]); 119 | }); 120 | 121 | test('record ShortDOI with title on citation retrieval', async ({page, serviceWorker}) => { 122 | await page.evaluate((s) => chrome.storage.local.set(s), { 123 | history: true, 124 | history_fetch_title: true, 125 | }); 126 | await page.getByLabel('DOI').fill('10/cg7cd4'); 127 | await page.getByRole('button', {name: 'Submit'}).click(); 128 | await expect 129 | .poll(() => getStorageValue(serviceWorker, 'recorded_dois')) 130 | .toEqual([{doi: '10/cg7cd4', title, save: false}]); 131 | }); 132 | 133 | test('only show history button when history available', async ({page}) => { 134 | await expect(page.getByRole('link', {name: 'History'})).toBeHidden(); 135 | await page.evaluate((s) => chrome.storage.local.set(s), {history: true}); 136 | await page.reload({waitUntil: 'load'}); 137 | await expect(page.getByRole('link', {name: 'History'})).toBeHidden(); 138 | await page.evaluate((s) => chrome.storage.local.set(s), { 139 | history: true, 140 | recorded_dois: [{doi: '10.1000/1', title: '', save: false}] as HistoryDoi[], 141 | }); 142 | await expect(page.getByRole('link', {name: 'History'})).toBeVisible(); 143 | }); 144 | 145 | test('history modal', async ({page}) => { 146 | await page.evaluate((s) => chrome.storage.local.set(s), { 147 | history: true, 148 | recorded_dois: [{doi: '10.1000/1', title: '', save: false}] as HistoryDoi[], 149 | }); 150 | await page.reload({waitUntil: 'load'}); 151 | const modalTrigger = page.getByRole('link', {name: 'History'}); 152 | await modalTrigger.click(); 153 | const modal = page.locator('#historyModal'); 154 | await expect(modal).toBeVisible(); 155 | await expect(modal.locator('.modal-title')).toHaveText('History'); 156 | await modal.getByLabel('Close').click(); 157 | await expect(modal).toBeHidden(); 158 | }); 159 | 160 | test('show DOIs in history modal', async ({page}) => { 161 | const recorded_dois: HistoryDoi[] = [ 162 | {doi: '10.1000/1', title: '', save: false}, 163 | {doi: '10.1000/2', title: '', save: true}, 164 | {doi: '10/3', title: '', save: false}, 165 | ]; 166 | await page.evaluate((s) => chrome.storage.local.set(s), { 167 | history: true, 168 | recorded_dois, 169 | }); 170 | await page.reload({waitUntil: 'load'}); 171 | 172 | await page.getByRole('link', {name: 'History'}).click(); 173 | const modal = page.locator('#historyModal'); 174 | await expect(modal).toBeVisible(); 175 | 176 | const expected = [ 177 | {text: '10.1000/2', divider: false}, 178 | {text: '↑ Saved / Unsaved ↓', divider: true}, 179 | {text: '10/3', divider: false}, 180 | {text: '10.1000/1', divider: false}, 181 | ]; 182 | for (let i = 0; i < expected.length; i++) { 183 | await expect(modal.getByRole('option').nth(i)).toHaveText(expected[i].text); 184 | await expect(modal.getByRole('option').nth(i)).toBeEnabled({enabled: !expected[i].divider}); 185 | } 186 | }); 187 | 188 | test('show DOI titles in history modal', async ({page}) => { 189 | const recorded_dois: HistoryDoi[] = [ 190 | {doi: '10.1000/1', title: 'Some title 1', save: false}, 191 | {doi: '10.1000/2', title: 'Another title 2', save: true}, 192 | {doi: '10/3', title: '3. Title', save: false}, 193 | ]; 194 | await page.evaluate((s) => chrome.storage.local.set(s), { 195 | history: true, 196 | history_showtitles: true, 197 | recorded_dois, 198 | }); 199 | await page.reload({waitUntil: 'load'}); 200 | 201 | await page.getByRole('link', {name: 'History'}).click(); 202 | const modal = page.locator('#historyModal'); 203 | await expect(modal).toBeVisible(); 204 | 205 | const expected = [ 206 | {text: 'Another title 2', divider: false}, 207 | {text: '↑ Saved / Unsaved ↓', divider: true}, 208 | {text: '3. Title', divider: false}, 209 | {text: 'Some title 1', divider: false}, 210 | ]; 211 | for (let i = 0; i < expected.length; i++) { 212 | await expect(modal.getByRole('option').nth(i)).toHaveText(expected[i].text); 213 | await expect(modal.getByRole('option').nth(i)).toBeEnabled({enabled: !expected[i].divider}); 214 | } 215 | }); 216 | 217 | test('select a DOI in history modal', async ({page}) => { 218 | await page.evaluate((s) => chrome.storage.local.set(s), { 219 | history: true, 220 | recorded_dois: [{doi, title: '', save: false}] as HistoryDoi[], 221 | }); 222 | await page.reload({waitUntil: 'load'}); 223 | 224 | await page.getByRole('link', {name: 'History'}).click(); 225 | const modal = page.locator('#historyModal'); 226 | await expect(modal).toBeVisible(); 227 | await modal.getByRole('option', {name: doi}).click(); 228 | await expect(modal).toBeHidden(); 229 | await expect(page.getByLabel('DOI')).toHaveValue(doi); 230 | }); 231 | 232 | test('select a DOI title in history modal', async ({page}) => { 233 | await page.evaluate((s) => chrome.storage.local.set(s), { 234 | history: true, 235 | history_showtitles: true, 236 | recorded_dois: [{doi, title, save: false}] as HistoryDoi[], 237 | }); 238 | await page.reload({waitUntil: 'load'}); 239 | 240 | await page.getByRole('link', {name: 'History'}).click(); 241 | const modal = page.locator('#historyModal'); 242 | await expect(modal).toBeVisible(); 243 | await modal.getByRole('option', {name: title}).click(); 244 | await expect(modal).toBeHidden(); 245 | await expect(page.getByLabel('DOI')).toHaveValue(doi); 246 | }); 247 | 248 | test('sort DOIs in history modal', async ({page}) => { 249 | const recorded_dois: HistoryDoi[] = [ 250 | {doi: '10.1000/2', title: 'A', save: false}, 251 | {doi: '10.1000/1', title: 'B', save: false}, 252 | {doi: '10.1000/3', title: 'C', save: false}, 253 | ]; 254 | await page.evaluate((s) => chrome.storage.local.set(s), { 255 | history: true, 256 | recorded_dois, 257 | }); 258 | 259 | const updateSortAndGetLocator = async (sortBy: HistorySort) => { 260 | await page.evaluate((s) => chrome.storage.local.set(s), {history_sortby: sortBy}); 261 | await page.reload({waitUntil: 'load'}); 262 | 263 | await page.getByRole('link', {name: 'History'}).click(); 264 | const modal = page.locator('#historyModal'); 265 | await expect(modal).toBeVisible(); 266 | return modal.getByRole('option', {disabled: false}); 267 | }; 268 | 269 | const expectedByDate = ['10.1000/3', '10.1000/1', '10.1000/2']; 270 | const optionsByDate = await updateSortAndGetLocator(HistorySort.Date); 271 | for (let i = 0; i < expectedByDate.length; i++) { 272 | await expect(optionsByDate.nth(i)).toHaveText(expectedByDate[i]); 273 | } 274 | 275 | const expectedByDoi = ['10.1000/1', '10.1000/2', '10.1000/3']; 276 | const optionsByDoi = await updateSortAndGetLocator(HistorySort.Doi); 277 | for (let i = 0; i < expectedByDoi.length; i++) { 278 | await expect(optionsByDoi.nth(i)).toHaveText(expectedByDoi[i]); 279 | } 280 | 281 | const expectedByTitle = ['10.1000/2', '10.1000/1', '10.1000/3']; 282 | const optionsByTitle = await updateSortAndGetLocator(HistorySort.Title); 283 | for (let i = 0; i < expectedByTitle.length; i++) { 284 | await expect(optionsByTitle.nth(i)).toHaveText(expectedByTitle[i]); 285 | } 286 | 287 | await page.evaluate((s) => chrome.storage.local.set(s), { 288 | recorded_dois: [ 289 | ...recorded_dois, 290 | {doi: '10.1000/4', title: 'D', save: true}, 291 | {doi: '10.1000/5', title: 'E', save: false}, 292 | ], 293 | }); 294 | 295 | const expectedBySave = ['10.1000/4', '10.1000/5', '10.1000/3', '10.1000/1', '10.1000/2']; 296 | const optionsBySave = await updateSortAndGetLocator(HistorySort.Save); 297 | for (let i = 0; i < expectedBySave.length; i++) { 298 | await expect(optionsBySave.nth(i)).toHaveText(expectedBySave[i]); 299 | } 300 | }); 301 | }); 302 | }); 303 | -------------------------------------------------------------------------------- /src/bubble.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Apache-2.0 3 | */ 4 | 5 | import { 6 | CustomResolverSelection, 7 | HistoryDoi, 8 | StorageOptions, 9 | getOptions, 10 | isCustomResolverSelection, 11 | setOptions, 12 | } from './lib/options'; 13 | import {requestMetaPermissions} from './lib/permissions'; 14 | import {filterSelectByText, isValidDoi, sortHistoryEntries, trimDoi} from './lib/utils'; 15 | import {queueRecordDoi} from './lib/history'; 16 | import {resolveDoi} from './lib/resolve'; 17 | import {logError, logInfo} from './lib/logger'; 18 | import {applyTheme, getMessageNodes} from './utils'; 19 | 20 | document.addEventListener( 21 | 'DOMContentLoaded', 22 | function () { 23 | new DoiBubble().init().catch((error) => { 24 | logError('Init failed', error); 25 | }); 26 | }, 27 | false 28 | ); 29 | 30 | enum BubbleAction { 31 | Qr = 'qr', 32 | Cite = 'cite', 33 | Doi = 'doi', 34 | Options = 'options', 35 | } 36 | 37 | /** 38 | * Verify an item is an instance of BubbleAction 39 | * @param val Unverified item 40 | */ 41 | function isBubbleAction(val: unknown): val is BubbleAction { 42 | return typeof val === 'string' && Object.values(BubbleAction).includes(val); 43 | } 44 | 45 | class DoiBubble { 46 | private needsHistoryMetaPermissions_: boolean; 47 | 48 | private elements_: { 49 | citeSubmit: HTMLButtonElement; 50 | crRadioBubbleCustom: HTMLInputElement; 51 | crRadioBubbleDefault: HTMLInputElement; 52 | crRadios: HTMLDivElement; 53 | doiForm: HTMLFormElement; 54 | doiHistory: HTMLSelectElement; 55 | hiddenButtonInput: HTMLInputElement; 56 | historyDiv: HTMLDivElement; 57 | messageDiv: HTMLDivElement; 58 | messageOutput: HTMLDivElement; 59 | metaButtons: HTMLDivElement; 60 | optionsSubmit: HTMLButtonElement; 61 | qrSubmit: HTMLButtonElement; 62 | resolveSubmit: HTMLButtonElement; 63 | textInput: HTMLInputElement; 64 | }; 65 | 66 | constructor() { 67 | this.needsHistoryMetaPermissions_ = false; 68 | 69 | const elementMissing = (selector: string) => { 70 | throw new Error(`Required element is missing from the page: ${selector}`); 71 | }; 72 | this.elements_ = { 73 | citeSubmit: 74 | document.querySelector('button#citeSubmit') || 75 | elementMissing('button#citeSubmit'), 76 | crRadioBubbleCustom: 77 | document.querySelector('input#crRadioBubbleCustom[name="crRadio"]') || 78 | elementMissing('input#crRadioBubbleCustom[name="crRadio"]'), 79 | crRadioBubbleDefault: 80 | document.querySelector('input#crRadioBubbleDefault[name="crRadio"]') || 81 | elementMissing('input#crRadioBubbleDefault[name="crRadio"]'), 82 | crRadios: 83 | document.querySelector('div#crRadios') || elementMissing('div#crRadios'), 84 | doiForm: 85 | document.querySelector('form#doiForm') || elementMissing('form#doiForm'), 86 | doiHistory: 87 | document.querySelector('select#doiHistory') || 88 | elementMissing('select#doiHistory'), 89 | hiddenButtonInput: 90 | document.querySelector('input#hiddenButtonInput') || 91 | elementMissing('input#hiddenButtonInput'), 92 | historyDiv: 93 | document.querySelector('div#historyDiv') || 94 | elementMissing('div#historyDiv'), 95 | messageDiv: 96 | document.querySelector('div#messageDiv') || 97 | elementMissing('div#messageDiv'), 98 | messageOutput: 99 | document.querySelector('div#messageOutput') || 100 | elementMissing('div#messageOutput'), 101 | metaButtons: 102 | document.querySelector('div#metaButtons') || 103 | elementMissing('div#metaButtons'), 104 | optionsSubmit: 105 | document.querySelector('button#optionsSubmit') || 106 | elementMissing('button#optionsSubmit'), 107 | qrSubmit: 108 | document.querySelector('button#qrSubmit') || 109 | elementMissing('button#qrSubmit'), 110 | resolveSubmit: 111 | document.querySelector('button#resolveSubmit') || 112 | elementMissing('button#resolveSubmit'), 113 | textInput: 114 | document.querySelector('input#textInput') || 115 | elementMissing('input#textInput'), 116 | }; 117 | } 118 | 119 | /** 120 | * Initialize bubble. 121 | */ 122 | public async init() { 123 | await this.restoreOptions(); 124 | this.getLocalMessages(); 125 | await applyTheme(window); 126 | await this.showHideOptionalElms(); 127 | await this.setHistoryMetaPermissions(); 128 | await this.populateHistory(); 129 | this.startListeners(); 130 | } 131 | 132 | /** 133 | * Attach window/element listeners. 134 | */ 135 | private startListeners(): void { 136 | const elements = this.elements_; 137 | elements.resolveSubmit.addEventListener('click', function () { 138 | elements.hiddenButtonInput.value = BubbleAction.Doi; 139 | }); 140 | elements.citeSubmit.addEventListener('click', function () { 141 | elements.hiddenButtonInput.value = BubbleAction.Cite; 142 | }); 143 | elements.qrSubmit.addEventListener('click', function () { 144 | elements.hiddenButtonInput.value = BubbleAction.Qr; 145 | }); 146 | elements.optionsSubmit.addEventListener('click', function () { 147 | elements.hiddenButtonInput.value = BubbleAction.Options; 148 | }); 149 | 150 | const formSubmitHandler = this.formSubmitHandler.bind(this); 151 | elements.doiForm.addEventListener('submit', function (event) { 152 | event.preventDefault(); 153 | formSubmitHandler(); 154 | }); 155 | 156 | const saveOptions = this.saveOptions.bind(this); 157 | elements.crRadioBubbleCustom.addEventListener('click', saveOptions); 158 | elements.crRadioBubbleDefault.addEventListener('click', saveOptions); 159 | } 160 | 161 | /** 162 | * Save options to storage 163 | */ 164 | private saveOptions(): void { 165 | const options: StorageOptions = {}; 166 | 167 | const checkedCrRadio = document.querySelector( 168 | 'input[name="crRadio"]:checked' 169 | ); 170 | if (checkedCrRadio) { 171 | if (isCustomResolverSelection(checkedCrRadio.value)) { 172 | options.cr_bubble_last = checkedCrRadio.value; 173 | } 174 | } 175 | 176 | if (Object.keys(options).length) { 177 | setOptions('local', options).catch((error) => { 178 | logError('Unable to save options', error); 179 | }); 180 | } 181 | } 182 | 183 | /** 184 | * Restore options from storage 185 | */ 186 | private async restoreOptions(): Promise { 187 | const stg = await getOptions('local', ['cr_bubble_last']); 188 | if (stg.cr_bubble_last === 'custom') { 189 | this.elements_.crRadioBubbleCustom.checked = true; 190 | } else { 191 | this.elements_.crRadioBubbleDefault.checked = true; 192 | } 193 | } 194 | 195 | /** 196 | * Clear message space 197 | */ 198 | private resetMessageSpace(): void { 199 | this.elements_.messageDiv.hidden = true; 200 | this.elements_.messageOutput.replaceChildren(); 201 | } 202 | 203 | /** 204 | * Print a notification 205 | * @param messageNodes Message nodes 206 | */ 207 | private bubbleMessage(messageNodes: Node[]): void { 208 | this.resetMessageSpace(); 209 | this.elements_.messageOutput.append(...messageNodes); 210 | this.elements_.messageDiv.hidden = false; 211 | } 212 | 213 | /** 214 | * Handle form submission 215 | */ 216 | private formSubmitHandler(): void { 217 | const action = this.elements_.hiddenButtonInput.value; 218 | if (!isBubbleAction(action)) { 219 | logError('Unrecognized action'); 220 | return; 221 | } 222 | const doiInput = encodeURI(trimDoi(this.elements_.textInput.value)); 223 | this.formSubmitHandlerAsync(action, doiInput).catch((error) => { 224 | logError('Failed to submit form', error); 225 | }); 226 | } 227 | 228 | /** 229 | * Async handler for form submissions 230 | * @param action Form action type 231 | * @param doiInput User-entered DOI 232 | */ 233 | private async formSubmitHandlerAsync(action: BubbleAction, doiInput: string): Promise { 234 | switch (action) { 235 | case BubbleAction.Qr: 236 | if (isValidDoi(doiInput)) { 237 | if (this.needsHistoryMetaPermissions_) { 238 | await requestMetaPermissions(); 239 | } 240 | await queueRecordDoi(doiInput); 241 | } 242 | // Allow tab to open with invalid DOI 243 | this.openQrGenerator(doiInput); 244 | break; 245 | case BubbleAction.Cite: 246 | if (isValidDoi(doiInput)) { 247 | if (this.needsHistoryMetaPermissions_) { 248 | await requestMetaPermissions(); 249 | } 250 | await queueRecordDoi(doiInput); 251 | } 252 | // Allow tab to open with invalid DOI 253 | this.openCitationGenerator(doiInput); 254 | break; 255 | case BubbleAction.Doi: 256 | if (isValidDoi(doiInput)) { 257 | if (this.needsHistoryMetaPermissions_) { 258 | await requestMetaPermissions(); 259 | } 260 | await queueRecordDoi(doiInput); 261 | await this.resolveDoi(doiInput); 262 | } else { 263 | this.bubbleMessage(getMessageNodes('invalidDoiAlert')); 264 | } 265 | break; 266 | case BubbleAction.Options: 267 | chrome.runtime.openOptionsPage(() => { 268 | window.close(); 269 | }); 270 | break; 271 | default: 272 | break; 273 | } 274 | } 275 | 276 | /** 277 | * Resolve a DOI in a new foreground tab. 278 | * @param doi DOI 279 | */ 280 | private async resolveDoi(doi: string): Promise { 281 | const stg = await getOptions('local', [ 282 | 'custom_resolver', 283 | 'cr_bubble', 284 | 'cr_bubble_last', 285 | 'doi_resolver', 286 | 'shortdoi_resolver', 287 | ]); 288 | 289 | const useCustomResolver = !!( 290 | stg.custom_resolver && 291 | (stg.cr_bubble === 'custom' || 292 | (stg.cr_bubble === 'selectable' && stg.cr_bubble_last === 'custom')) 293 | ); 294 | 295 | await resolveDoi(doi, useCustomResolver, 'newForegroundTab'); 296 | window.close(); 297 | } 298 | 299 | /** 300 | * Open Citation Generator page 301 | * @param doi DOI-like string 302 | */ 303 | private openCitationGenerator(doi: string): void { 304 | const url = 'citation.html?doi=' + encodeURIComponent(doi); 305 | chrome.tabs.create({url}, () => { 306 | window.close(); 307 | }); 308 | } 309 | 310 | /** 311 | * Open QR Generator page 312 | * @param doi DOI-like string 313 | */ 314 | private openQrGenerator(doi: string): void { 315 | const url = 'qr.html?doi=' + encodeURIComponent(doi); 316 | chrome.tabs.create({url}, () => { 317 | window.close(); 318 | }); 319 | } 320 | 321 | /** 322 | * Show or hide additional buttons in bubble 323 | */ 324 | async showHideOptionalElms(): Promise { 325 | const stg = await getOptions('local', ['meta_buttons', 'custom_resolver', 'cr_bubble']); 326 | 327 | this.elements_.metaButtons.hidden = !stg.meta_buttons; 328 | this.elements_.crRadios.hidden = 329 | !stg.custom_resolver || stg.cr_bubble !== CustomResolverSelection.Selectable; 330 | } 331 | 332 | /** 333 | * Get the size of the box. 357 | */ 358 | async populateHistory(): Promise { 359 | const stg = await getOptions('local', [ 360 | 'meta_buttons', 361 | 'history', 362 | 'recorded_dois', 363 | 'history_showsave', 364 | 'history_showtitles', 365 | 'history_sortby', 366 | ]); 367 | 368 | if (!stg.meta_buttons || !stg.history) { 369 | this.elements_.historyDiv.hidden = true; 370 | return; 371 | } 372 | if (!stg.recorded_dois || stg.recorded_dois.length < 1) { 373 | this.elements_.historyDiv.hidden = true; 374 | return; 375 | } 376 | 377 | this.elements_.historyDiv.hidden = false; 378 | 379 | // Skip holes in the array (should not occur) 380 | stg.recorded_dois = stg.recorded_dois.filter(Boolean); 381 | 382 | sortHistoryEntries(stg.recorded_dois, stg.history_sortby); 383 | 384 | const createOption = (item: HistoryDoi): HTMLOptionElement => { 385 | const {doi, title, save} = item; 386 | const element = document.createElement('option'); 387 | element.value = doi; 388 | element.textContent = stg.history_showtitles && title ? title : doi; 389 | element.title = [doi, title].join(' - '); 390 | if (save) { 391 | element.classList.add('save'); 392 | } 393 | return element; 394 | }; 395 | 396 | const optionElements: HTMLOptionElement[] = []; 397 | const savedOptions = stg.recorded_dois.filter((item) => item.save).map(createOption); 398 | const unsavedOptions = !stg.history_showsave 399 | ? stg.recorded_dois.filter((item) => !item.save).map(createOption) 400 | : []; 401 | const dividerOptions: HTMLOptionElement[] = []; 402 | if (savedOptions.length && unsavedOptions.length) { 403 | const dividerOption = document.createElement('option'); 404 | dividerOption.disabled = true; 405 | dividerOptions.push(dividerOption); 406 | } 407 | optionElements.push(...savedOptions, ...dividerOptions, ...unsavedOptions); 408 | 409 | const selectBox = this.elements_.doiHistory; 410 | selectBox.size = this.getSelectBoxSize(optionElements.length); 411 | selectBox.selectedIndex = -1; 412 | optionElements.forEach((optionElement) => selectBox.appendChild(optionElement)); 413 | 414 | const filterInput = function (this: HTMLInputElement): void { 415 | filterSelectByText(selectBox, this.value, false); 416 | }; 417 | 418 | const textInput = this.elements_.textInput; 419 | const resetMessageSpace = this.resetMessageSpace.bind(this); 420 | textInput.addEventListener('input', filterInput); 421 | selectBox.addEventListener('change', function () { 422 | const value = this.value; 423 | if (!value) return; 424 | textInput.removeEventListener('input', filterInput); 425 | textInput.value = value; 426 | textInput.addEventListener('input', filterInput); 427 | this.selectedIndex = -1; 428 | resetMessageSpace(); 429 | }); 430 | } 431 | 432 | /** 433 | * Get localization strings and populate their corresponding elements' HTML. 434 | */ 435 | getLocalMessages(): void { 436 | document.title = chrome.i18n.getMessage('appName'); 437 | 438 | const nestedMessageIds = ['citeSubmit', 'optionsSubmit', 'qrSubmit']; 439 | 440 | for (const messageId of nestedMessageIds) { 441 | const message = getMessageNodes(messageId); 442 | const elementId = `${messageId}Text`; 443 | const element = document.getElementById(elementId); 444 | if (element) { 445 | element.append(...message); 446 | } else if (!message) { 447 | logInfo(`Unable to insert message ${messageId} because it is not defined.`); 448 | } else { 449 | logInfo(`Message for #${elementId} not inserted because element not found.`); 450 | } 451 | } 452 | 453 | const messageIds = [ 454 | 'optionCrCustom', 455 | 'optionCrDefault', 456 | 'optionCrLabelBubble', 457 | 'resolveSubmit', 458 | ]; 459 | 460 | for (const messageId of messageIds) { 461 | const message = getMessageNodes(messageId); 462 | const element = document.getElementById(messageId); 463 | if (element) { 464 | element.append(...message); 465 | } else if (!message) { 466 | logInfo(`Unable to insert message ${messageId} because it is not defined.`); 467 | } else { 468 | logInfo(`Message for #${messageId} not inserted because element not found.`); 469 | } 470 | } 471 | } 472 | } 473 | --------------------------------------------------------------------------------