├── .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 | 
2 |
3 | Citation Style Language - Locales Repository
4 |
5 |
6 |
7 |
8 |
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 |
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 |
95 |
96 |
97 |
104 |
105 |
106 |
116 |
117 |
124 |
125 |
126 |
127 |
128 |
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