diff --git a/.gitmodules b/.gitmodules
index 860a00c3..e8965564 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -8,3 +8,7 @@
[submodule "vendor/subminer-yomitan"]
path = vendor/subminer-yomitan
url = https://github.com/ksyasuda/subminer-yomitan
+[submodule "vendor/hachidori"]
+ path = vendor/hachidori
+ url = https://github.com/ksyasuda/hachidori.git
+ branch = subminer
diff --git a/changes/hachidori-backend.md b/changes/hachidori-backend.md
index 2a386fa6..95f7c574 100644
--- a/changes/hachidori-backend.md
+++ b/changes/hachidori-backend.md
@@ -11,3 +11,4 @@ area: dictionary
- Stats dashboard mining and deck lookup use the selected backend. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining and media enrichment stay in SubMiner.
+- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
diff --git a/docs-site/usage.md b/docs-site/usage.md
index 87c449f4..88850891 100644
--- a/docs-site/usage.md
+++ b/docs-site/usage.md
@@ -323,7 +323,7 @@ Open Hachidori settings with `subminer app --hachidori` or `SubMiner.AppImage --
First-run setup also offers **Dictionary source → Use an external dictionary host → Link host**. Enable sharing in the other Hachidori app or browser, or start a compatible Docker dictionary host, then enter its sharing address, such as `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port. The external host section is collapsed until you expand it or a host is linked. Browser hosts need the browser, Hachidori extension, and relay running. Electron hosts need the host app and any required relay running. Docker hosts need the container running; no browser needs to stay open.
-Setup checks the host connection and dictionary inventory before enabling Finish. Import at least one dictionary on the host and refresh status. The link persists across restarts. **Unlink and use local dictionaries** restores SubMiner's local library. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio/image processing remain local while linked. Dictionary settings and dictionary edits use the host. Older hosts without the standalone frequency query support frequency annotations through term lookups; frequency-only entries without a matching term require an updated host.
+Setup checks the host connection and dictionary inventory before enabling Finish. Import at least one dictionary on the host and refresh status. The link persists across restarts. **Unlink and use local dictionaries** restores SubMiner's local library. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio/image processing remain local while linked. Dictionary settings and dictionary edits use the host. Frequency annotations use the frequencies returned with Hachidori dictionary entries. SubMiner keeps ranks found during scanning and queries the existing term-entry API for missing ranks. Words without a matching definition entry may remain unranked, even if a frequency dictionary contains them.
Both named settings flags work independently of the selected backend. Opening settings does not switch the overlay backend. The global dictionary-settings shortcut opens the selected backend.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 4326a1ec..03062aad 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -48,7 +48,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
-Hachidori's source, HoshiDicts engine, licenses, pinned revision, and local patch notes live in `vendor/hachidori/`. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its native runtime messages to the existing subtitle scanner and dictionary automation. Its local content bridge implements SubMiner's existing popup events and commands. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
+`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. Its local content bridge implements SubMiner's existing popup events and commands. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
- Small units, explicit boundaries
- Composition over monoliths
diff --git a/scripts/check-hachidori-parser.cjs b/scripts/check-hachidori-parser.cjs
index c1db6f03..f6047014 100644
--- a/scripts/check-hachidori-parser.cjs
+++ b/scripts/check-hachidori-parser.cjs
@@ -187,37 +187,28 @@ app
assert.equal(tokens[1].endPos, 7);
assert.equal(tokens[1].frequencyRank, 42);
assert.deepEqual(tokens[1].wordClasses, ['v1']);
- // Remove all term dictionaries before checking direct frequency queries.
- for (const title of ['SubMiner Test Terms', 'SubMiner Character Dictionary (AniList 1)']) {
- assert.equal(await parser.deleteYomitanDictionaryByTitle(title, deps, logger), true);
- }
- parser.clearYomitanParserCachesForWindow(parserWindow);
const exact = await parser.requestYomitanTermFrequencies(
- [{ term: '頻度だけ', reading: 'ひんどだけ' }],
+ [{ term: '食べる', reading: 'たべる' }],
deps,
logger,
);
- assert.deepEqual(
- exact.map((value) => value.frequency).sort((a, b) => a - b),
- [17, 120],
+ assert.equal(exact.length, 1);
+ assert.equal(exact[0].frequency, 42);
+ assert.equal(exact[0].reading, 'たべる');
+ assert.equal(exact[0].hasReading, false);
+ const otherReading = await parser.requestYomitanTermFrequencies(
+ [{ term: '食べる', reading: 'べつのよみ' }],
+ deps,
+ logger,
);
- assert.ok(
- exact.some(
- (value) => value.frequency === 120 && value.hasReading && value.reading === 'ひんどだけ',
- ),
- );
- assert.ok(
- exact.some((value) => value.frequency === 17 && !value.hasReading && value.reading === null),
- );
- const allReadings = await parser.requestYomitanTermFrequencies(
+ // The shared frequency pipeline retries a missing reading as a term-only query.
+ assert.equal(otherReading[0]?.frequency, 42);
+ const unmatched = await parser.requestYomitanTermFrequencies(
[{ term: '頻度だけ', reading: null }],
deps,
logger,
);
- assert.deepEqual(
- allReadings.map((value) => value.frequency).sort((a, b) => a - b),
- [17, 120, 250],
- );
+ assert.deepEqual(unmatched, []);
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
assert.equal(
@@ -236,7 +227,7 @@ app
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(errors.length, 0);
console.log(
- 'PASS Hachidori native import, scanner, character names, frequency-only dictionaries, reading provenance, settings and removal',
+ 'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
);
clearTimeout(deadline);
app.exit(0);
diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts
index b8a7f0d2..63bbf77f 100644
--- a/src/core/services/tokenizer.test.ts
+++ b/src/core/services/tokenizer.test.ts
@@ -4362,6 +4362,26 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds'
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
});
+test('tokenizeSubtitle skips frequency requests for ranks supplied by the scanner', async () => {
+ const deps = makeDepsFromYomitanTokens(
+ [{ surface: '猫', reading: 'ねこ', headword: '猫', frequencyRank: 42 }],
+ { getFrequencyDictionaryEnabled: () => true },
+ );
+ const parserWindow = deps.getYomitanParserWindow();
+ assert.ok(parserWindow);
+ deps.getYomitanParserWindow = () => parserWindow;
+ const scripts: string[] = [];
+ const execute = parserWindow.webContents.executeJavaScript.bind(parserWindow.webContents);
+ parserWindow.webContents.executeJavaScript = async (script) => {
+ scripts.push(script);
+ return execute(script);
+ };
+ const result = await tokenizeSubtitle('猫', deps);
+ assert.equal(result.tokens?.[0]?.frequencyRank, 42);
+ assert.ok(scripts.length > 0);
+ assert.equal(scripts.filter((script) => script.includes('getTermFrequencies')).length, 0);
+});
+
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
const result = await tokenizeSubtitle(
'第二走者',
diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts
index d5cd5e12..72ae03cd 100644
--- a/src/core/services/tokenizer.ts
+++ b/src/core/services/tokenizer.ts
@@ -494,6 +494,7 @@ function buildYomitanFrequencyTermReadingList(
): Array<{ term: string; reading: string | null }> {
const termReadingList: Array<{ term: string; reading: string | null }> = [];
for (const token of tokens) {
+ if (normalizePositiveFrequencyRank(token.frequencyRank) !== null) continue;
const readingRaw =
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.test.ts b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts
index f1367b04..ae2b46b2 100644
--- a/src/core/services/tokenizer/hachidori-parser-bridge.test.ts
+++ b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts
@@ -116,20 +116,6 @@ async function createHarness(emptyLibrary = false) {
results: candidates.filter((result) => text.startsWith(result.matched)),
};
}
- case 'hd_frequencies':
- return {
- ok: true,
- frequencies: [
- {
- term: '食べる',
- reading: 'たべる',
- hasReading: true,
- dictionary: 'Frequency',
- frequency: 42,
- displayValue: null,
- },
- ],
- };
case 'hd_options_write': {
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
const update = message.options;
@@ -254,6 +240,28 @@ test('Hachidori runs the shared scanner with inflected offsets, headwords, names
assert.equal(frequencies[0]?.dictionary, 'Frequency');
});
+test('Hachidori frequency lookups match API headwords, readings and requested dictionaries', async () => {
+ const harness = await createHarness();
+ const query = (term: string, reading: string | null, dictionaries = ['Frequency']) =>
+ harness.invoke('getTermFrequencies', { termReadingList: [{ term, reading }], dictionaries });
+ assert.deepEqual(await query('食べる', 'たべる'), [
+ {
+ term: '食べる',
+ reading: 'たべる',
+ hasReading: false,
+ dictionary: 'Frequency',
+ frequency: 42,
+ displayValue: null,
+ displayValueParsed: false,
+ },
+ ]);
+ assert.deepEqual(await query('食べる', 'べつのよみ'), []);
+ assert.deepEqual(await query('食べる', null, ['Other frequency']), []);
+ assert.deepEqual(await query('食べるだけ', null), []);
+ assert.deepEqual(await query('頻度だけ', null), []);
+ assert.deepEqual(await query('食べる', null), await query('食べる', 'たべる'));
+});
+
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
const harness = await createHarness();
const synced = await syncYomitanDefaultAnkiServer(
diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.ts b/src/core/services/tokenizer/hachidori-parser-bridge.ts
index 20e78cbe..66d89f84 100644
--- a/src/core/services/tokenizer/hachidori-parser-bridge.ts
+++ b/src/core/services/tokenizer/hachidori-parser-bridge.ts
@@ -138,30 +138,27 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
}
}
async function getTermFrequencies({ termReadingList, dictionaries }) {
- let frequencies;
- try {
- ({ frequencies } = await engine('hd_frequencies', { termReadingList }));
- } catch (error) {
- const { sharing } = await send('hd_sharing_status', {}, 'hachidori-sharing');
- if (!sharing?.client?.connected || !/unknown|unsupported|not supported/i.test(error.message)) throw error;
- // Older external hosts expose frequency data through term lookups only.
- frequencies = [];
- for (const { term, reading } of termReadingList) {
- const { results } = await engine('hd_lookup', { text: term, maxResults: 100 });
- for (const result of results) {
- if (result.term.expression !== term || (reading !== null && result.term.reading !== reading)) continue;
- for (const group of result.term.frequencies) {
- for (const value of group.frequencies) {
- frequencies.push({ term, reading: value.reading || null,
- hasReading: typeof value.reading === 'string' && value.reading.length > 0,
- dictionary: group.dictionary, frequency: value.value,
- displayValue: value.displayValue || null, displayValueParsed: false });
- }
- }
+ const terms = [...new Set(termReadingList.map(pair => pair.term))];
+ const { results } = await api({ type: 'hd_api_term_entries', terms });
+ const frequencies = [];
+ for (const result of results) {
+ const term = terms[result.index];
+ const pairs = termReadingList.filter(pair => pair.term === term);
+ for (const entry of result.dictionaryEntries) {
+ for (const value of entry.frequencies) {
+ const headword = entry.headwords[value.headwordIndex];
+ if (!headword || headword.term !== term || !dictionaries.includes(value.dictionary)) continue;
+ if (!pairs.some(pair => pair.reading === null || pair.reading === headword.reading)) continue;
+ // Upstream does not expose the frequency entry's original reading.
+ // Keep its API flag and associate the value with the matched headword.
+ frequencies.push({ term, reading: headword.reading || null,
+ hasReading: value.hasReading, dictionary: value.dictionary,
+ frequency: value.frequency, displayValue: value.displayValue,
+ displayValueParsed: value.displayValueParsed });
}
}
}
- return frequencies.filter(frequency => dictionaries.includes(frequency.dictionary));
+ return frequencies;
}
// Hachidori's public tokenize API emits display furigana without headwords.
// SubMiner's fallback requires one group per token and a dictionary form.
diff --git a/vendor/hachidori b/vendor/hachidori
new file mode 160000
index 00000000..1a47c444
--- /dev/null
+++ b/vendor/hachidori
@@ -0,0 +1 @@
+Subproject commit 1a47c4442056b9f964a75bfdd31f0f03a809655b
diff --git a/vendor/hachidori/LICENSE b/vendor/hachidori/LICENSE
deleted file mode 100644
index e72bfdda..00000000
--- a/vendor/hachidori/LICENSE
+++ /dev/null
@@ -1,674 +0,0 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
\ No newline at end of file
diff --git a/vendor/hachidori/README.md b/vendor/hachidori/README.md
deleted file mode 100644
index 70f951aa..00000000
--- a/vendor/hachidori/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Hachidori in SubMiner
-
-This is a source snapshot of Hachidori at the revision in `SOURCE.json`.
-`UPSTREAM-README.md` is the original project introduction. The extension,
-WebAssembly bindings, engine source, build scripts and licenses are kept here
-so host changes stay local to SubMiner.
-
-`bun run build:hachidori` verifies the committed engine artifacts and
-copies the extension to `build/hachidori`. The full app build includes this step,
-and Electron packaging puts the result in `resources/hachidori`.
-
-## Local changes
-
-- `extension/overlay-mode.js` enables embedded host behavior. Custom
- JavaScript is disabled because Electron does not provide `userScripts`.
-- `extension/subminer-host.js` implements the existing SubMiner popup event and
- command contract and prioritizes character-name results. `content.js` supplies
- popup lifecycle and reader actions.
-- `extension/anki-mining.js` marks initial add/overwrite requests for SubMiner's
- AnkiConnect proxy. Later Hachidori pronunciation updates remain unmarked so
- they do not repeat SubMiner media enrichment.
-- `extension/anki.js` sends those private markers only to the exact configured
- SubMiner proxy URL. Direct AnkiConnect requests use standard parameters.
-- `extension/manifest.json` loads the host bridge and drops `userScripts`.
-- The native `hdw_frequencies` binding and `hd_frequencies` engine message query
- frequency dictionaries without requiring a term dictionary. Frequency values
- retain their source reading, including whether a frequency was untagged.
-
-- External dictionary links retain local Anki templates, audio sources, and custom buttons. Dictionary requests use the host; mining and media rendering use SubMiner. Setup uses Hachidori's native link/unlink messages and verifies the live host inventory.
-
-## Rebuilding the engine
-
-Ordinary app builds use the locally rebuilt WASM and JavaScript files with
-SHA-256 checksums in `SOURCE.json`. The current engine uses Emscripten 6.0.9.
-To rebuild them, install Emscripten and the
-CMake prerequisites described in `docs/source-build.md`. Use a temporary checkout
-of `https://github.com/bee-san/hachidori` at the exact `revision` in `SOURCE.json`,
-then run `git submodule update --init --recursive`. Copy this snapshot's
-`wasm/` and `third_party/hoshidicts/` sources into that temporary checkout,
-preserving the initialized external submodules. Run `sh wasm/build.sh` and,
-if changing the unused overlay capture encoder, `sh wasm/avif/build.sh`.
-
-The snapshot's `third_party/hoshidicts` has its engine sources and `.gitmodules`;
-its external submodules are retrieved by that recursive checkout. The
-hoshidicts revision is pinned separately in `SOURCE.json`. The AVIF CMake file
-pins libavif. Copy rebuilt files from that temporary checkout's
-`extension/vendor/` only as an intentional source/artifact update and update
-the corresponding checksums. Do not overwrite the locally adapted extension.
-
-Hachidori and its modifications are GPL-3.0-or-later, see `LICENSE`.
-The engine and bundled libraries retain their own license files.
-The packaged `extension/vendor/hoshidicts-licenses/` includes dependency and
-toolchain notices for the rebuilt engine.
diff --git a/vendor/hachidori/SOURCE.json b/vendor/hachidori/SOURCE.json
deleted file mode 100644
index 94e34f2f..00000000
--- a/vendor/hachidori/SOURCE.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "repository": "https://github.com/bee-san/hachidori",
- "revision": "4be36f5e87ab946574d6c0279c6e7dd6fee36c45",
- "hoshidicts": {
- "repository": "https://github.com/bee-san/hoshidicts",
- "revision": "3066e606a75e2d79c2d2c1bc89d6c5bd2587f284"
- },
- "license": "GPL-3.0-or-later",
- "artifacts": {
- "extension/vendor/avif-encoder.mjs": "442a13571f3e8ee8117bc886e06ab52b4c5ebde7cb1659071fb498bfc0bea434",
- "extension/vendor/avif-encoder.wasm": "384b6299418d989cde5fd7660caf167aa8d4cfcedca53b2e73ddd086fc047797",
- "extension/vendor/hoshidicts-threaded-idbfs.mjs": "977cd9367539b922fc697522931d6943d6621b28897b16e754b413f15e64b8f7",
- "extension/vendor/hoshidicts-threaded-idbfs.wasm": "1e784283a8cd8c07d5eda64d7a10d6fb9cc526ac5a31c29805543d9efafe5059",
- "extension/vendor/hoshidicts-threaded.mjs": "688b3aad5ba3248609c8b3e7e3e1eee441454e51c3e691121eed254428abc6c5",
- "extension/vendor/hoshidicts-threaded.wasm": "3c78e56899d0f8f6fb60fa6af5ef85081d782014f15119516713e35ace9c97c6",
- "extension/vendor/hoshidicts.mjs": "e0116880d5fb7a3211c23aeec42d1d330326435260bf22a7e97053ba287b76f4",
- "extension/vendor/hoshidicts.wasm": "2d8b8fc5b5293f9e75d699416577f1ecb145156990fdcc6537c11793b95f231e"
- },
- "localChanges": [
- "Enable overlay mode and disable unsupported userScripts customization.",
- "Bridge popup state, successful lookups, mouse events and host commands to SubMiner.",
- "Mark initial Anki adds and overwrites for SubMiner media enrichment; background pronunciation enrichment stays unmarked.",
- "Expose native direct term/reading frequency queries, retaining reading-specific versus untagged frequency provenance in all three WASM runtimes.",
- "Prioritize SubMiner character glossaries and character-name lookup results in the host popup.",
- "Send private mining metadata only to the exact SubMiner-managed Anki proxy URL; direct AnkiConnect uses standard parameters."
- ],
- "nativeBuild": {
- "emscripten": "6.0.9",
- "runtimes": [
- "threaded-opfs",
- "threaded-idbfs",
- "single-thread-idbfs"
- ],
- "dependencies": {
- "zstd": "82d322c4973d9e2968d94047a40892bc6d9a9bdf",
- "xxHash": "c0b5ea995d66691734b1a79ad89e73a0d2fd5a53",
- "utfcpp": "c5585ef88b169a11891e03b5ca268d2925629399",
- "utf8proc": "0075ed7d0adba45682ee6bf7a83b10f8fd110163",
- "unordered_dense": "e5b9441ecf193f3e1e8f954527fc76edee20d7eb",
- "libdeflate": "b122c8be1d78b19f6d0a6efc5bb79bfcbb30dd51",
- "kanji-processor": "452cc2db9f3626a70bfb07c575c1165ae67cbdee",
- "glaze": "dda11044fb0cf4ec6b3bced58fcd8136a987e06b"
- }
- }
-}
diff --git a/vendor/hachidori/UPSTREAM-README.md b/vendor/hachidori/UPSTREAM-README.md
deleted file mode 100644
index 338ddfac..00000000
--- a/vendor/hachidori/UPSTREAM-README.md
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
-
Hachidori
-
-
The fastest, most feature rich Japanese dictionary app in the world
-
-Hachidori is a blazing fast Japanese Dictionary Chrome Extension that is feature rich and optionated.
-
-## Install in 15 seconds
-
-Click here to install from Chrome store. (note: this will always lag behind the repo and may have bugs fixed in the repo)
-
-Every GitHub release also ships an unsigned Firefox 153+ desktop package
-(`*-firefox-unsigned.xpi`) for temporary installation; it is not on
-addons.mozilla.org yet. See [the Firefox guide](docs/firefox.md); media
-recording is not included in that edition.
-
-
-
-
-
-# Blazing Fast
-
-Hachidori is 83 times faster than the worlds most popular Japanese dictionary app at importing dictionaries.
-
-
-
-See [the measured results](docs/browser-performance.md).
-
-
-# Media mining
-
-
-
-Hachidori can record your screen and capture sentence audio + a gif. Not just in Chrome but in all windows on your desktop.
-
-This feature is **experimental** and may not work very well. I may remove it or reduce it also.
-
-See [Media mining setup, limits, and verification](docs/media-capture.md).
-
-# Custom Dictionary
-
-Do you keep on seeing a name pop up over & over again in a book, but it's not in the dictionary?
-
-With Hachidori, you can highlight the word and add it as a custom definition.
-
-
-
-
-
-# Lookup blur
-
-Sometimes we fall into a trap of looking up a word over & over again, but never learning it.
-
-Hachidori records how many times you have looked up a word and can blur it for you for a few seconds to force you to remember it.
-
-It can even use anki.
-
-
-
-
-
- `;
- const row = { element, voiceVersion: -1 };
- for (const name of ["enabled", "number", "type", "url", "voice", "up", "down", "remove", "test"]) {
- row[name] = element.querySelector(`.audio-${name}`);
- }
- row.status = element.querySelector(".audio-test-status");
- row.urlField = element.querySelector(".audio-url-field");
- row.voiceField = element.querySelector(".audio-voice-field");
- for (const type of window.HDReaderOptions.AUDIO_SOURCE_TYPES) row.type.add(new window.Option(labels[type], type));
- for (const name of ["enabled", "type", "url", "voice"]) row[name].id = `opt-audio-${name}-${source.id}`;
- row.enabled.addEventListener("change", () => change(source.id, { enabled: row.enabled.checked }));
- row.type.addEventListener("change", () => change(source.id, { type: row.type.value, url: "", voice: "" }));
- row.url.addEventListener("input", () => change(source.id, { url: row.url.value }));
- row.voice.addEventListener("change", () => change(source.id, { voice: row.voice.value }));
- row.up.addEventListener("click", () => move(source.id, -1));
- row.down.addEventListener("click", () => move(source.id, 1));
- row.remove.addEventListener("click", () => {
- editSources(readSources().filter(item => item.id !== source.id));
- render();
- document.getElementById("audio-source-add").focus();
- });
- row.test.addEventListener("click", () => { void testSource(source.id, row); });
- element.addEventListener("focusout", () => { window.queueMicrotask(render); });
- return row;
- }
-
- function renderVoice(row, source) {
- if (row.voice === document.activeElement) return;
- if (row.voiceVersion !== voiceVersion || row.voice.value !== source.voice) {
- row.voice.replaceChildren(new window.Option("Automatic Japanese", ""));
- for (const voice of voices) row.voice.add(new window.Option(
- `${voice.name} (${voice.lang})${voice.localService ? "" : " — online"}`, voice.voiceURI));
- if (source.voice && !voices.some(voice => voice.voiceURI === source.voice)) {
- row.voice.add(new window.Option(`${source.voice} — unavailable`, source.voice));
- }
- row.voice.value = source.voice;
- row.voiceVersion = voiceVersion;
- }
- }
-
- function render() {
- const sources = readSources();
- if (active && active.source !== JSON.stringify(sources.find(source => source.id === active.id))) stop();
- const ids = new Set(sources.map(source => source.id));
- for (const [id, row] of rows) {
- if (!ids.has(id)) { row.element.remove(); rows.delete(id); }
- }
- const ordered = sources.map((source, index) => {
- if (!rows.has(source.id)) rows.set(source.id, createRow(source));
- const row = rows.get(source.id);
- if (row.testedSource && row.testedSource !== JSON.stringify(source)) {
- row.status.textContent = "";
- row.testedSource = null;
- }
- if (row.enabled.checked !== source.enabled) row.enabled.checked = source.enabled;
- const number = `Source ${index + 1}`;
- if (row.number.textContent !== number) row.number.textContent = number;
- for (const key of ["type", "url"]) {
- if (row[key] !== document.activeElement && row[key].value !== source[key]) row[key].value = source[key];
- }
- const speech = source.type.startsWith("text-to-speech");
- if (row.urlField.hidden !== speech) row.urlField.hidden = speech;
- if (row.voiceField.hidden === speech) row.voiceField.hidden = !speech;
- if (speech) renderVoice(row, source);
- if (row.up.disabled !== (index === 0)) row.up.disabled = index === 0;
- if (row.down.disabled !== (index === sources.length - 1)) row.down.disabled = index === sources.length - 1;
- for (const [key, label] of [["up", "Move up"], ["down", "Move down"], ["remove", "Remove"]]) {
- labelControl(row[key], `${label}: source ${index + 1}`);
- }
- setTesting(row, active?.id === source.id);
- return row.element;
- });
- reorderSettingsRows(list, ordered);
- const empty = document.getElementById("audio-source-empty");
- if (empty.hidden !== (sources.length > 0)) empty.hidden = sources.length > 0;
- }
-
- function adoptVoices(value) {
- voices = [...value].sort((a, b) => Number(/^ja(?:[-_]|$)/i.test(b.lang)) - Number(/^ja(?:[-_]|$)/i.test(a.lang)));
- voiceVersion += 1;
- render();
- }
- const voiceListener = message => {
- if (message?.target === "hachidori-audio-ui" && message.type === "hd_audio_voices_changed") adoptVoices(message.voices);
- };
- extensionApi.runtime.onMessage.addListener(voiceListener);
- const initialVersion = voiceVersion;
- void send("hd_audio_voices").then(reply => {
- if (reply.ok && voiceVersion === initialVersion) adoptVoices(reply.voices);
- }).catch(() => {}); // Test reports any current playback/voice failure itself.
- document.getElementById("audio-source-add").addEventListener("click", () => {
- const source = { id: window.crypto.randomUUID(), type: "custom", enabled: true, url: "", voice: "" };
- editSources([...readSources(), source]);
- render();
- rows.get(source.id).url.focus();
- });
- window.addEventListener("pagehide", () => { stop(); extensionApi.runtime.onMessage.removeListener(voiceListener); }, { once: true });
- return { render, stop };
-}
diff --git a/vendor/hachidori/extension/audio-sources.js b/vendor/hachidori/extension/audio-sources.js
deleted file mode 100644
index e824296d..00000000
--- a/vendor/hachidori/extension/audio-sources.js
+++ /dev/null
@@ -1,40 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Browser-native source rules adapted from GSM PR #549's hoshidicts_audio.py
-// and hoshidicts_audio_profile.py. Configuration remains global reader options.
-
-function httpUrl(value) {
- if (typeof value !== "string" || /[\u0000-\u001f]/u.test(value)) {
- throw new Error("Audio URLs must be absolute HTTP(S) URLs.");
- }
- const url = new URL(value);
- if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port === "0") {
- throw new Error("Audio URLs must use HTTP(S) without a username or password.");
- }
- if (/[{}]/u.test(url.host)) throw new Error("Audio URL placeholders cannot appear in the host.");
- return url.href;
-}
-
-function encodeValue(value) {
- return encodeURIComponent(value).replace(/[!'()*]/gu, char => `%${char.codePointAt(0).toString(16).toUpperCase()}`);
-}
-
-export function audioSourceUrl(template, { expression, reading }) {
- httpUrl(template);
- const encodedTerm = encodeValue(expression);
- const values = { term: encodedTerm, expression: encodedTerm, reading: encodeValue(reading), language: "ja" };
- return httpUrl(template.replace(/\{([^{}]*)\}/gu, (match, key) => Object.hasOwn(values, key) ? values[key] : match));
-}
-
-export function parseAudioSourceList(value) {
- if (value?.type !== "audioSourceList" || !Array.isArray(value.audioSources)
- || Object.keys(value).some(key => !["type", "audioSources"].includes(key))) {
- throw new Error("The audio provider returned an invalid Yomitan audioSourceList.");
- }
- return value.audioSources.map(candidate => {
- if (!candidate || typeof candidate.url !== "string" || (candidate.name !== undefined && typeof candidate.name !== "string")
- || Object.keys(candidate).some(key => !["url", "name"].includes(key))) {
- throw new Error("The audio provider returned an invalid pronunciation candidate.");
- }
- return { url: httpUrl(candidate.url), name: candidate.name ?? "" };
- });
-}
diff --git a/vendor/hachidori/extension/avif-sequence.js b/vendor/hachidori/extension/avif-sequence.js
deleted file mode 100644
index f5c6d7f4..00000000
--- a/vendor/hachidori/extension/avif-sequence.js
+++ /dev/null
@@ -1,135 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import {
- CAPTURE_SAMPLE_RATE,
- MAX_ANIMATED_AVIF_BYTES,
-} from "./media-limits.js";
-
-export { MAX_ANIMATED_AVIF_BYTES };
-export const AVIF_TIMESCALE = CAPTURE_SAMPLE_RATE;
-
-function encoderError(module, handle) {
- return module.UTF8ToString(module._hda_last_error(handle)) || "Animated AVIF encoding failed.";
-}
-
-export function frameDurations(frames, endMs, timescale = AVIF_TIMESCALE) {
- if (!Array.isArray(frames) || !frames.length || !Number.isFinite(endMs)
- || !Number.isSafeInteger(timescale) || timescale < 1) {
- throw new Error("AVIF frame timing is invalid");
- }
- for (let index = 0; index < frames.length; index += 1) {
- const frame = frames[index];
- if (!Number.isFinite(frame.timestampMs)
- || (index > 0 && frame.timestampMs <= frames[index - 1].timestampMs)) {
- throw new Error("AVIF frame timestamps must increase");
- }
- }
- const startMs = frames[0].timestampMs;
- let previousTick = 0;
- return frames.map((frame, index) => {
- const nextMs = index + 1 < frames.length ? frames[index + 1].timestampMs : endMs;
- if (nextMs <= frame.timestampMs) throw new Error("AVIF frame duration is empty");
- // Quantize cumulative boundaries, so rounding never drifts across frames.
- // The final ceil matches the WAV's exact number of PCM samples.
- const nextTick = index + 1 === frames.length
- ? Math.ceil((nextMs - startMs) * timescale / 1000)
- : Math.round((nextMs - startMs) * timescale / 1000);
- const duration = nextTick - previousTick;
- if (duration < 1) throw new Error("AVIF frame duration is below the timebase precision");
- previousTick = nextTick;
- return duration;
- });
-}
-
-export function createAvifSequenceEncoder(module, {
- width,
- height,
- timescale = AVIF_TIMESCALE,
- quality = 55,
- speed = 8,
-} = {}) {
- const handle = module._hda_create(width, height, timescale, quality, speed);
- if (!handle) throw new Error(encoderError(module, 0));
- let finished = false;
- return {
- add(rgba, duration) {
- if (finished) throw new Error("AVIF sequence is already finished");
- if (!(rgba instanceof Uint8Array) || rgba.byteLength !== width * height * 4) {
- throw new Error("AVIF RGBA frame has the wrong size");
- }
- const pointer = module._malloc(rgba.byteLength);
- if (!pointer) throw new Error("Could not allocate AVIF frame memory.");
- try {
- module.HEAPU8.set(rgba, pointer);
- if (!module._hda_add_rgba(handle, pointer, rgba.byteLength, duration)) {
- throw new Error(encoderError(module, handle));
- }
- } finally {
- module._free(pointer);
- }
- },
- finish() {
- if (finished) throw new Error("AVIF sequence is already finished");
- finished = true;
- if (!module._hda_finish(handle)) throw new Error(encoderError(module, handle));
- const size = module._hda_output_size(handle);
- if (!size || size > MAX_ANIMATED_AVIF_BYTES) throw new Error("Animated AVIF exceeds its output limit.");
- const pointer = module._hda_output(handle);
- return module.HEAPU8.slice(pointer, pointer + size);
- },
- destroy() {
- module._hda_destroy(handle);
- },
- };
-}
-
-export async function encodeJpegSequence(module, frames, {
- endMs,
- quality = 55,
- speed = 8,
- createBitmap = globalThis.createImageBitmap?.bind(globalThis),
- createCanvas = (width, height) => new OffscreenCanvas(width, height),
- onProgress = () => {},
-} = {}) {
- if (!Array.isArray(frames) || !frames.length || typeof createBitmap !== "function") {
- throw new Error("Captured video frames cannot be decoded.");
- }
- const [{ width, height }] = frames;
- if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) throw new Error("Captured frame dimensions are invalid.");
- if (frames.some(frame => frame.width !== width || frame.height !== height)) {
- throw new Error("Captured frame dimensions changed during the selected interval.");
- }
- const durations = frameDurations(frames, endMs);
- if (frames.length === 1 && durations[0] < 2) {
- throw new Error("AVIF sequence duration is below the timebase precision");
- }
- const canvas = createCanvas(width, height);
- const context = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
- if (!context) throw new Error("Could not create the AVIF frame decoder canvas.");
- const encoder = createAvifSequenceEncoder(module, { width, height, quality, speed });
- try {
- for (let index = 0; index < frames.length; index += 1) {
- const frame = frames[index];
- const bitmap = await createBitmap(new Blob([frame.data], { type: "image/jpeg" }));
- try {
- context.drawImage(bitmap, 0, 0, width, height);
- const rgba = context.getImageData(0, 0, width, height).data;
- const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
- if (frames.length === 1) {
- // libavif writes one image as a still AVIF without sequence timing.
- // Repeat its pixels while preserving the exact selected sample count.
- const firstDuration = Math.floor(durations[index] / 2);
- encoder.add(pixels, firstDuration);
- encoder.add(pixels, durations[index] - firstDuration);
- } else {
- encoder.add(pixels, durations[index]);
- }
- } finally {
- bitmap.close();
- }
- onProgress(index + 1, frames.length);
- }
- return encoder.finish();
- } finally {
- encoder.destroy();
- }
-}
diff --git a/vendor/hachidori/extension/background.js b/vendor/hachidori/extension/background.js
deleted file mode 100644
index 3b9f136b..00000000
--- a/vendor/hachidori/extension/background.js
+++ /dev/null
@@ -1,3358 +0,0 @@
-import { extensionApi as chrome, IS_FIREFOX } from "./browser-api.js";
-import { ensureChromeOffscreen } from "./chrome-offscreen.js";
-import { waitForFirefoxOffscreen } from "./firefox-host.js";
-import "./reader-options.js";
-import { createAnkiGateway } from "./anki.js";
-import { detectAnkiSetup, verifyAnkiSetup } from "./anki-setup.js";
-import { createAnkiWorkerService } from "./anki-worker.js";
-import { detectLocalAudioSource } from "./local-audio-setup.js";
-import { createLocalAudioSource, findLocalAudioSource } from "./local-audio-source.js";
-import { lookupAnkiIndex } from "./anki-index.js";
-import { ANKI_INDEX_ALARM, ANKI_INDEX_KEY, ankiIndexConfigurationChange, createAnkiDuplicateIndex } from "./anki-index-cache.js";
-import { createBackupDownloads } from "./backup-downloads.js";
-import { assertBackupSnapshot, backupRevisions } from "./backup-state.js";
-import {
- AUTOMATIC_BACKUP_ALARM,
- AUTOMATIC_BACKUPS_KEY,
- automaticBackupDue,
- automaticBackupStore,
- nextAutomaticBackupTime,
- replaceAutomaticBackup,
- validAutomaticBackups,
-} from "./backup-automatic.js";
-import { SHARING_HOST_ALARM, SHARING_KEY, createSharingHost } from "./sharing-host.js";
-import { API_REQUESTS, createApiHost } from "./api-host.js";
-import { NOT_REACHABLE, SHARING_LOCAL_STATE_KEY, createSharingClient } from "./sharing-client.js";
-import {
- API_CAPABILITY, FORWARDED_REQUESTS, LINKED_ANKI_CAPABILITY, LINKED_ANKI_UNSUPPORTED, SHARING_CAPABILITIES,
- allowLinkedAnkiDiscoveryRequest, allowLinkedAnkiRequest, allowLinkedAnkiSetupRequest,
- browserName, forwardableRequest, mutatingForwardedRequest, parseLinkAddress,
-} from "./sharing-protocol.js";
-import { LOOKUP_STATS_KEY, LOOKUP_STATS_ROW_PREFIX, assertLookupStatsDescriptor, assertLookupStatsRows, emptyLookupStats, incrementLookupStats, lookupStatsKey, lookupStatsPrefix, normaliseLookupTerm } from "./lookup-stats.js";
-import "./external-links.js";
-import "./dictionary-group-state.js";
-import {
- assertDictionaryUpdateSchedule,
- httpsUrl,
- installedRecommendedDictionary,
- MANAGED_DICTIONARY_CHANGED,
- managedDictionaryFingerprint,
- managedDictionaryMatches,
- managedDictionarySource,
- managedUpdateSchedule,
- nextDictionaryUpdateCheck,
- nextManagedUpdateCheck,
- normaliseUpdateSettings,
- recommendedDictionarySource,
- recommendedIndexUrlMatches,
-} from "./managed-dictionary-source.js";
-import {
- CUSTOM_DICTIONARY_ID,
- CUSTOM_DICTIONARY_SOURCE_KEY,
- CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
- CUSTOM_DICTIONARY_TITLE,
- assertCustomDictionaryCommit,
- assertCustomSourceState,
- customDictionarySemanticRevision,
- normaliseCustomDictionaryDocument,
- parseCustomDictionary,
-} from "./custom-dictionary.js";
-import { sameJsonValue } from "./json-value.js";
-import {
- boundResponseFailure, responseFits, responseLimitError, validResponseRequestId,
-} from "./response-limits.js";
-import { HOST_CAPABILITIES, MINING_CAPABILITIES, OVERLAY_MODE } from "./overlay-mode.js";
-import {
- FIRST_INSTALL_OPTIONS, FIRST_INSTALL_SELECTIONS, OVERLAY_MODE_OPTIONS, SETUP_STATE_KEY, STARTUP_PAGE,
- RECOMMENDED_SELECTIONS_KEY, OVERLAY_LOCAL_OPTION_KEYS,
- advanceSetupState, capabilityAnkiOptions, initialSetupState, normaliseSetupState, overlayAnkiOptions, recordSetupAnki, recordSetupDictionaries,
-} from "./setup-state.js";
-import { applyCustomJavaScript } from "./custom-javascript.js";
-
-const {
- ANKI_TEMPLATE_CONFIG_KEYS, DEFAULT_OPTIONS, ankiTemplateConfig, normaliseOptions, projectStoredOptions,
- validateOptionsPatch,
-} = globalThis.HDReaderOptions;
-const { normaliseExternalUrl } = globalThis.HDExternalLinks;
-const { pruneGroupMemberships } = globalThis.HDDictionaryGroups;
-
-/*
- * Service worker for Hachidori.
- *
- * The worker holds no engine state: it only guarantees that the offscreen
- * document exists and relays requests to it. The engine lives in the offscreen
- * document because a service worker is torn down after 30 s idle, which would
- * throw away the loaded dictionaries.
- *
- * It owns the revisioned `dictionaryState` value and dictionary-backed option
- * writes in chrome.storage.local. An offscreen document is granted chrome.runtime
- * and nothing else -- no chrome.storage -- so every read and write the engine
- * needs arrives here as a message.
- *
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-const OFFSCREEN_DOCUMENT = "offscreen.html";
-const TARGET = "hoshidicts-offscreen";
-const UPDATE_TARGET = "hachidori-updates";
-const AUDIO_TARGET = "hachidori-audio";
-const CAPTURE_TARGET = "hachidori-capture";
-const CAPTURE_PAGE_TARGET = "hachidori-capture-page";
-const CAPTURE_CONTENT_TARGET = "hachidori-capture-content";
-const CAPTURE_DOCUMENT = "capture.html";
-const SETUP_TARGET = "hachidori-setup";
-const PAGE_ZOOM_TARGET = "hachidori-page-zoom";
-const BACKUP_LIFECYCLE_PORT = "hachidori-backup-settings";
-
-// Requests the worker answers itself. A second target is what keeps them out of
-// the relay below: a message from the offscreen document carrying TARGET is
-// indistinguishable from one sent by an extension page, so it would be stamped
-// `relayed` and handed straight back to the offscreen document, where the
-// engine's own request queue would then wait on itself.
-const WORKER_TARGET = "hoshidicts-worker";
-let ankiGateway, ankiMining, ankiDuplicateIndex;
-let activeAnkiOperations = 0;
-const ankiIdleWaiters = new Set();
-
-function trackAnkiOperation(job) {
- activeAnkiOperations += 1;
- return Promise.resolve().then(job).finally(() => {
- activeAnkiOperations -= 1;
- if (activeAnkiOperations !== 0) return;
- for (const resolve of ankiIdleWaiters) resolve();
- ankiIdleWaiters.clear();
- });
-}
-
-function waitForAnkiIdle() {
- if (activeAnkiOperations === 0) return Promise.resolve();
- return new Promise(resolve => ankiIdleWaiters.add(resolve));
-}
-let backupDownloads;
-let automaticBackupRun = null;
-let automaticBackupNextAt = null;
-let automaticBackupWaitingForState = false;
-// One first-run Anki detection at a time; duplicate startup pages share it.
-let ankiSetupDetection = null;
-
-function getBackupDownloads() {
- backupDownloads ??= createBackupDownloads(chrome, relay);
- return backupDownloads;
-}
-
-const DICTIONARY_STATE_KEY = "dictionaryState";
-const LEGACY_DICTIONARIES_KEY = "dictionaries";
-const OPTIONS_KEY = "options";
-const UPDATE_SETTINGS_KEY = "dictionaryUpdates";
-const UPDATE_ALARM = "hachidori-managed-dictionary-updates";
-const AUTOMATIC_BACKUP_RETRY_MS = 60 * 60 * 1000;
-const DICTIONARY_STATE_SCHEMA_VERSION = 1;
-const KANJI_SELECTION_KINDS = new Set(["term", "kanji"]);
-// The overlay host (Electron 43) exposes chrome.alarms, and create()/get()
-// even record the alarm, but onAlarm never dispatches to the worker; a host
-// without the API at all behaves the same. Keep the one-shot contract on
-// worker-lifetime timers there: a worker restart re-runs the module-load
-// reconciliation, which re-arms whatever is still due.
-const alarms = chrome.alarms && !OVERLAY_MODE ? chrome.alarms : createTimerAlarms();
-
-function createTimerAlarms() {
- const MAX_TIMER_MS = 2 ** 31 - 1;
- const pending = new Map();
- function arm(name, when) {
- pending.get(name).timer = setTimeout(() => {
- if (Date.now() < when) {
- arm(name, when);
- return;
- }
- pending.delete(name);
- handleAlarm({ name, scheduledTime: when });
- }, Math.min(Math.max(when - Date.now(), 0), MAX_TIMER_MS));
- }
- return {
- async create(name, { when, delayInMinutes }) {
- clearTimeout(pending.get(name)?.timer);
- const scheduledTime = when ?? Date.now() + delayInMinutes * 60_000;
- pending.set(name, { when: scheduledTime });
- arm(name, scheduledTime);
- },
- async get(name) {
- const entry = pending.get(name);
- return entry ? { name, scheduledTime: entry.when } : undefined;
- },
- async clear(name) {
- const entry = pending.get(name);
- if (!entry) return false;
- clearTimeout(entry.timer);
- pending.delete(name);
- return true;
- },
- };
-}
-
-// The user data a linked browser mirrors: the same five keys a backup carries,
-// plus the lookup-count rows.
-const SHARED_STATE_KEYS = [DICTIONARY_STATE_KEY, OPTIONS_KEY, CUSTOM_DICTIONARY_SOURCE_KEY, UPDATE_SETTINGS_KEY, LOOKUP_STATS_KEY];
-// What this install is called by the ones it shares with or links to.
-const SHARING_NAME = OVERLAY_MODE ? "GameSentenceMiner overlay" : browserName(globalThis.navigator);
-let sharingHost;
-
-function dictionaryCount(state) {
- return Array.isArray(state?.dictionaries) ? state.dictionaries.length : 0;
-}
-
-async function readSharedState() {
- const stored = await chrome.storage.local.get(SHARED_STATE_KEYS);
- return Object.fromEntries(SHARED_STATE_KEYS.map(key => [key, stored[key] ?? null]));
-}
-
-function getSharingHost() {
- sharingHost ??= createSharingHost({
- WebSocket: globalThis.WebSocket,
- alarms,
- dispatch: dispatchSharedRequest,
- readSnapshot: readSharedState,
- sharedKey: key => SHARED_STATE_KEYS.includes(key) || key.startsWith(LOOKUP_STATS_ROW_PREFIX),
- version: chrome.runtime.getManifest().version,
- name: SHARING_NAME,
- capabilities: [...SHARING_CAPABILITIES, API_CAPABILITY],
- });
- return sharingHost;
-}
-
-// The relay's API asks like a linked browser; its lookups and renders go
-// through the same engine and offscreen senders as Anki mining.
-let apiHost;
-function getApiHost() {
- apiHost ??= createApiHost({
- version: chrome.runtime.getManifest().version,
- engine: fields => sendAnkiRequest(TARGET, fields),
- render: fields => sendAnkiRequest("hachidori-anki-render", fields),
- readDictionaries: async () => (await readDictionaryStorage()).state?.dictionaries ?? [],
- readAudioSources: async () => (await readAnkiOptions()).audioSources.filter(source => source.enabled),
- });
- return apiHost;
-}
-
-// Client side: this install uses another Hachidori. `sharingLinked` is read
-// synchronously by the interception points below after `sharingReady`.
-let sharingClient;
-let sharingLinked = false;
-let sharingEpoch = 0;
-let sharingReady = Promise.resolve();
-let sharingTransitionTail = Promise.resolve();
-const WORKER_FORWARDS = FORWARDED_REQUESTS[WORKER_TARGET];
-const SHARING_OPTIONS_VERSION_KEY = "sharingOptionsVersion";
-const OVERLAY_OPTIONS_STORAGE_KEYS = [OPTIONS_KEY, DICTIONARY_STATE_KEY, SHARING_LOCAL_STATE_KEY, SHARING_OPTIONS_VERSION_KEY];
-const linkedAnkiConfigPrefix = `linked:${crypto.randomUUID()}:`;
-
-function linkedAnkiConfigKey(configKey) {
- return `${linkedAnkiConfigPrefix}${String(configKey ?? "")}`;
-}
-
-function hostLinkedAnkiRequest(request) {
- if (typeof request?.configKey !== "string" || !request.configKey.startsWith(linkedAnkiConfigPrefix)) {
- throw new Error("Anki configuration changed. Refresh this result before adding a note.");
- }
- return { ...request, configKey: request.configKey.slice(linkedAnkiConfigPrefix.length) };
-}
-
-function engineSender(sender) {
- return sender?.id === chrome.runtime.id && sender.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT);
-}
-
-function ankiSettingsSender(sender) {
- return sender?.id === chrome.runtime.id
- && sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL("settings.html");
-}
-
-// While linked, this install's own engine keeps reading and committing the
-// state it had before linking, so its generations are never judged against
-// the host's inventory that the mirror now holds under the live keys.
-const sharingLocalStore = {
- async get(keys) {
- const record = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY] ?? {};
- const list = Array.isArray(keys) ? keys : [keys];
- return Object.fromEntries(list.filter(key => record[key] !== null && record[key] !== undefined).map(key => [key, record[key]]));
- },
- async set(values) {
- const record = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY] ?? {};
- await chrome.storage.local.set({ [SHARING_LOCAL_STATE_KEY]: { ...record, ...values } });
- },
-};
-
-function stateStore(sender) {
- return sharingLinked && engineSender(sender) ? sharingLocalStore : chrome.storage.local;
-}
-
-function composeOverlayOptions(shared, local, revision) {
- const preferences = normaliseOptions(local);
- return { ...projectStoredOptions(shared),
- ...Object.fromEntries(OVERLAY_LOCAL_OPTION_KEYS.map(key => [key, preferences[key]])), revision };
-}
-
-// The offset keeps one increasing revision for existing readers and Settings,
-// while retaining the host's actual CAS revision for forwarded writes.
-function overlayHostOptionsValues(shared, stored, snapshot = false) {
- const previous = stored[SHARING_OPTIONS_VERSION_KEY];
- const hostRevision = optionsRevision(shared);
- if (previous && !snapshot && shared !== null && hostRevision < previous.hostRevision) return {};
- const revision = optionsRevision(stored[OPTIONS_KEY]);
- const offset = !previous || hostRevision < previous.hostRevision
- ? Math.max(previous?.offset ?? 0, revision + 1 - hostRevision) : previous.offset;
- return {
- [OPTIONS_KEY]: composeOverlayOptions(shared, stored[SHARING_LOCAL_STATE_KEY]?.options ?? stored[OPTIONS_KEY], hostRevision + offset),
- [SHARING_OPTIONS_VERSION_KEY]: { hostRevision, offset },
- };
-}
-
-// Mirror host batches together; overlay options additionally retain their
-// local preferences and translate the host's revision for existing consumers.
-async function applyMirror(changes, snapshot = false) {
- const values = {};
- const removals = [];
- if (OVERLAY_MODE && Object.hasOwn(changes, OPTIONS_KEY)) {
- Object.assign(values, overlayHostOptionsValues(changes[OPTIONS_KEY],
- await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS), snapshot));
- }
- for (const [key, value] of Object.entries(changes)) {
- if (OVERLAY_MODE && key === OPTIONS_KEY) continue;
- if (value === null) removals.push(key);
- else values[key] = value;
- }
- if (Object.keys(values).length > 0) await chrome.storage.local.set(values);
- if (removals.length > 0) await chrome.storage.local.remove(removals);
-}
-
-function getSharingClient() {
- sharingClient ??= createSharingClient({
- WebSocket: globalThis.WebSocket,
- applyBatch: (changes, isCurrent, snapshot) => serialiseStorage(() => {
- // Unlink or a replacement connection may have retired this batch while
- // it waited behind the restoration's storage writes.
- if (sharingLinked && isCurrent()) return applyMirror(changes, snapshot);
- }),
- version: chrome.runtime.getManifest().version,
- name: SHARING_NAME,
- });
- return sharingClient;
-}
-
-function sharingStatus() {
- const client = getSharingClient().status();
- return { ...getSharingHost().status(), client: { ...client, display: client.address === null ? null : parseLinkAddress(client.address).display } };
-}
-
-function forwardToHost(message, capability = null) {
- return getSharingClient().forward(message, {
- capability,
- mutation: mutatingForwardedRequest(message),
- }).catch(error => failureReply(message, error));
-}
-
-function linkedOptionsCapability(message) {
- if (message?.type !== "hd_options_write") return null;
- const patch = message.options;
- return patch && typeof patch === "object"
- && (Object.hasOwn(patch, "anki") || Object.hasOwn(patch, "customButtons"))
- ? LINKED_ANKI_CAPABILITY
- : null;
-}
-
-const LINKED_SETTINGS_UPDATE_REQUIRED =
- "Update the linked Hachidori before editing Templates or Custom Buttons.";
-
-function assignMatchingLegacyLinks(incoming, currentLinks, available, assigned) {
- for (const [incomingIndex, button] of incoming.entries()) {
- const match = currentLinks.findIndex((candidate, currentIndex) => available.has(currentIndex)
- && candidate.label === button.label && candidate.url === button.url);
- if (match < 0) continue;
- assigned[incomingIndex] = match;
- available.delete(match);
- }
-}
-
-function assignPositionedLegacyLinks(incoming, available, assigned) {
- for (let index = 0; index < incoming.length; index += 1) {
- if (assigned[index] >= 0 || !available.has(index)) continue;
- assigned[index] = index;
- available.delete(index);
- }
-}
-
-function mergeLegacyCustomLinks(currentButtons, links) {
- const incoming = validateOptionsPatch({ customLinks: links }).customButtons;
- const currentLinks = currentButtons.filter(button => button.type === "link");
- const assigned = new Array(incoming.length).fill(-1);
- const available = new Set(currentLinks.map((_, index) => index));
- // Preserve identity through legacy reordering before treating a changed row
- // as an edit of the link that occupied the same legacy position.
- assignMatchingLegacyLinks(incoming, currentLinks, available, assigned);
- assignPositionedLegacyLinks(incoming, available, assigned);
- const usedIds = new Set(currentButtons.filter(button => button.type !== "link").map(button => button.id));
- for (const currentIndex of assigned) {
- if (currentIndex >= 0) usedIds.add(currentLinks[currentIndex].id);
- }
- let generated = 1;
- const nextLinks = incoming.map((button, index) => {
- if (assigned[index] >= 0) return { ...button, id: currentLinks[assigned[index]].id };
- let id = `legacy-link-${generated++}`;
- while (usedIds.has(id)) id = `legacy-link-${generated++}`;
- usedIds.add(id);
- return { ...button, id };
- });
- let linkIndex = 0;
- const merged = [];
- for (const button of currentButtons) {
- if (button.type === "link") {
- if (linkIndex < nextLinks.length) merged.push(nextLinks[linkIndex++]);
- } else {
- merged.push(button);
- }
- }
- merged.push(...nextLinks.slice(linkIndex));
- return merged;
-}
-
-function mergeLegacyAnki(current, legacy) {
- const first = {
- id: current.templates[0].id,
- name: current.templates[0].name,
- ...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, legacy[key]])),
- };
- return {
- url: legacy.url,
- apiKey: legacy.apiKey,
- templates: [first, ...current.templates.slice(1)],
- ...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, first[key]])),
- };
-}
-
-async function compatibleLinkedWorkerMessage(message, sender) {
- const capabilities = sender.linkedCapabilities;
- if (message.type !== "hd_options_write" || !Array.isArray(capabilities)
- || capabilities.includes(LINKED_ANKI_CAPABILITY)) return message;
- const patch = message.options;
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) return message;
- const richAnki = patch.anki && typeof patch.anki === "object"
- && Object.hasOwn(patch.anki, "templates");
- if (Object.hasOwn(patch, "customButtons") || richAnki) {
- throw new Error(LINKED_SETTINGS_UPDATE_REQUIRED);
- }
- if (!Object.hasOwn(patch, "customLinks") && !Object.hasOwn(patch, "anki")) return message;
- const stored = normaliseOptions((await chrome.storage.local.get(OPTIONS_KEY))[OPTIONS_KEY]);
- const compatible = { ...patch };
- if (Object.hasOwn(patch, "customLinks")) {
- compatible.customButtons = mergeLegacyCustomLinks(stored.customButtons, patch.customLinks);
- delete compatible.customLinks;
- }
- if (Object.hasOwn(patch, "anki")) {
- const legacy = validateOptionsPatch({ anki: patch.anki }).anki;
- compatible.anki = mergeLegacyAnki(stored.anki, legacy);
- }
- return { ...message, options: compatible };
-}
-
-function forwardWorkerRequest(message) {
- return OVERLAY_MODE && message.type === "hd_options_write"
- ? writeLinkedOverlayOptions(message)
- : forwardToHost(message, linkedOptionsCapability(message));
-}
-
-async function readAnkiOptions() {
- const options = normaliseOptions((await chrome.storage.local.get(OPTIONS_KEY))[OPTIONS_KEY]);
- return capabilityAnkiOptions(options, {
- screenshot: MINING_CAPABILITIES.screenshot,
- browserSpeech: MINING_CAPABILITIES.browserSpeech,
- mediaCapture: HOST_CAPABILITIES.mediaCapture,
- });
-}
-
-// Called within the background storage queue. Options and index invalidation
-// share one write so a delayed storage event cannot publish an obsolete pull.
-async function writeLocalState(values, store = chrome.storage.local) {
- if (store === chrome.storage.local && Object.hasOwn(values, OPTIONS_KEY)) {
- const stored = await chrome.storage.local.get([OPTIONS_KEY, ANKI_INDEX_KEY]);
- const index = await ankiIndexConfigurationChange(
- normaliseOptions(stored[OPTIONS_KEY]), normaliseOptions(values[OPTIONS_KEY]), stored[ANKI_INDEX_KEY],
- );
- if (index !== undefined) values = { ...values, [ANKI_INDEX_KEY]: index };
- }
- await store.set(values);
-}
-
-function getAnkiDuplicateIndex() {
- ankiDuplicateIndex ??= createAnkiDuplicateIndex({
- fetchRows: async source => {
- const reply = await relay({ target: "hachidori-anki-render", type: "hd_anki_index_refresh",
- requestId: `anki-index-${crypto.randomUUID()}`, source });
- if (!reply.ok) throw new Error(reply.error);
- return reply.rows;
- },
- lookupLive: (source, expression, invoke) => lookupAnkiIndex(invoke, source, expression),
- readOptions: readAnkiOptions,
- readState: async () => (await chrome.storage.local.get(ANKI_INDEX_KEY))[ANKI_INDEX_KEY],
- updateState: update => serialiseStorage(async () => {
- const stored = await chrome.storage.local.get([OPTIONS_KEY, ANKI_INDEX_KEY]);
- const state = stored[ANKI_INDEX_KEY];
- const next = await update({ options: normaliseOptions(stored[OPTIONS_KEY]), state });
- if (next !== undefined && !sameJsonValue(state, next)) {
- await writeLocalState({ [ANKI_INDEX_KEY]: next });
- }
- return next ?? state;
- }),
- alarms,
- });
- return ankiDuplicateIndex;
-}
-// A relayed request can arrive in the window between createDocument() resolving
-// and offscreen.js running its module body, where nothing is listening yet.
-const RELAY_ATTEMPTS = 5;
-const RELAY_BACKOFF_MS = 40;
-const NOT_LISTENING = /Receiving end does not exist|Could not establish connection/i;
-
-// True after the offscreen document answered a relayed request; cleared when a
-// relay gets no reply, so the next attempt verifies the document again.
-let offscreenAnswered = false;
-let latestAudioOperation = null;
-let capturePage = null;
-let captureRecovery = null;
-let captureContentDocument = null;
-let captureLink = null;
-
-function describe(error) {
- if (error instanceof Error) {
- return error.message || String(error);
- }
- return typeof error === "string" ? error : JSON.stringify(error);
-}
-
-function sleep(ms) {
- return new Promise((resolve) => {
- setTimeout(resolve, ms);
- });
-}
-
-function capturePageSender(sender) {
- return sender.id === chrome.runtime.id
- && sender.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT)
- && sender.tab === undefined;
-}
-
-function trustedCaptureControl(sender) {
- if (sender.id !== chrome.runtime.id || typeof sender.url !== "string") return false;
- try {
- const url = new URL(sender.url);
- if (url.search) return false;
- url.hash = "";
- return ["settings.html", "toolbar.html", CAPTURE_DOCUMENT]
- .some(document => url.href === chrome.runtime.getURL(document));
- } catch {
- return false;
- }
-}
-
-async function relayCapture(message, stillCurrent = null) {
- if (!capturePage || captureRecovery) await recoverCaptureHost();
- if (stillCurrent && !stillCurrent()) return { ignored: true };
- return sendCapture(message);
-}
-
-async function sendCapture(message) {
- if (!capturePage?.documentId) throw new Error("The media capture host is unavailable.");
- const request = {
- ...message,
- target: CAPTURE_PAGE_TARGET,
- relayed: true,
- captureDocumentId: capturePage.documentId,
- };
- let reply;
- try {
- reply = await chrome.runtime.sendMessage(request);
- } catch (error) {
- capturePage = null;
- void unlinkCaptureContent();
- throw error;
- }
- if (!reply) {
- capturePage = null;
- void unlinkCaptureContent();
- throw new Error("The media capture host did not reply.");
- }
- if (!responseFits(reply)) throw new Error(responseLimitError(message.type));
- if (!reply.ok) throw new Error(reply.error || "The capture operation failed.");
- return reply;
-}
-
-async function recoverCaptureBinding(page) {
- if (captureContentDocument || captureLink?.captureSessionId || !page) return;
- assertCaptureTabId(page.tabId);
- if (!shortCaptureString(page.documentId)) throw new Error("The linked document identity is invalid.");
- const owner = capturePage;
- const identity = { tabId: page.tabId, documentId: page.documentId };
- const reply = await chrome.tabs.sendMessage(page.tabId, {
- target: CAPTURE_CONTENT_TARGET, type: "hd_capture_recover",
- }, { documentId: page.documentId }).catch(() => null);
- if (capturePage !== owner || captureContentDocument || captureLink?.captureSessionId) return;
- if (reply?.linked === true && reply.documentId === page.documentId) {
- captureContentDocument = identity;
- } else {
- await sendCapture({ type: "hd_capture_unlinked", ...identity,
- reason: "The reading document is no longer available. Link the page again." });
- }
-}
-
-async function recoverCaptureHost() {
- if (captureRecovery) return captureRecovery;
- if (capturePage) return;
- captureRecovery = (async () => {
- await ensureOffscreen();
- const contexts = await chrome.runtime.getContexts({
- contextTypes: ["OFFSCREEN_DOCUMENT"], documentUrls: [chrome.runtime.getURL(OFFSCREEN_DOCUMENT)],
- });
- if (capturePage || contexts.length !== 1) return;
- const context = contexts[0];
- capturePage = { documentId: context.documentId };
- const status = await sendCapture({ type: "hd_capture_status" });
- await recoverCaptureBinding(status.linkedPage);
- })();
- try { await captureRecovery; }
- finally { captureRecovery = null; }
-}
-
-function finiteCaptureTime(value) {
- return Number.isFinite(value) && Math.abs(value - Date.now()) <= 10 * 60 * 1000;
-}
-
-function shortCaptureString(value, limit = 256) {
- return typeof value === "string" && value.length > 0 && value.length <= limit;
-}
-
-function assertCaptureTabId(tabId) {
- if (!Number.isInteger(tabId) || tabId < 0) throw new Error("Choose a valid reading tab.");
-}
-
-async function captureTabs() {
- const tabs = await chrome.tabs.query({});
- return tabs.filter(tab => Number.isInteger(tab.id) && typeof tab.url === "string"
- && /^(https?|file):/u.test(tab.url))
- .map(tab => ({ id: tab.id, title: String(tab.title || "").slice(0, 200), url: tab.url.slice(0, 2048) }));
-}
-
-async function commandCaptureContent(tabId, type, fields = {}) {
- assertCaptureTabId(tabId);
- try {
- const reply = await chrome.tabs.sendMessage(tabId, {
- target: CAPTURE_CONTENT_TARGET,
- type,
- ...fields,
- }, { frameId: 0 });
- if (reply?.error) throw new Error(reply.error);
- return reply;
- } catch (error) {
- throw new Error(`The reading page is unavailable. Reload it and try again. ${describe(error)}`);
- }
-}
-
-function captureDocumentKey(tabId) {
- return `tab:${tabId}`;
-}
-
-async function unlinkCaptureContent() {
- const linked = captureContentDocument;
- if (!linked) return;
- try {
- await commandCaptureContent(linked.tabId, "hd_capture_unlink");
- } catch {
- // Navigation and tab closure already destroy the content-script state.
- }
- if (captureContentDocument?.tabId === linked.tabId
- && captureContentDocument.documentId === linked.documentId) {
- captureContentDocument = null;
- }
-}
-
-// createDocument() rejects when called while another call is in flight, so every
-// caller waits on the same promise.
-async function ensureOffscreen() {
- if (IS_FIREFOX) {
- await waitForFirefoxOffscreen();
- return;
- }
- // Some extension hosts keep this page alive themselves instead of exposing
- // Chrome's offscreen-document lifecycle API.
- await ensureChromeOffscreen(OFFSCREEN_DOCUMENT);
-}
-
-async function relay(message, stillCurrent = null) {
- let failure = null;
- for (let attempt = 0; attempt < RELAY_ATTEMPTS; attempt += 1) {
- // ensureOffscreen() costs a getContexts() round trip to the browser process
- // on every request (about 0.2 ms of a 2.3 ms lookup). Once the document has
- // answered, send to it directly; a missing reply falls back to the checked
- // path immediately, without consuming an attempt or backing off.
- const optimistic = offscreenAnswered;
- if (!optimistic) {
- await ensureOffscreen();
- }
- if (stillCurrent && !stillCurrent()) {
- return { type: `${message.type}_result`, requestId: message.requestId, ok: true, status: "cancelled" };
- }
- try {
- // `relayed` is what lets offscreen.js ignore the copy of this message that
- // chrome.runtime.sendMessage also delivers to it directly, so a request
- // from an extension page runs on the engine exactly once.
- const reply = await chrome.runtime.sendMessage({ ...message, relayed: true });
- if (reply !== undefined) {
- offscreenAnswered = true;
- return reply;
- }
- failure = new Error("offscreen document sent no reply");
- } catch (error) {
- if (!NOT_LISTENING.test(describe(error))) {
- throw error;
- }
- failure = error;
- }
- offscreenAnswered = false;
- if (optimistic) {
- attempt -= 1;
- continue;
- }
- await sleep(RELAY_BACKOFF_MS * (attempt + 1));
- }
- throw failure ?? new Error("offscreen document unreachable");
-}
-
-async function readDictionaryStorage(includeCustomDocument = false, store = chrome.storage.local) {
- const keys = [
- DICTIONARY_STATE_KEY,
- LEGACY_DICTIONARIES_KEY,
- OPTIONS_KEY,
- ];
- if (includeCustomDocument) keys.push(CUSTOM_DICTIONARY_SOURCE_KEY);
- const stored = await store.get(keys);
- const state = stored?.[DICTIONARY_STATE_KEY] ?? null;
- return {
- state,
- legacyDictionaries:
- state === null && Array.isArray(stored?.[LEGACY_DICTIONARIES_KEY])
- ? stored[LEGACY_DICTIONARIES_KEY]
- : null,
- options: stored?.[OPTIONS_KEY],
- customDocument: includeCustomDocument
- ? stored?.[CUSTOM_DICTIONARY_SOURCE_KEY] ?? null
- : undefined,
- };
-}
-
-async function readUpdateSettings() {
- const stored = await chrome.storage.local.get(UPDATE_SETTINGS_KEY);
- return normaliseUpdateSettings(stored?.[UPDATE_SETTINGS_KEY]);
-}
-
-function hasCapability(dictionary, kind) {
- if (kind === "freq") return dictionary.frequencyCount > 0;
- if (kind === "kanji") return dictionary.kanjiCount > 0;
- if (dictionary.termCount > 0) return true;
- return dictionary.frequencyCount === 0 && dictionary.pitchCount === 0 && dictionary.kanjiCount === 0;
-}
-
-function normaliseDictionarySelections(value, dictionaries) {
- if (!value || typeof value !== "object" || Array.isArray(value)) {
- return value;
- }
- const options = { ...value };
- const selectedFrequency = dictionaries.find((entry) =>
- entry.title === options.frequencyDictionary);
- if (
- options.frequencyDictionary
- && (!selectedFrequency || selectedFrequency.enabled === false || !hasCapability(selectedFrequency, "freq"))
- ) {
- options.frequencyDictionary = "";
- }
-
- const selection = typeof options.kanjiClickDictionary === "string"
- ? { title: options.kanjiClickDictionary, kind: "" }
- : options.kanjiClickDictionary;
- if (selection?.title) {
- const selected = dictionaries.find((entry) => entry.title === selection.title);
- let kind = selection.kind;
- if (!KANJI_SELECTION_KINDS.has(kind)) {
- kind = selected && hasCapability(selected, "kanji") ? "kanji" : "term";
- }
- if (!selected || selected.enabled === false || !hasCapability(selected, kind)) {
- options.kanjiClickDictionary = "";
- } else if (!KANJI_SELECTION_KINDS.has(selection.kind)) {
- options.kanjiClickDictionary = { title: selection.title, kind };
- }
- }
- return options;
-}
-
-function assertDictionaryState(state) {
- if (state !== null && state?.schemaVersion !== DICTIONARY_STATE_SCHEMA_VERSION) {
- throw new Error(`unsupported dictionary state schema ${String(state?.schemaVersion)}`);
- }
-}
-
-function customPackageEngineState(value) {
- if (!value || typeof value !== "object" || Array.isArray(value)) return value;
- const engineState = { ...value };
- delete engineState.displayName;
- delete engineState.favorite;
- return engineState;
-}
-
-function assertOrdinaryCustomTransition(currentDictionaries, nextDictionaries) {
- const currentIndex = currentDictionaries.findIndex(
- (dictionary) => dictionary?.id === CUSTOM_DICTIONARY_ID,
- );
- const nextIndexes = nextDictionaries.flatMap((dictionary, index) =>
- dictionary?.id === CUSTOM_DICTIONARY_ID ? [index] : []);
- if (currentIndex < 0 && nextIndexes.length === 0) return;
- if (currentIndex < 0 || nextIndexes.length !== 1) {
- throw new Error("the managed custom dictionary can only be changed by its source editor");
- }
- const current = currentDictionaries[currentIndex];
- const next = nextDictionaries[nextIndexes[0]];
- if (currentIndex !== 0
- || current?.title !== CUSTOM_DICTIONARY_TITLE
- || current?.enabled !== true
- || nextIndexes[0] !== 0
- || next?.title !== CUSTOM_DICTIONARY_TITLE
- || next?.enabled !== true
- || !sameJsonValue(customPackageEngineState(current), customPackageEngineState(next))) {
- throw new Error("the managed custom dictionary must stay enabled and first");
- }
-}
-
-function assertCustomDictionaryCasRequest(message) {
- if (!Number.isInteger(message?.baseDocumentRevision)
- || message.baseDocumentRevision < 0) {
- throw new Error("the custom dictionary write carried no valid document revision");
- }
- if (!Number.isInteger(message?.baseRevision) || message.baseRevision < 0) {
- throw new Error("the custom dictionary write carried no valid dictionary revision");
- }
- if (typeof message?.text !== "string"
- || typeof message?.semanticRevision !== "string") {
- throw new TypeError("the custom dictionary write carried no source document");
- }
- const changesDictionaryState = message.dictionaries !== undefined;
- if (changesDictionaryState && !Array.isArray(message.dictionaries)) {
- throw new TypeError("the custom dictionary write carried an invalid dictionary list");
- }
- if (message.groups !== undefined && !Array.isArray(message.groups)) {
- throw new TypeError("the custom dictionary write carried invalid groups");
- }
- return changesDictionaryState;
-}
-
-function committedSelectionTitle(title, current, dictionaries) {
- const selected = current?.dictionaries.find((entry) => entry.title === title);
- return selected ? dictionaries.find((entry) => entry.id === selected.id)?.title ?? "" : title;
-}
-
-function migrateCommittedDictionarySelections(value, current, dictionaries) {
- const options = { ...value };
- const migrate = (title) => typeof title === "string" && title !== ""
- ? committedSelectionTitle(title, current, dictionaries)
- : title;
- for (const key of [
- "frequencyDictionary",
- "definitionBlurFrequencyDictionary",
- "compactDefinitionSummaryDictionary",
- "pitchAccentFuriganaDictionary",
- ]) {
- if (Object.hasOwn(options, key)) options[key] = migrate(options[key]);
- }
- if (Object.hasOwn(options, "kanjiClickDictionary")
- && typeof options.kanjiClickDictionary === "string") {
- options.kanjiClickDictionary = migrate(options.kanjiClickDictionary);
- } else if (options.kanjiClickDictionary?.title) {
- const title = migrate(options.kanjiClickDictionary.title);
- options.kanjiClickDictionary = title === ""
- ? ""
- : { ...options.kanjiClickDictionary, title };
- }
- if (Object.hasOwn(options, "popupImageSource")
- && options.popupImageSource?.kind === "dictionary") {
- const title = migrate(options.popupImageSource.title);
- options.popupImageSource = title ? { kind: "dictionary", title } : null;
- }
- return options;
-}
-
-function dictionaryCommit(current, currentOptions, dictionaries, groups) {
- for (const dictionary of dictionaries) assertDictionaryUpdateSchedule(dictionary);
- const currentRevision = current?.revision ?? 0;
- const state = {
- schemaVersion: DICTIONARY_STATE_SCHEMA_VERSION,
- revision: currentRevision + 1,
- dictionaries,
- groups: pruneGroupMemberships(groups ?? current?.groups, dictionaries),
- };
- const values = { [DICTIONARY_STATE_KEY]: state };
- if (currentOptions !== undefined) {
- const revision = optionsRevision(currentOptions);
- const nextOptions = normaliseDictionarySelections(
- migrateCommittedDictionarySelections(
- { ...projectStoredOptions(currentOptions), revision },
- current,
- state.dictionaries,
- ),
- state.dictionaries,
- );
- if (!sameJsonValue(nextOptions, { ...currentOptions, revision })) {
- values[OPTIONS_KEY] = { ...nextOptions, revision: revision + 1 };
- }
- }
- return { state, values };
-}
-
-function optionsRevision(options) {
- return Number.isInteger(options?.revision) && options.revision >= 0 ? options.revision : 0;
-}
-
-async function removeLegacyDictionaryRows(current, legacyDictionaries) {
- if (current !== null || legacyDictionaries === null) return;
- try {
- await chrome.storage.local.remove(LEGACY_DICTIONARIES_KEY);
- } catch (error) {
- console.warn("hoshidicts: could not remove legacy dictionary rows:", describe(error));
- }
-}
-
-// The engine or settings page reads state, changes it, and sends it back a
-// message round trip later. A caller includes the revision it read so a stale
-// write cannot discard a change made by another extension context.
-async function lookupStatisticsStorage(message, record) {
- const term = normaliseLookupTerm(message.term, message.reading);
- const stored = await chrome.storage.local.get([LOOKUP_STATS_KEY, OPTIONS_KEY]);
- let descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
- assertLookupStatsDescriptor(descriptor);
- const storedOptions = stored[OPTIONS_KEY];
- if (storedOptions?.showLookupCounts === false) {
- return { descriptor, statistics: null };
- }
- const key = lookupStatsKey(descriptor, term);
- let row = descriptor.generation === null ? undefined : (await chrome.storage.local.get(key))[key];
- if (record) {
- row = incrementLookupStats(row, term, Date.now());
- descriptor = { generation: descriptor.generation ?? crypto.randomUUID(), revision: descriptor.revision + 1 };
- assertLookupStatsDescriptor(descriptor);
- await writeLocalState({ [LOOKUP_STATS_KEY]: descriptor, [lookupStatsKey(descriptor, term)]: row });
- } else if (row !== undefined) {
- assertLookupStatsRows(descriptor, [row]);
- if (lookupStatsKey(descriptor, row) !== key) throw new Error("The lookup statistics row does not match its key.");
- }
- return {
- descriptor,
- statistics: row ?? { ...term, lookupCount: 0 },
- };
-}
-
-function lookupStatistics(message, record) {
- return serialiseStorage(
- () => lookupStatisticsStorage(message, record),
- );
-}
-
-function assertBackupEngineSender(sender) {
- if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)) {
- throw new Error("Backup restore and cleanup must be requested by the dictionary engine.");
- }
-}
-
-class AutomaticBackupNotReadyError extends Error {}
-
-async function readBackupPayload() {
- const { snapshot } = await WORKER_HANDLERS.hd_backup_base_read();
- const stored = await chrome.storage.local.get(null);
- const descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
- assertLookupStatsDescriptor(descriptor);
- const prefix = lookupStatsPrefix(descriptor);
- const lookupStatsRows = Object.entries(stored).filter(([key]) => key.startsWith(prefix)).map(([key, row]) => {
- if (lookupStatsKey(descriptor, row) !== key) throw new Error("The lookup statistics row does not match its key.");
- return row;
- });
- assertLookupStatsRows(descriptor, lookupStatsRows);
- return { snapshot: {
- state: snapshot.state,
- options: { ...projectStoredOptions(snapshot.options), revision: optionsRevision(snapshot.options) },
- document: normaliseCustomDictionaryDocument(snapshot.document),
- updates: normaliseUpdateSettings(snapshot.updates),
- lookupStats: descriptor,
- }, lookupStatsRows };
-}
-
-async function commitAutomaticBackupStore(current, next) {
- try {
- await chrome.storage.local.set({ [AUTOMATIC_BACKUPS_KEY]: next });
- return;
- } catch (commitError) {
- let readback;
- try {
- readback = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- } catch (readError) {
- throw new Error(
- `automatic backup metadata commit outcome is unknown: ${describe(commitError)}; `
- + `readback failed: ${describe(readError)}`,
- );
- }
- if (sameJsonValue(readback, next)) return;
- if (sameJsonValue(readback, current)) throw commitError;
- throw new Error(
- `automatic backup metadata commit outcome is unknown: ${describe(commitError)}; `
- + "readback did not match the previous or replacement index",
- );
- }
-}
-
-function automaticBackupSummary(record) {
- return {
- id: record.id,
- createdAt: record.createdAt,
- dictionaries: record.snapshot.state.dictionaries.map(({ title, enabled }) => ({ title, enabled })),
- customEntryCount: parseCustomDictionary(record.snapshot.document.text).entries.length,
- };
-}
-
-async function scheduleAutomaticBackup(when) {
- const scheduledTime = Math.max(Date.now(), when);
- const existing = await alarms.get(AUTOMATIC_BACKUP_ALARM);
- if (existing?.scheduledTime !== scheduledTime || existing.periodInMinutes !== undefined) {
- await alarms.create(AUTOMATIC_BACKUP_ALARM, { when: scheduledTime });
- }
- automaticBackupNextAt = when;
-}
-
-async function suppressAutomaticBackupsWhileLinked() {
- automaticBackupNextAt = null;
- automaticBackupWaitingForState = false;
- await alarms.clear(AUTOMATIC_BACKUP_ALARM);
- return { created: false, linked: true };
-}
-
-async function reconcileAutomaticBackups() {
- if (sharingLinked) return suppressAutomaticBackupsWhileLinked();
- const result = await serialiseStorage(async () => {
- if (sharingLinked) return { created: false, linked: true };
- const current = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- const store = automaticBackupStore(current);
- const checkedAt = Date.now();
- if (!automaticBackupDue(store, checkedAt)) {
- return { created: false, nextAt: nextAutomaticBackupTime(store, checkedAt) };
- }
- const payload = await readBackupPayload();
- try {
- await assertBackupSnapshot(payload.snapshot);
- } catch (error) {
- throw new AutomaticBackupNotReadyError(describe(error), { cause: error });
- }
- const createdAt = Date.now();
- if (!automaticBackupDue(store, createdAt)) {
- return { created: false, nextAt: nextAutomaticBackupTime(store, createdAt) };
- }
- const record = {
- id: crypto.randomUUID(),
- createdAt: new Date(createdAt).toISOString(),
- snapshot: payload.snapshot,
- lookupStatsRows: payload.lookupStatsRows,
- };
- const next = await replaceAutomaticBackup(store, record, normaliseOptions(payload.snapshot.options).automaticBackupDays);
- await commitAutomaticBackupStore(current, next);
- return {
- created: true,
- record,
- nextAt: nextAutomaticBackupTime(next, createdAt),
- };
- });
- if (result.linked) return suppressAutomaticBackupsWhileLinked();
- automaticBackupWaitingForState = false;
- await scheduleAutomaticBackup(result.nextAt);
- if (result.created) {
- try {
- const cleanup = await relay({
- target: TARGET,
- type: "hd_backup_auto_cleanup",
- requestId: `automatic-backup-cleanup-${result.record.id}`,
- });
- if (!cleanup?.ok) throw new Error(cleanup?.error || "the dictionary engine refused automatic backup cleanup");
- } catch (error) {
- console.warn("hachidori: automatic backup metadata was committed; deferred generation cleanup failed:", describe(error));
- }
- }
- return result;
-}
-
-function queueAutomaticBackup(force = false) {
- if (!force && automaticBackupNextAt !== null && Date.now() < automaticBackupNextAt) {
- return automaticBackupRun ?? Promise.resolve();
- }
- if (automaticBackupRun !== null) return automaticBackupRun;
- const run = sharingReady.then(reconcileAutomaticBackups).catch(async (error) => {
- if (error instanceof AutomaticBackupNotReadyError) {
- automaticBackupNextAt = null;
- automaticBackupWaitingForState = true;
- return;
- }
- automaticBackupWaitingForState = false;
- const retryAt = Date.now() + AUTOMATIC_BACKUP_RETRY_MS;
- try { await scheduleAutomaticBackup(retryAt); }
- catch (alarmError) {
- console.warn("hachidori: could not schedule an automatic backup retry:", describe(alarmError));
- }
- console.warn("hachidori: could not create the automatic backup:", describe(error));
- }).finally(() => {
- if (automaticBackupRun === run) automaticBackupRun = null;
- });
- automaticBackupRun = run;
- return run;
-}
-
-async function reconcileAutomaticBackupsAfterSharingTransition() {
- const previous = automaticBackupRun;
- if (previous !== null) await previous;
- await queueAutomaticBackup(true);
-}
-
-const WORKER_HANDLERS = {
- hd_lookup_stats_record(message) { return lookupStatistics(message, true); },
- hd_lookup_stats_read(message) { return lookupStatistics(message, false); },
- async hd_lookup_stats_cleanup(_message, sender) {
- assertBackupEngineSender(sender);
- const stored = await chrome.storage.local.get(null);
- const descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
- assertLookupStatsDescriptor(descriptor);
- const prefix = lookupStatsPrefix(descriptor);
- const unused = Object.keys(stored).filter(key => key.startsWith(LOOKUP_STATS_ROW_PREFIX) && !key.startsWith(prefix));
- if (unused.length > 0) await chrome.storage.local.remove(unused);
- return {};
- },
- async hd_backup_download(message, sender) {
- if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL("settings.html")) {
- throw new Error("Backup downloads are available only from Hachidori Settings.");
- }
- return getBackupDownloads().download();
- },
- async hd_backup_base_read() {
- const stored = await chrome.storage.local.get([
- DICTIONARY_STATE_KEY, OPTIONS_KEY, CUSTOM_DICTIONARY_SOURCE_KEY, UPDATE_SETTINGS_KEY, LOOKUP_STATS_KEY,
- ]);
- return { snapshot: {
- state: stored[DICTIONARY_STATE_KEY] ?? null,
- options: stored[OPTIONS_KEY] ?? null,
- document: stored[CUSTOM_DICTIONARY_SOURCE_KEY] ?? null,
- updates: stored[UPDATE_SETTINGS_KEY] ?? null,
- lookupStats: stored[LOOKUP_STATS_KEY] ?? null,
- } };
- },
-
- async hd_backup_read() {
- return readBackupPayload();
- },
-
- async hd_backup_auto_list(_message, sender) {
- if (!ankiSettingsSender(sender)) {
- throw new Error("Automatic backups are available only from Hachidori Settings.");
- }
- if (sharingLinked) return { backups: [], corruptCount: 0, linked: true };
- const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- const { backups, corruptCount } = await validAutomaticBackups(stored);
- return { backups: backups.map(automaticBackupSummary), corruptCount };
- },
-
- async hd_backup_auto_get(message, sender) {
- assertBackupEngineSender(sender);
- if (typeof message.id !== "string" || message.id === "") {
- throw new Error("Choose an automatic backup to restore.");
- }
- const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- const { backups } = await validAutomaticBackups(stored);
- const matches = backups.filter(record => record.id === message.id);
- if (matches.length !== 1) {
- throw new Error("This automatic backup is corrupt or no longer retained.");
- }
- return { backup: matches[0] };
- },
-
- async hd_backup_auto_roots(_message, sender) {
- assertBackupEngineSender(sender);
- const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- const store = automaticBackupStore(stored);
- const { backups, corruptCount } = await validAutomaticBackups(store);
- if (corruptCount > 0 || backups.length !== store.backups.length) {
- return { complete: false, dictionaries: [] };
- }
- return {
- complete: true,
- dictionaries: backups.flatMap(record => record.snapshot.state.dictionaries),
- };
- },
-
- async hd_backup_cas(message, sender) {
- assertBackupEngineSender(sender);
- const { snapshot: current } = await WORKER_HANDLERS.hd_backup_base_read();
- if (!sameJsonValue(message.base, current)) {
- return { ok: false, conflict: true, error: "Hachidori changed since this backup was prepared. Prepare it again before restoring." };
- }
- const snapshot = message.snapshot;
- await assertBackupSnapshot(snapshot);
- assertLookupStatsRows(snapshot.lookupStats, message.lookupStatsRows);
- if (snapshot.lookupStats.generation === null || snapshot.lookupStats.generation === current.lookupStats?.generation) {
- throw new Error("A backup restore requires a fresh lookup statistics namespace.");
- }
- const expected = Object.fromEntries(Object.entries(backupRevisions(current)).map(([key, revision]) => [key, revision + 1]));
- if (!sameJsonValue(backupRevisions(snapshot), expected)) throw new Error("Invalid backup restore revisions.");
- if (!sameJsonValue(snapshot.options, normaliseDictionarySelections(snapshot.options, snapshot.state.dictionaries))) {
- throw new Error("The backup reader settings refer to unavailable dictionaries.");
- }
- await writeLocalState({
- [DICTIONARY_STATE_KEY]: snapshot.state,
- [OPTIONS_KEY]: snapshot.options,
- [CUSTOM_DICTIONARY_SOURCE_KEY]: snapshot.document,
- [UPDATE_SETTINGS_KEY]: snapshot.updates,
- [LOOKUP_STATS_KEY]: snapshot.lookupStats,
- ...Object.fromEntries(message.lookupStatsRows.map(row => [lookupStatsKey(snapshot.lookupStats, row), row])),
- });
- return { snapshot };
- },
-
- async hd_anki_discover(message, sender) {
- if (!ankiSettingsSender(sender)) {
- throw new Error("Anki discovery is available only from Hachidori Settings");
- }
- if (typeof message.model !== "string" || typeof message.apiKey !== "string") {
- throw new TypeError("Anki discovery requires a note type and API key string");
- }
- ankiGateway ??= createAnkiGateway();
- const stored = await chrome.storage.local.get(OPTIONS_KEY);
- const url = message.url === undefined ? normaliseOptions(stored[OPTIONS_KEY]).anki.url : message.url;
- return ankiGateway.discover({ model: message.model, apiKey: message.apiKey, url });
- },
- async hd_anki_setup(message, sender) {
- if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL("settings.html")) {
- throw new Error("Anki setup discovery is available only from Hachidori Settings");
- }
- // Settings owns the draft and saves a proposal through its ordinary options
- // CAS. Discovery itself neither changes options nor records onboarding.
- return checkAnkiSetup(validateOptionsPatch({ anki: message.anki }).anki);
- },
- async hd_open_external(message, sender) {
- if (sender.id !== chrome.runtime.id) throw new Error("external link request came from another extension");
- const url = normaliseExternalUrl(message.url);
- if (!url) throw new TypeError("external link URL is invalid");
- const active = message.active === undefined ? true : message.active;
- if (typeof active !== "boolean") throw new TypeError("external link activation is invalid");
- await chrome.tabs.create({ url, active, ...(sender.tab ? { windowId: sender.tab.windowId } : {}) });
- return { opened: true };
- },
-
- async hd_state_read(message, sender) {
- const { state, legacyDictionaries } = await readDictionaryStorage(false, stateStore(sender));
- return { state, legacyDictionaries };
- },
-
- async hd_state_cas(message, sender) {
- const store = stateStore(sender);
- if (!Number.isInteger(message?.baseRevision) || message.baseRevision < 0) {
- throw new Error("the dictionary state write request carried no valid base revision");
- }
- if (!Array.isArray(message?.dictionaries)) {
- throw new TypeError("the dictionary state write request carried no list");
- }
- if (message.groups !== undefined && !Array.isArray(message.groups)) {
- throw new TypeError("the dictionary state write request carried invalid groups");
- }
-
- const { state: current, legacyDictionaries, options: currentOptions } = await readDictionaryStorage(false, store);
- assertDictionaryState(current);
- const currentRevision = current?.revision ?? 0;
- if (message.baseRevision !== currentRevision) {
- return {
- ok: false,
- conflict: true,
- error: "the dictionary state changed while it was being written",
- state: current,
- };
- }
-
- try {
- assertOrdinaryCustomTransition(current?.dictionaries ?? [], message.dictionaries);
- } catch (error) {
- return {
- ok: false,
- protected: true,
- error: describe(error),
- state: current,
- };
- }
- const { state, values } = dictionaryCommit(
- current,
- currentOptions,
- message.dictionaries,
- message.groups,
- );
- await writeLocalState(values, store);
- await removeLegacyDictionaryRows(current, legacyDictionaries);
- return { state };
- },
-
- async hd_custom_read(message, sender) {
- const { state, customDocument } = await readDictionaryStorage(true, stateStore(sender));
- assertDictionaryState(state);
- return {
- document: normaliseCustomDictionaryDocument(customDocument),
- state,
- };
- },
-
- async hd_custom_cas(message, sender) {
- const store = stateStore(sender);
- const changesDictionaryState = assertCustomDictionaryCasRequest(message);
-
- const {
- state: current,
- legacyDictionaries,
- options: currentOptions,
- customDocument: storedDocument,
- } = await readDictionaryStorage(true, store);
- assertDictionaryState(current);
- const document = normaliseCustomDictionaryDocument(storedDocument);
- if (message.baseDocumentRevision !== document.revision) {
- return {
- ok: false,
- stale: true,
- error: "the custom dictionary source changed while it was being saved",
- document,
- state: current,
- };
- }
- const currentRevision = current?.revision ?? 0;
- if (message.baseRevision !== currentRevision) {
- return {
- ok: false,
- conflict: true,
- error: "the dictionary state changed while the custom dictionary was being saved",
- document,
- state: current,
- };
- }
- const parsed = parseCustomDictionary(message.text);
- const calculatedRevision = await customDictionarySemanticRevision(parsed.entries);
- if (calculatedRevision !== message.semanticRevision) {
- throw new Error("the custom dictionary semantic revision does not match its source");
- }
- assertCustomSourceState(
- changesDictionaryState ? message.dictionaries : current?.dictionaries ?? [],
- calculatedRevision,
- parsed.entries.length,
- );
-
- const documentChanged = document.text !== message.text
- || document.semanticRevision !== message.semanticRevision;
- const nextDocument = documentChanged
- ? {
- schemaVersion: CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
- revision: document.revision + 1,
- semanticRevision: message.semanticRevision,
- text: message.text,
- }
- : document;
- let state = current;
- const values = {};
- if (documentChanged) {
- values[CUSTOM_DICTIONARY_SOURCE_KEY] = nextDocument;
- }
- if (changesDictionaryState) {
- assertCustomDictionaryCommit(message.dictionaries);
- const nextGroups = pruneGroupMemberships(
- message.groups ?? current?.groups,
- message.dictionaries,
- );
- const dictionaryChanged = current === null
- || !sameJsonValue(current.dictionaries, message.dictionaries)
- || !sameJsonValue(current.groups, nextGroups);
- if (dictionaryChanged) {
- const commit = dictionaryCommit(
- current,
- currentOptions,
- message.dictionaries,
- nextGroups,
- );
- state = commit.state;
- Object.assign(values, commit.values);
- }
- }
- if (Object.keys(values).length > 0) {
- await writeLocalState(values, store);
- if (state !== current) {
- await removeLegacyDictionaryRows(current, legacyDictionaries);
- }
- }
- return { document: nextDocument, state };
- },
-
- async hd_options_write(message) {
- const patch = validateOptionsPatch(message.options);
- const { state, options: currentOptions } = await readDictionaryStorage();
- const result = optionsWriteResult(message, patch, state, currentOptions);
- if (result.ok !== false && result.options.revision !== optionsRevision(currentOptions)) {
- await writeLocalState({ [OPTIONS_KEY]: result.options });
- }
- return result;
- },
-
- async hd_setup_cas(message, sender) {
- if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL(STARTUP_PAGE)) {
- throw new Error("Setup progress can be changed only from the Hachidori startup page.");
- }
- if (!Number.isInteger(message.baseRevision) || message.baseRevision < 0) {
- throw new Error("the setup write request carried no valid base revision");
- }
- if (message.continued !== undefined && typeof message.continued !== "boolean") {
- throw new Error("the setup write request carried an invalid continuation flag");
- }
- const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
- const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
- if (current === null) throw new Error("Setup has not started on this installation.");
- if (message.baseRevision !== current.revision) {
- return { ok: false, conflict: true, error: "Setup changed in another tab.", state: current };
- }
- const state = advanceSetupState(current, message.stage, new Date().toISOString(), { continued: message.continued === true });
- await writeLocalState({ [SETUP_STATE_KEY]: state });
- return { state };
- },
-
- // The offscreen installer reports each dictionary outcome and each run's
- // duration; a committed catalogue entry also settles its first-install
- // selection exactly once.
- // The startup page asks once for Anki and local-audio detection. The Anki
- // outcome is the durable gate, so duplicate startup pages share one run.
- async hd_setup_anki(message, sender) {
- if (!startupSender(sender)) throw new Error("Anki setup is available only from the Hachidori startup page.");
- const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
- const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
- if (current === null) throw new Error("Setup has not started on this installation.");
- if (current.stage === "welcome") throw new Error("Start setup before checking Anki.");
- if (current.anki !== null) return { state: current };
- ankiSetupDetection ??= detectFirstRunAnki().finally(() => { ankiSetupDetection = null; });
- return ankiSetupDetection;
- },
-
- async hd_setup_record(message, sender) {
- if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)) {
- throw new Error("Setup outcomes are recorded only by the dictionary engine host.");
- }
- const outcomes = message.outcomes ?? {};
- if (!outcomes || typeof outcomes !== "object" || Array.isArray(outcomes)
- || !Object.keys(outcomes).every((sourceId) => recommendedDictionarySource(sourceId) !== null)) {
- throw new Error("the setup record names an unknown catalogue source");
- }
- const stored = await chrome.storage.local.get([SETUP_STATE_KEY, DICTIONARY_STATE_KEY, OPTIONS_KEY, RECOMMENDED_SELECTIONS_KEY]);
- const store = stateStore(sender);
- const library = store === chrome.storage.local ? stored : await store.get([DICTIONARY_STATE_KEY, OPTIONS_KEY]);
- const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
- const previousSelections = stored[RECOMMENDED_SELECTIONS_KEY] ?? current?.dictionaries.selectionsApplied ?? [];
- const selections = firstInstallSelections(previousSelections, outcomes, library[DICTIONARY_STATE_KEY], library[OPTIONS_KEY]);
- const state = recordSetupDictionaries(message.recordSetup === false ? null : current, {
- runId: message.runId, outcomes, runSeconds: message.runSeconds ?? null, selectionsApplied: selections.applied,
- });
- const values = state === null ? {} : { [SETUP_STATE_KEY]: state };
- if (selections.applied.length > 0) values[RECOMMENDED_SELECTIONS_KEY] = [...new Set([...previousSelections, ...selections.applied])];
- if (selections.options !== null) {
- if (store === chrome.storage.local) values[OPTIONS_KEY] = selections.options;
- else {
- const captured = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY];
- values[SHARING_LOCAL_STATE_KEY] = { ...captured, options: selections.options };
- }
- }
- if (Object.keys(values).length > 0) await writeLocalState(values);
- return { state };
- },
-};
-
-function startupSender(sender) {
- return sender.id === chrome.runtime.id && sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL(STARTUP_PAGE);
-}
-
-function optionsWriteConflict(message, options) {
- return checkedOptionsResult(message, { ok: false, conflict: true,
- error: "Settings changed in another page. Review your changes before saving again.", options });
-}
-
-function optionsWriteResult(message, patch, state, storedOptions) {
- if (!Number.isInteger(message.baseRevision) || message.baseRevision < 0) {
- throw new Error("the options write request carried no valid base revision");
- }
- assertDictionaryState(state);
- const revision = optionsRevision(storedOptions);
- const current = { ...projectStoredOptions(storedOptions), revision };
- if (message.baseRevision !== revision) return optionsWriteConflict(message, current);
- const patched = { ...current, ...patch };
- const options = state === null ? patched : normaliseDictionarySelections(patched, state.dictionaries);
- if (!sameJsonValue(options, { ...storedOptions, revision })) options.revision += 1;
- return checkedOptionsResult(message, { options });
-}
-
-function localOverlayOptionsValues(options, patch, stored) {
- const changed = options.revision - optionsRevision(stored[OPTIONS_KEY]);
- if (changed === 0) return {};
- const version = stored[SHARING_OPTIONS_VERSION_KEY];
- const values = { [OPTIONS_KEY]: options,
- [SHARING_OPTIONS_VERSION_KEY]: { ...version, offset: version.offset + changed } };
- const captured = stored[SHARING_LOCAL_STATE_KEY];
- if (captured) values[SHARING_LOCAL_STATE_KEY] = { ...captured,
- options: { ...captured.options, ...patch, revision: optionsRevision(captured.options) + 1 } };
- return values;
-}
-
-async function prepareOverlayOptionsWrite(message) {
- if (!sharingLinked) return { reply: workerReply(message, await WORKER_HANDLERS.hd_options_write(message)) };
- const patch = validateOptionsPatch(message.options);
- const stored = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
- const result = optionsWriteResult(message, patch, stored[DICTIONARY_STATE_KEY] ?? null, stored[OPTIONS_KEY]);
- if (result.ok === false) return { reply: workerReply(message, result) };
- const local = {}, shared = {};
- for (const [key, value] of Object.entries(patch)) {
- (OVERLAY_LOCAL_OPTION_KEYS.includes(key) ? local : shared)[key] = value;
- }
- if (Object.keys(shared).length === 0) {
- const values = localOverlayOptionsValues(result.options, local, stored);
- if (Object.keys(values).length > 0) await writeLocalState(values);
- return { reply: workerReply(message, result) };
- }
- return { local, shared, version: stored[SHARING_OPTIONS_VERSION_KEY], epoch: sharingEpoch };
-}
-
-async function finishOverlayOptionsWrite(message, prepared, reply) {
- const stored = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
- // Link/Unlink and local edits remain available during the network wait. A
- // reply for the former owner must not change the newly selected installation.
- if (!sharingLinked || prepared.epoch !== sharingEpoch) {
- return workerReply(message, optionsWriteConflict(message, stored[OPTIONS_KEY]));
- }
- if (!reply.options) return reply;
- const version = stored[SHARING_OPTIONS_VERSION_KEY];
- const values = overlayHostOptionsValues(reply.options, stored);
- const current = values[OPTIONS_KEY] ?? stored[OPTIONS_KEY];
- let result;
- if (reply.ok !== false && (version.offset !== prepared.version.offset || optionsRevision(reply.options) < version.hostRevision)) {
- result = workerReply(message, optionsWriteConflict(message, current));
- } else {
- let options = current;
- if (reply.ok !== false) {
- options = { ...current, ...prepared.local };
- if (Object.entries(prepared.local).some(([key, value]) => current[key] !== value)) options.revision += 1;
- Object.assign(values, localOverlayOptionsValues(options, prepared.local, { ...stored, ...values }));
- }
- result = checkedOptionsResult(message, { ...reply, options });
- }
- if (Object.keys(values).length > 0) await writeLocalState(values);
- return result;
-}
-
-async function writeLinkedOverlayOptions(message) {
- const prepared = await serialiseStorage(() => prepareOverlayOptionsWrite(message));
- if (prepared.reply) return prepared.reply;
- const forwarded = { ...message, options: prepared.shared, baseRevision: prepared.version.hostRevision };
- const reply = await forwardToHost(forwarded, linkedOptionsCapability(forwarded));
- return serialiseStorage(() => finishOverlayOptionsWrite(message, prepared, reply));
-}
-
-// Ordinary absence is a connection that never answered; an answer that refused
-// or failed keeps its specific reason.
-function ankiSetupFailure(error) {
- const detail = describe(error);
- const unavailable = /Open Anki with the AnkiConnect add-on|timed out/iu.test(detail);
- return { status: unavailable ? "unavailable" : "needs-attention", detail, model: null, deck: null };
-}
-
-// One read-only conversation with Anki: an unconfigured profile is offered a
-// proposal, and a mapping the user already saved is verified the way Settings
-// verifies it, never replaced. Nothing here holds the storage queue.
-async function checkAnkiSetup(anki) {
- ankiGateway ??= createAnkiGateway();
- const invoke = (action, params) => ankiGateway.invoke(action, params, anki.apiKey, undefined, anki.url);
- try {
- const proposal = anki.model === "" ? await detectAnkiSetup(invoke, anki) : await verifyAnkiSetup(invoke, anki);
- return { proposal, outcome: { status: proposal.status, detail: proposal.detail, model: proposal.model, deck: proposal.deck } };
- } catch (error) {
- // Nothing is claimed about a mapping that could not be checked: the
- // connection's own reason is the outcome, and the mapping is left untouched.
- return { proposal: null, outcome: ankiSetupFailure(error) };
- }
-}
-
-// The check runs outside the storage queue, so the mapping it judged can change
-// while it runs. Such a check is stale: the write is abandoned and the mapping
-// now stored is checked instead. Only a mapping that stops changing can be
-// recorded, so a user still editing Anki settings gets that reason and the link.
-const ANKI_SETUP_ATTEMPTS = 3;
-const ANKI_SETUP_CHANGED = "Anki settings changed while setup checked them. Confirm the mapping in Settings.";
-
-async function detectFirstRunLocalAudio(options) {
- if (sharingLinked || findLocalAudioSource(options.audioSources) !== null) return null;
- try {
- return await detectLocalAudioSource({ fetch: globalThis.fetch });
- } catch {
- // Local audio is optional. Its absence never changes the Anki outcome or
- // interrupts first-run setup.
- return null;
- }
-}
-
-async function detectFirstRunAnki() {
- let localAudioDetection = null;
- for (let attempt = 1; ; attempt += 1) {
- const stored = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
- const options = normaliseOptions(stored[OPTIONS_KEY]);
- const last = attempt >= ANKI_SETUP_ATTEMPTS;
- localAudioDetection ??= detectFirstRunLocalAudio(options);
- const [{ proposal, outcome }, detectedAudio] = await Promise.all([
- checkAnkiSetup(options.anki),
- localAudioDetection,
- ]);
- const written = await serialiseStorage(async () => {
- const current = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
- const setup = normaliseSetupState(current[SETUP_STATE_KEY]);
- if (setup === null) throw new Error("Setup has not started on this installation.");
- if (setup.anki !== null) return { state: setup };
- const currentOptions = normaliseOptions(current[OPTIONS_KEY]);
- const stale = !sameJsonValue(currentOptions.anki, options.anki);
- if (stale && !last) return null;
- const values = {};
- const patch = {};
- if (!stale && proposal?.status === "configured") {
- const anki = { ...options.anki, model: proposal.model, deck: proposal.deck, fieldTemplates: proposal.fieldTemplates };
- patch.anki = anki;
- }
- if (detectedAudio !== null && !sharingLinked && findLocalAudioSource(currentOptions.audioSources) === null) {
- patch.audioSources = [createLocalAudioSource(crypto.randomUUID(), detectedAudio), ...currentOptions.audioSources];
- }
- if (Object.keys(patch).length > 0) {
- const revision = optionsRevision(current[OPTIONS_KEY]);
- values[OPTIONS_KEY] = {
- ...projectStoredOptions(current[OPTIONS_KEY]),
- ...validateOptionsPatch(patch),
- revision: revision + 1,
- };
- }
- const state = recordSetupAnki(setup, stale
- ? { status: "needs-attention", detail: ANKI_SETUP_CHANGED, model: null, deck: null }
- : outcome);
- values[SETUP_STATE_KEY] = state;
- await writeLocalState(values);
- return { state };
- });
- if (written !== null) return written;
- }
-}
-
-// Dictionary-dependent initial preferences follow the committed entry's exact
-// title, whether setup installed it or found it installed. Each is consumed
-// once; an option the user already changed is left alone.
-function firstInstallSelections(previousSelections, outcomes, dictionaryState, storedOptions) {
- const dictionaries = dictionaryState?.dictionaries ?? [];
- const effective = normaliseOptions(storedOptions);
- const applied = [];
- const patch = {};
- for (const [sourceId, rule] of Object.entries(FIRST_INSTALL_SELECTIONS)) {
- if (!["installed", "already-installed"].includes(outcomes[sourceId]?.status)
- || previousSelections.includes(sourceId)) continue;
- // The same catalogue identity the installer uses, so a package carried in
- // or imported by hand, which is recognised by its exact update index, is
- // the entry the selection follows.
- const source = recommendedDictionarySource(sourceId);
- const committed = source === null ? null : installedRecommendedDictionary(source, dictionaries);
- if (committed === null) continue;
- applied.push(sourceId);
- if (effective[rule.option] === "") patch[rule.option] = rule.select(committed.title);
- }
- if (Object.keys(patch).length === 0) return { applied, options: null };
- const revision = optionsRevision(storedOptions);
- const options = normaliseDictionarySelections(
- { ...projectStoredOptions(storedOptions), ...validateOptionsPatch(patch), revision }, dictionaries,
- );
- return { applied, options: sameJsonValue(options, { ...storedOptions, revision }) ? null : { ...options, revision: revision + 1 } };
-}
-
-// One read-then-write at a time, so the check above cannot be overtaken by
-// another worker-mediated write between its get and its set.
-let storageTail = Promise.resolve();
-
-function serialiseStorage(job) {
- const run = storageTail.then(job, job);
- storageTail = run.then(
- () => undefined,
- () => undefined,
- );
- return run;
-}
-
-async function writeUpdateSettings(update) {
- return serialiseStorage(async () => {
- const current = await readUpdateSettings();
- const next = update(current);
- if (next === null) return { ok: false, error: "The update settings changed elsewhere. Review the current schedule before retrying.", settings: current };
- if (sameJsonValue(next, current)) return { settings: current };
- const settings = { ...next, revision: current.revision + 1 };
- await writeLocalState({ [UPDATE_SETTINGS_KEY]: settings });
- return { settings };
- });
-}
-
-async function updateDictionaryCheck(fingerprint, lastUpdateCheck) {
- return serialiseStorage(async () => {
- const { state } = await readDictionaryStorage();
- const index = state?.dictionaries?.findIndex(
- (dictionary) => dictionary?.id === fingerprint.id,
- ) ?? -1;
- if (index < 0 || !managedDictionaryMatches(state.dictionaries[index], fingerprint)) {
- return null;
- }
- const dictionaries = [...state.dictionaries];
- dictionaries[index] = { ...dictionaries[index], lastUpdateCheck };
- const reply = await WORKER_HANDLERS.hd_state_cas({
- baseRevision: state.revision,
- dictionaries,
- });
- if (reply.ok === false) {
- throw new Error(reply.error || "the dictionary update state could not be saved");
- }
- return reply.state.dictionaries.find((dictionary) => dictionary?.id === fingerprint.id) ?? null;
- });
-}
-
-async function managedCandidates(dictionaryIds) {
- const selected = dictionaryIds === null ? null : new Set(dictionaryIds);
- const { state } = await serialiseStorage(readDictionaryStorage);
- return (state?.dictionaries ?? []).flatMap((dictionary) => {
- if (selected !== null && !selected.has(dictionary?.id)) {
- return [];
- }
- const fingerprint = managedDictionaryFingerprint(dictionary);
- return fingerprint === null ? [] : [{
- id: dictionary.id,
- title: dictionary.displayName || dictionary.title,
- fingerprint,
- }];
- });
-}
-
-async function remoteUpdate(candidate) {
- const { source } = candidate.fingerprint;
- const response = await fetch(source.indexUrl, { credentials: "omit" });
- if (!response.ok) {
- throw new Error(`update index request failed with HTTP ${response.status}`);
- }
- if (source.kind === "recommended"
- && !recommendedIndexUrlMatches(recommendedDictionarySource(source.sourceId), response.url)) {
- throw new Error("update index downloaded from an unexpected final URL");
- }
- if (source.kind === "generic" && httpsUrl(response.url) === null) {
- throw new Error("update index redirected to a non-HTTPS URL");
- }
- const index = await response.json();
- if (typeof index?.revision !== "string" || index.revision === "") {
- throw new Error("update index did not declare a revision");
- }
- let archiveUrl = source.downloadUrl;
- if (source.kind === "generic"
- && typeof index.downloadUrl === "string"
- && index.downloadUrl !== "") {
- archiveUrl = httpsUrl(index.downloadUrl);
- if (archiveUrl === null) {
- throw new Error("update index returned a non-HTTPS download URL");
- }
- }
- return { revision: index.revision, archiveUrl };
-}
-
-let updateRequestCounter = 0;
-
-async function installManagedCandidate(candidate, update, checkedAt) {
- updateRequestCounter += 1;
- const { fingerprint } = candidate;
- const reply = await relay({
- target: TARGET,
- type: "hd_import",
- requestId: `managed-update-${updateRequestCounter}`,
- managedFingerprint: fingerprint,
- sourceId: fingerprint.source.kind === "recommended" ? fingerprint.source.sourceId : null,
- archiveUrl: update.archiveUrl,
- expectedRevision: update.revision,
- checkedAt,
- fileName: candidate.title,
- });
- if (!reply?.ok || !reply.report?.success) {
- throw new Error(reply?.error || reply?.report?.error || "the dictionary update failed");
- }
-}
-
-function changedManagedOutcome(candidate) {
- return {
- id: candidate.id,
- status: "check-failed",
- error: MANAGED_DICTIONARY_CHANGED,
- };
-}
-
-async function recordManagedOutcome(candidate, lastUpdateCheck, outcome) {
- const recorded = await updateDictionaryCheck(candidate.fingerprint, lastUpdateCheck);
- return recorded === null ? changedManagedOutcome(candidate) : outcome;
-}
-
-async function checkManagedCandidate(candidate, checkedAt) {
- let update;
- try {
- update = await remoteUpdate(candidate);
- } catch (error) {
- const message = describe(error);
- return {
- update: null,
- available: null,
- outcome: await recordManagedOutcome(
- candidate,
- {
- checkedAt,
- status: "check-failed",
- remoteRevision: null,
- error: message,
- },
- { id: candidate.id, status: "check-failed", error: message },
- ),
- };
- }
-
- if (update.revision === candidate.fingerprint.revision) {
- const outcome = await recordManagedOutcome(
- candidate,
- {
- checkedAt,
- status: "up-to-date",
- remoteRevision: update.revision,
- error: null,
- },
- { id: candidate.id, status: "up-to-date" },
- );
- return { update: null, available: null, outcome };
- }
-
- const available = {
- checkedAt,
- status: "update-available",
- remoteRevision: update.revision,
- error: null,
- };
- const outcome = await recordManagedOutcome(
- candidate,
- available,
- { id: candidate.id, status: "update-available" },
- );
- return {
- update: outcome.status === "update-available" ? update : null,
- available,
- outcome,
- };
-}
-
-async function installCheckedCandidate(candidate, checked, checkedAt) {
- if (checked.update === null) {
- return checked.outcome;
- }
- try {
- await installManagedCandidate(candidate, checked.update, checkedAt);
- return { id: candidate.id, status: "updated" };
- } catch (error) {
- let message = describe(error);
- const failed = await updateDictionaryCheck(
- candidate.fingerprint,
- { ...checked.available, error: message },
- );
- if (failed === null) message = MANAGED_DICTIONARY_CHANGED;
- return {
- id: candidate.id,
- status: failed === null ? "check-failed" : "update-available",
- error: message,
- };
- }
-}
-
-async function readUpdatePlan() {
- const stored = await chrome.storage.local.get([DICTIONARY_STATE_KEY, UPDATE_SETTINGS_KEY]);
- return { dictionaries: stored[DICTIONARY_STATE_KEY]?.dictionaries ?? [],
- settings: normaliseUpdateSettings(stored[UPDATE_SETTINGS_KEY]) };
-}
-
-async function scheduledCandidateIsDue(candidate) {
- const { dictionaries, settings } = await serialiseStorage(readUpdatePlan);
- const current = dictionaries.find(dictionary => dictionary.id === candidate.id);
- if (!current || !managedDictionaryMatches(current, candidate.fingerprint)) return false;
- const now = Date.now();
- const due = nextDictionaryUpdateCheck(current, settings.schedule, now);
- return due !== null && due <= now;
-}
-
-async function runManagedUpdateCycle({ dictionaryIds = null, install = false, dueOnly = false } = {}) {
- const candidates = await managedCandidates(dictionaryIds);
- const outcomes = [];
-
- for (const candidate of candidates) {
- // A later package can be switched Off while an earlier fetch is in flight.
- if (dueOnly && !await scheduledCandidateIsDue(candidate)) continue;
- const checkedAt = new Date().toISOString();
- const checked = await checkManagedCandidate(candidate, checkedAt);
- outcomes.push(install
- ? await installCheckedCandidate(candidate, checked, checkedAt)
- : checked.outcome);
- }
-
- if (dueOnly && outcomes.length === 0) return { outcomes, settings: await readUpdateSettings() };
- const { settings } = await writeUpdateSettings((current) => ({
- ...current,
- lastCheckedAt: new Date().toISOString(),
- }));
- return { outcomes, settings };
-}
-
-let updateTail = Promise.resolve();
-let updateCycleActive = false;
-
-function queueManagedUpdate(options) {
- const execute = async () => {
- updateCycleActive = true;
- try { return await runManagedUpdateCycle(options); }
- finally {
- updateCycleActive = false;
- await refreshUpdateAlarm();
- }
- };
- const run = updateTail.then(execute, execute);
- updateTail = run.then(
- () => undefined,
- () => undefined,
- );
- return run;
-}
-
-let alarmTail = Promise.resolve();
-
-function reconcileUpdateAlarm() {
- const run = alarmTail.then(async () => {
- await sharingReady;
- if (sharingLinked) {
- await alarms.clear(UPDATE_ALARM);
- return;
- }
- if (updateCycleActive) return;
- const { dictionaries, settings } = await serialiseStorage(readUpdatePlan);
- const now = Date.now();
- const when = nextManagedUpdateCheck(dictionaries, settings.schedule, now);
- const existing = await alarms.get(UPDATE_ALARM);
- if (updateCycleActive) return;
- if (when === null) {
- if (existing) await alarms.clear(UPDATE_ALARM);
- return;
- }
- if (existing && existing.periodInMinutes === undefined
- && (existing.scheduledTime === when || (when === now && existing.scheduledTime <= now))) {
- return;
- }
- await alarms.create(UPDATE_ALARM, { when });
- });
- alarmTail = run.then(
- () => undefined,
- () => undefined,
- );
- return run;
-}
-
-function refreshUpdateAlarm() {
- return reconcileUpdateAlarm().catch(error => {
- console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
- });
-}
-
-function updateTiming(dictionaries = []) {
- return dictionaries.map(dictionary => [managedDictionarySource(dictionary) !== null,
- dictionary.updateScheduleOverride ?? null, dictionary.lastUpdateCheck?.checkedAt ?? null]);
-}
-
-chrome.storage.onChanged.addListener((changes, area) => {
- if (area !== "local" || !changes[DICTIONARY_STATE_KEY]) return;
- sharingHost?.setDictionaries(dictionaryCount(changes[DICTIONARY_STATE_KEY].newValue));
-});
-
-chrome.storage.onChanged.addListener((changes, area) => {
- if (area !== "local" || updateCycleActive) return;
- const state = changes[DICTIONARY_STATE_KEY];
- // Update-settings writers reconcile explicitly after releasing the storage queue.
- if (state && !sameJsonValue(
- updateTiming(state.oldValue?.dictionaries), updateTiming(state.newValue?.dictionaries),
- )) void refreshUpdateAlarm();
-});
-
-chrome.storage.onChanged.addListener((changes, area) => {
- if (area !== "local" || !changes[OPTIONS_KEY]) return;
- void reconcileAnkiIndex();
- void applyCustomJavaScript(chrome, normaliseOptions(changes[OPTIONS_KEY].newValue).customPopupJavascript);
-});
-
-async function applyAnkiIndexRole() {
- const index = getAnkiDuplicateIndex();
- if (sharingLinked && !OVERLAY_MODE) await index.suspend();
- else await index.resume();
-}
-
-async function reconcileAnkiIndex() {
- await sharingReady;
- // A link suspends the old role before publishing the new one. An options
- // event or alarm in that interval must not resume local Anki behind it.
- await sharingTransitionTail;
- await applyAnkiIndexRole();
-}
-
-const UPDATE_HANDLERS = {
- async hd_updates_schedule(message) {
- const schedule = managedUpdateSchedule(message?.schedule);
- if (schedule === null) {
- throw new Error("the dictionary update schedule is invalid");
- }
- const result = await writeUpdateSettings(current =>
- message.baseRevision === current.revision ? { ...current, schedule } : null);
- if (result.ok !== false) await reconcileUpdateAlarm();
- return result;
- },
-
- async hd_updates_check() {
- return queueManagedUpdate({ install: false });
- },
-
- async hd_updates_install(message) {
- if (!Array.isArray(message?.dictionaryIds)) {
- throw new TypeError("the dictionary update request carried no dictionary IDs");
- }
- return queueManagedUpdate({ dictionaryIds: message.dictionaryIds, install: true });
- },
-};
-
-function workerReply(message, result) {
- const { ok = true, error = null, ...payload } = result ?? {};
- return { type: `${message.type}_result`, requestId: message.requestId ?? null, ok, error, ...payload };
-}
-
-function checkedOptionsResult(message, result) {
- if (!responseFits(workerReply(message, result))) throw new Error(responseLimitError(message.type));
- return result;
-}
-
-function failureReply(message, error) {
- const description = describe(error);
- let errorCode = typeof error?.code === "string" ? error.code : null;
- if (errorCode === null && description === NOT_REACHABLE) {
- errorCode = "sharing-disconnected";
- }
- return boundResponseFailure({
- type: `${message?.type ?? "hd_unknown"}_result`,
- requestId: message?.requestId ?? null,
- ok: false,
- error: description,
- generation: 0,
- ...(errorCode === null ? {} : { errorCode }),
- ...(error?.outcomeUnknown === true ? { outcomeUnknown: true } : {}),
- });
-}
-
-const ANKI_METHODS = { hd_anki_status: "status", hd_anki_view: "view", hd_anki_preflight: "preflight", hd_anki_submit: "submit",
- hd_anki_browse: "browse", hd_anki_screenshot: "screenshot", hd_anki_screenshot_discard: "discardScreenshot",
- hd_anki_maturity: "maturity" };
-
-// Chrome rate-limits viewport captures, so a second mining action in the same
-// second waits once rather than losing its screenshot.
-const CAPTURE_VISIBLE_RETRY_MS = 600;
-
-// Startup messages can include or omit sender.tab. Chrome's live extension
-// contexts bind either shape to the same document; Firefox supplies the tab on
-// the extension-page sender and has no getContexts equivalent.
-async function screenshotOwnedTab(sender, startup) {
- let tabId = sender.tab?.id;
- if (startup && typeof chrome.runtime.getContexts === "function") {
- const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentIds: [sender.documentId] });
- if (!context) throw new Error("The reading document changed before the screenshot.");
- tabId = context.tabId;
- }
- const tab = await chrome.tabs.get(tabId);
- if (tab?.active !== true) throw new Error("The reading tab is no longer the active tab.");
- // Tabs hides extension-page URLs; startup's exact live document was checked above.
- if (!startup && (sender.frameId ?? 0) === 0 && tab.url !== sender.url) {
- throw new Error("The reading tab moved to another page before the screenshot.");
- }
- if (!startup) {
- // Address the exact content-script document, so a same-URL reload cannot
- // answer on its predecessor's behalf.
- const document = await chrome.tabs.sendMessage(tabId, {
- target: "hachidori-anki-content", type: "hd_anki_document",
- }, { documentId: sender.documentId }).catch(() => null);
- if (document?.present !== true) throw new Error("The reading document changed before the screenshot.");
- }
- return tab;
-}
-
-// captureVisibleTab takes the window's active tab. Both the active page and its
-// document owner are checked around every attempt, including a rate-limit retry.
-async function captureSenderViewport(sender) {
- const startup = startupSender(sender);
- if (typeof sender.tab?.id !== "number" && !startup) {
- throw new Error("Only a reading tab can be captured.");
- }
- if (!sender.documentId) throw new Error("The reading document identity is unavailable.");
- for (let attempt = 1; ; attempt += 1) {
- const tab = await screenshotOwnedTab(sender, startup);
- let captured;
- try {
- captured = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "jpeg" });
- } catch (error) {
- if (attempt >= 2 || !/per second|too many|MAX_CAPTURE/iu.test(describe(error))) throw error;
- await sleep(CAPTURE_VISIBLE_RETRY_MS);
- continue;
- }
- // Capturing stays bound to this window even if the active reading tab is
- // dragged to another one before the pixels return.
- const afterCapture = await screenshotOwnedTab(sender, startup);
- if (afterCapture.windowId !== tab.windowId) {
- throw new Error("The reading tab moved to another window during the screenshot.");
- }
- return captured;
- }
-}
-
-const CAPTURE_CONTROL_TYPES = new Set([
- "hd_capture_open",
- "hd_capture_tabs",
- "hd_capture_link",
- "hd_capture_unlink",
- "hd_capture_video_select",
- "hd_capture_track_area",
- "hd_capture_clear_area",
- "hd_capture_status",
- "hd_capture_start",
- "hd_capture_stop",
-]);
-const CAPTURE_CONTENT_TYPES = new Set([
- "hd_capture_content_identify",
- "hd_capture_text_begin",
- "hd_capture_text_close",
- "hd_capture_text_source_close",
- "hd_capture_page_status",
- "hd_capture_pin",
- "hd_capture_release",
- "hd_capture_export",
- "hd_capture_job_status",
- "hd_capture_cancel",
-]);
-
-let captureConfigRevision = 0;
-let captureConfigTail = Promise.resolve();
-
-chrome.storage.onChanged.addListener((changes, area) => {
- if (!HOST_CAPABILITIES.mediaCapture || area !== "local" || !changes[OPTIONS_KEY]) return;
- const previous = globalThis.HDReaderOptions.normaliseOptions(changes[OPTIONS_KEY].oldValue).mediaCapture;
- const mediaCapture = globalThis.HDReaderOptions.normaliseOptions(changes[OPTIONS_KEY].newValue).mediaCapture;
- if (sameJsonValue(previous, mediaCapture)) return;
- const revision = ++captureConfigRevision;
- const apply = () => relayCapture({ type: "hd_capture_configure", mediaCapture },
- () => revision === captureConfigRevision);
- captureConfigTail = captureConfigTail.then(apply, apply).catch(error => {
- console.error("hachidori: could not update media capture settings:", describe(error));
- });
-});
-
-function assertCurrentCaptureLink(link, status = null) {
- if (captureLink !== link || (status && (status.state !== "recording"
- || status.captureSessionId !== link.captureSessionId))) {
- throw new Error("The capture session changed before the reading page was linked.");
- }
-}
-
-async function linkCapturePage(message) {
- const link = { tabId: message.tabId, captureSessionId: null, document: null };
- captureLink = link;
- let captureDocumentId;
- try {
- const status = await relayCapture({ type: "hd_capture_status" });
- assertCurrentCaptureLink(link);
- if (status.state !== "recording" || !status.captureSessionId) {
- await unlinkCaptureContent();
- throw new Error("Start capture before linking a reading page.");
- }
- link.captureSessionId = status.captureSessionId;
- captureDocumentId = capturePage.documentId;
- if (status.linkedPage) {
- await relayCapture({ type: "hd_capture_unlinked", ...status.linkedPage,
- reason: "Linking a reading page." }, () => captureLink === link);
- assertCurrentCaptureLink(link);
- }
- await unlinkCaptureContent();
- const stored = await chrome.storage.local.get(OPTIONS_KEY);
- assertCurrentCaptureLink(link);
- const mediaCapture = globalThis.HDReaderOptions.projectContentOptions(stored[OPTIONS_KEY]).mediaCapture;
- const details = await commandCaptureContent(message.tabId, "hd_capture_link", {
- mediaCapture, captureSessionId: link.captureSessionId,
- });
- const document = link.document;
- if (!document?.documentId) {
- throw new Error("The linked page did not establish a document identity.");
- }
- const tab = await chrome.tabs.get(message.tabId);
- assertCurrentCaptureLink(link);
- const page = {
- tabId: message.tabId,
- documentId: document.documentId,
- title: String(tab.title || "").slice(0, 200),
- url: String(tab.url || "").slice(0, 2048),
- videos: Array.isArray(details?.videos) ? details.videos : [],
- message: details?.message || "",
- };
- await relayCapture({ type: "hd_capture_linked", requestId: message.requestId,
- captureSessionId: link.captureSessionId, page });
- return { page };
- } catch (error) {
- if (link.document) {
- await unlinkRetiredCapture({ captureDocumentId, captureSessionId: link.captureSessionId,
- linkedPage: link.document }, captureDocumentId, link);
- }
- throw error;
- } finally {
- if (captureLink === link) captureLink = null;
- }
-}
-
-function sameCapturePage(page, expected) {
- return page?.tabId === expected.tabId && page.documentId === expected.documentId;
-}
-
-function replacementCaptureLink(link) {
- if (captureLink && captureLink !== link && captureLink.tabId === link.tabId) return true;
- return captureContentDocument !== link.document && sameCapturePage(captureContentDocument, link.document);
-}
-
-async function unlinkRetiredCapture(message, documentId, retiringLink = null) {
- if (message.captureDocumentId !== documentId) return;
- const page = message.linkedPage;
- assertCaptureTabId(page?.tabId);
- if (!shortCaptureString(page.documentId) || !shortCaptureString(message.captureSessionId)) {
- throw new Error("The retired capture identity is invalid.");
- }
- // A source-ended event can wake a fresh worker before routing has recovered.
- // Check the live host without publishing a partly recovered capturePage.
- const status = await chrome.runtime.sendMessage({
- target: CAPTURE_PAGE_TARGET, type: "hd_capture_status", relayed: true, captureDocumentId: documentId,
- }).catch(() => null);
- if (retiringLink && replacementCaptureLink(retiringLink)) return;
- if (status?.captureSessionId && status.captureSessionId !== message.captureSessionId
- && sameCapturePage(status.linkedPage, page)) return;
- await chrome.tabs.sendMessage(page.tabId, {
- target: CAPTURE_CONTENT_TARGET, type: "hd_capture_unlink",
- }, { documentId: page.documentId }).catch(() => {});
- if (sameCapturePage(captureContentDocument, page)) captureContentDocument = null;
-}
-
-async function handleCaptureHostMessage(message, sender) {
- if (!capturePageSender(sender)) throw new Error("Only the offscreen document can register or stop the capture host.");
- const contexts = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"],
- documentUrls: [chrome.runtime.getURL(OFFSCREEN_DOCUMENT)] });
- const documentId = contexts.length === 1 ? contexts[0].documentId : null;
- if (!documentId) throw new Error("The capture host document is unavailable.");
- if (message.type === "hd_capture_host_stopped") {
- await unlinkRetiredCapture(message, documentId);
- return { stopped: true };
- }
- capturePage = { documentId };
- await recoverCaptureBinding(message.linkedPage);
- const stored = await chrome.storage.local.get(OPTIONS_KEY);
- return { documentId,
- mediaCapture: globalThis.HDReaderOptions.normaliseOptions(stored[OPTIONS_KEY]).mediaCapture };
-}
-
-// A newly created tab can be reopened before its TAB context is published.
-let captureControlsTabId = null;
-let captureControlsOpening = null;
-
-function openCaptureControls() {
- captureControlsOpening ??= focusCaptureControls().finally(() => { captureControlsOpening = null; });
- return captureControlsOpening;
-}
-
-async function focusCaptureControls() {
- if (captureControlsTabId === null) {
- const [existing] = await chrome.runtime.getContexts({ contextTypes: ["TAB"],
- documentUrls: [chrome.runtime.getURL(CAPTURE_DOCUMENT)] });
- captureControlsTabId = existing?.tabId ?? null;
- }
- if (captureControlsTabId !== null) {
- try {
- const tab = await chrome.tabs.update(captureControlsTabId, { active: true });
- if (Number.isInteger(tab.windowId)) await chrome.windows.update(tab.windowId, { focused: true });
- return { tabId: tab.id };
- } catch { /* A closed controls tab can be reopened. */ }
- }
- const tab = await chrome.tabs.create({ url: chrome.runtime.getURL(CAPTURE_DOCUMENT), active: true });
- captureControlsTabId = tab.id;
- return { tabId: tab.id };
-}
-
-async function handleCaptureControl(message, sender) {
- if (["hd_capture_register", "hd_capture_host_stopped"].includes(message.type)) {
- return handleCaptureHostMessage(message, sender);
- }
- if (!CAPTURE_CONTROL_TYPES.has(message.type) || !trustedCaptureControl(sender)) {
- throw new Error("Unknown or untrusted capture control request.");
- }
- if (message.type === "hd_capture_open") return openCaptureControls();
- if (message.type === "hd_capture_tabs") return { tabs: await captureTabs() };
- if (["hd_capture_status", "hd_capture_start", "hd_capture_stop"].includes(message.type)) return relayCapture(message);
- assertCaptureTabId(message.tabId);
- if (message.type === "hd_capture_link") {
- return linkCapturePage(message);
- }
- if (message.type === "hd_capture_unlink") {
- const result = await commandCaptureContent(message.tabId, "hd_capture_unlink", {});
- if (captureContentDocument?.tabId === message.tabId) captureContentDocument = null;
- return result;
- }
- const command = {
- hd_capture_video_select: ["hd_capture_video_select", { videoId: message.videoId }],
- hd_capture_track_area: ["hd_capture_track_area", {}],
- hd_capture_clear_area: ["hd_capture_clear_area", {}],
- }[message.type];
- return commandCaptureContent(message.tabId, command[0], command[1]);
-}
-
-function authoritativeCaptureRecord(message, sender) {
- const record = message.record;
- if (!record || !["cue", "dom"].includes(record.sourceKind)
- || !shortCaptureString(record.sourceEpoch) || !shortCaptureString(record.occurrenceId)
- || typeof record.text !== "string" || record.text.length === 0 || record.text.length > 4096
- || !finiteCaptureTime(record.startMs)) throw new Error("The reading page sent an invalid text timing record.");
- return {
- sourceKind: record.sourceKind,
- sourceId: captureDocumentKey(sender.tab.id),
- sourceEpoch: `${sender.documentId}:${record.sourceEpoch}`,
- occurrenceId: record.occurrenceId,
- text: record.text,
- startMs: record.startMs,
- onsetKnown: record.onsetKnown !== false,
- };
-}
-
-function authoritativeCaptureIdentity(message, sender) {
- const identity = message.identity;
- if (!identity || !["cue", "dom"].includes(identity.sourceKind)
- || !shortCaptureString(identity.sourceEpoch) || !shortCaptureString(identity.occurrenceId)
- || !finiteCaptureTime(message.endMs)) throw new Error("The reading page sent an invalid text close record.");
- return {
- sourceKind: identity.sourceKind,
- sourceId: captureDocumentKey(sender.tab.id),
- sourceEpoch: `${sender.documentId}:${identity.sourceEpoch}`,
- occurrenceId: identity.occurrenceId,
- };
-}
-
-async function handleCaptureContent(message, sender) {
- if (!CAPTURE_CONTENT_TYPES.has(message.type) || sender.id !== chrome.runtime.id
- || !Number.isInteger(sender.tab?.id) || sender.frameId !== 0
- || typeof sender.documentId !== "string" || sender.documentId === "") {
- throw new Error("Unknown or untrusted reading-page capture request.");
- }
- if (message.type === "hd_capture_content_identify") {
- const link = captureLink;
- if (link?.tabId !== sender.tab.id || link.captureSessionId !== message.captureSessionId) {
- throw new Error("This reading page is not being linked to the capture session.");
- }
- const status = await relayCapture({ type: "hd_capture_status" });
- assertCurrentCaptureLink(link, status);
- link.document = { tabId: sender.tab.id, documentId: sender.documentId };
- captureContentDocument = link.document;
- return { documentId: sender.documentId, tabId: sender.tab.id };
- }
- if (!capturePage || captureRecovery) await recoverCaptureHost();
- // Admitted exports outlive reader relinking. The offscreen job retains the
- // original document and checks these authoritative sender fields itself.
- if (["hd_capture_job_status", "hd_capture_cancel"].includes(message.type)) {
- return relayCaptureContent(message, sender);
- }
- const document = captureContentDocument;
- if (document?.tabId !== sender.tab.id || document.documentId !== sender.documentId) {
- throw new Error("This reading document is not linked to the capture session.");
- }
- return relayCaptureContent(message, sender);
-}
-
-function assertCaptureLookup(lookup) {
- if (!lookup || typeof lookup.lookupText !== "string" || lookup.lookupText.length === 0
- || lookup.lookupText.length > 4096 || !finiteCaptureTime(lookup.lookupTimeMs)
- || (lookup.occurrenceId !== "" && !shortCaptureString(lookup.occurrenceId))
- || !["", "dom", "cue"].includes(lookup.occurrenceSourceKind ?? "")) {
- throw new Error("The reading page sent an invalid lookup capture request.");
- }
-}
-
-function assertCaptureExport(message) {
- if (!shortCaptureString(message.token)
- || !message.requirements || typeof message.requirements !== "object"
- || typeof message.requirements.includeAnimation !== "boolean"
- || typeof message.requirements.includeAudio !== "boolean"
- || (!message.requirements.includeAnimation && !message.requirements.includeAudio)) {
- throw new Error("The capture export request is invalid.");
- }
-}
-
-async function relayCaptureContent(message, sender) {
- const authority = { tabId: sender.tab.id, documentId: sender.documentId };
- if (message.type === "hd_capture_text_begin") {
- return relayCapture({ ...message, ...authority, record: authoritativeCaptureRecord(message, sender) });
- }
- if (message.type === "hd_capture_text_close") {
- return relayCapture({ ...message, ...authority, identity: authoritativeCaptureIdentity(message, sender) });
- }
- if (message.type === "hd_capture_text_source_close") {
- if (!["cue", "dom"].includes(message.sourceKind) || !shortCaptureString(message.sourceEpoch)
- || !finiteCaptureTime(message.endMs)) throw new Error("The reading page sent an invalid source close.");
- return relayCapture({
- ...message,
- ...authority,
- sourceId: captureDocumentKey(sender.tab.id),
- sourceEpoch: `${sender.documentId}:${message.sourceEpoch}`,
- });
- }
- if (message.type === "hd_capture_pin") {
- const lookup = message.lookup;
- assertCaptureLookup(lookup);
- return relayCapture({ ...message, ...authority,
- lookup: { ...lookup, occurrenceId: lookup.occurrenceId || "",
- occurrenceSourceKind: lookup.occurrenceSourceKind || "" } });
- }
- if (message.type === "hd_capture_release") {
- if (!shortCaptureString(message.token)) throw new Error("The capture release token is invalid.");
- return relayCapture({ ...message, ...authority });
- }
- if (message.type === "hd_capture_export") {
- assertCaptureExport(message);
- return relayCapture({
- ...message,
- ...authority,
- requirements: {
- includeAnimation: message.requirements.includeAnimation,
- includeAudio: message.requirements.includeAudio,
- },
- });
- }
- if (message.type === "hd_capture_job_status" || message.type === "hd_capture_cancel") {
- if (!shortCaptureString(message.jobId)) throw new Error("The capture export job is invalid.");
- return relayCapture({ ...message, ...authority });
- }
- if (message.type === "hd_capture_page_status") {
- return relayCapture({ ...message, ...authority });
- }
- throw new Error("Unknown reading-page capture request.");
-}
-
-function clearNavigatedCaptureDocument(tabId, reason) {
- const document = captureContentDocument;
- if (document?.tabId !== tabId) return;
- captureContentDocument = null;
- void relayCapture({
- type: "hd_capture_unlinked",
- requestId: `capture-navigation-${crypto.randomUUID()}`,
- tabId,
- documentId: document.documentId,
- reason,
- }).catch(() => {});
-}
-
-chrome.tabs?.onUpdated?.addListener((tabId, changeInfo) => {
- if (changeInfo.status === "loading") {
- if (tabId === captureControlsTabId) captureControlsTabId = null;
- clearNavigatedCaptureDocument(tabId, "The reading page navigated. Link it again.");
- }
-});
-chrome.tabs?.onRemoved?.addListener(tabId => {
- if (tabId === captureControlsTabId) captureControlsTabId = null;
- clearNavigatedCaptureDocument(tabId, "The linked reading tab was closed.");
-});
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== CAPTURE_TARGET || message.relayed === true) return false;
- let operation;
- if (!HOST_CAPABILITIES.mediaCapture) {
- operation = Promise.reject(new Error(
- IS_FIREFOX ? "Media capture is unavailable in Firefox."
- : "Media capture is unavailable in this overlay.",
- ));
- } else if (["hd_capture_register", "hd_capture_host_stopped"].includes(message.type)
- || CAPTURE_CONTROL_TYPES.has(message.type)) {
- operation = handleCaptureControl(message, sender);
- } else {
- operation = handleCaptureContent(message, sender);
- }
- Promise.resolve(operation).then(
- result => sendResponse(workerReply(message, result)),
- error => sendResponse(failureReply(message, error)),
- );
- return true;
-});
-
-// Anki owns its own mutation queue. Discovery, DOM rendering and network I/O
-// must never hold the dictionary storage queue while the engine calls into it.
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== "hachidori-anki") return false;
- handleAnkiRequest(message, sender).then(sendResponse);
- return true;
-});
-
-async function handleAnkiRequest(message, sender) {
- await sharingReady;
- // Linking waits for operations admitted under the old role. Requests which
- // arrive during that transition wait too, so none can read one browser's
- // configuration and finish after routing has moved to another.
- await sharingTransitionTail;
- return trackAnkiOperation(async () => {
- if (sharingLinked && !OVERLAY_MODE) {
- // The reading browser alone can capture or discard its viewport bytes.
- if (["hd_anki_screenshot", "hd_anki_screenshot_discard"].includes(message.type)) {
- return answerAnkiRequest(message, sender);
- }
- // Mature-word evidence has always belonged to the host, including hosts
- // from before linked mining advertised a capability.
- if (message.type === "hd_anki_maturity") return forwardToHost(message);
- if (message.type === "hd_anki_submit") return submitToLinkedAnki(message);
- if (["hd_anki_status", "hd_anki_view", "hd_anki_preflight", "hd_anki_browse"].includes(message.type)) {
- try {
- const reply = await getSharingClient().forward(message, { capability: LINKED_ANKI_CAPABILITY });
- if (message.type === "hd_anki_preflight" && reply?.ok !== false && reply?.clientSpeech) {
- await getAnkiMining().preflightClientSpeech({
- ...message.request,
- clientSpeech: reply.clientSpeech,
- });
- }
- return reply;
- } catch (error) {
- if (message.type === "hd_anki_status" && describe(error) === LINKED_ANKI_UNSUPPORTED) {
- return workerReply(message, { available: false, configKey: "", error: LINKED_ANKI_UNSUPPORTED });
- }
- return failureReply(message, error);
- }
- }
- }
- return answerAnkiRequest(message, sender);
- });
-}
-
-async function sendAnkiRequest(target, fields) {
- const message = { ...fields, target, requestId: `anki-${crypto.randomUUID()}` };
- const reply = target === TARGET ? await relayEngineRequest(message) : await relay(message);
- if (!reply?.ok) throw new Error(reply?.error || "Anki preparation did not complete.");
- return reply;
-}
-
-function getAnkiMining() {
- if (!ankiMining) {
- const send = sendAnkiRequest;
- ankiGateway ??= createAnkiGateway();
- ankiMining = createAnkiWorkerService({ gateway: ankiGateway,
- readOptions: readAnkiOptions,
- duplicateIndex: getAnkiDuplicateIndex(),
- readDictionaries: async () => (await readDictionaryStorage()).state?.dictionaries ?? [],
- engine: fields => send(TARGET, fields), offscreen: fields => send("hachidori-anki-render", fields),
- capture: fields => relayCapture({ ...fields, requestId: `anki-capture-${crypto.randomUUID()}` }),
- });
- }
- return ankiMining;
-}
-
-async function submitToLinkedAnki(message) {
- const local = getAnkiMining();
- let clientMedia;
- try {
- clientMedia = await local.clientMedia(message.request);
- } catch (error) {
- return failureReply(message, error);
- }
- let sent = false;
- let reply;
- try {
- reply = await getSharingClient().forward({ ...message, clientMedia }, {
- capability: LINKED_ANKI_CAPABILITY,
- mutation: true,
- onSent: () => { sent = true; },
- });
- } catch (error) {
- if (!sent) return failureReply(message, error);
- return workerReply(message, {
- state: "uncertain",
- error: `The write could not be confirmed. Check Anki before trying again. ${describe(error)}`,
- });
- }
- const states = ["added", "updated", "duplicate", "invalid", "uncertain"];
- if (!reply || reply.type !== `${message.type}_result` || reply.requestId !== message.requestId
- || typeof reply.ok !== "boolean" || (reply.ok === true && !states.includes(reply.state))) {
- return workerReply(message, {
- state: "uncertain",
- error: "The write could not be confirmed. Check Anki before trying again. The linked Hachidori returned an unexpected response.",
- });
- }
- const settlement = reply.ok === false ? "invalid"
- : ["added", "updated", "duplicate", "invalid"].includes(reply.state) ? reply.state : null;
- if (settlement !== null) {
- try {
- await local.settleClientMedia(message.request, settlement);
- } catch (error) {
- if (["added", "updated"].includes(settlement)) {
- reply = { ...reply, warnings: [...(Array.isArray(reply.warnings) ? reply.warnings : []),
- `Captured media cleanup: ${describe(error)}`] };
- } else {
- console.warn("hachidori: could not discard rejected linked media:", describe(error));
- }
- }
- }
- return reply;
-}
-
-function answerAnkiRequest(message, sender, linkedClient = false) {
- return Promise.resolve().then(async () => {
- if (sender.id !== chrome.runtime.id || !Object.hasOwn(ANKI_METHODS, message.type)) throw new Error("Unknown Anki request.");
- const service = getAnkiMining();
- // Only the screenshot needs to know which page asked, and it is given the
- // capture rather than the sender, so nothing else can capture a tab.
- if (message.type === "hd_anki_screenshot") {
- return service.screenshot(() => captureSenderViewport(sender), message.templateId);
- }
- if (linkedClient && message.type === "hd_anki_status") {
- const status = await service.status(message.templateId);
- return { ...status, configKey: linkedAnkiConfigKey(status.configKey) };
- }
- if (linkedClient && message.type === "hd_anki_view") {
- const result = await service.view(message.request);
- return { ...result, configKey: linkedAnkiConfigKey(result.configKey) };
- }
- if (linkedClient && message.type === "hd_anki_preflight") {
- return service.preflightClient(hostLinkedAnkiRequest(message.request));
- }
- if (linkedClient && message.type === "hd_anki_submit") {
- return service.submitClient(hostLinkedAnkiRequest(message.request), message.clientMedia);
- }
- if (linkedClient && message.type === "hd_anki_browse") {
- return service.browse(hostLinkedAnkiRequest(message.request));
- }
- if (message.type === "hd_anki_status") return service.status(message.templateId);
- return service[ANKI_METHODS[message.type]](message.type === "hd_anki_browse"
- ? message.request ?? message.expression : message.request);
- }).then(result => workerReply(message, result), error => failureReply(message, error));
-}
-
-const backupPreparations = new Map();
-let backupCancelTail = Promise.resolve();
-
-async function relayEngineRequest(message) {
- await sharingReady;
- if (sharingLinked && forwardableRequest(message)) return forwardToHost(message);
- if (message.type === "hd_backup_cancel") {
- const preparation = backupPreparations.get(message.token);
- if (preparation) preparation.cancelled = true;
- // Retire startup/retries now, then let an already-dispatched prepare finish
- // before cancellation reaches the engine. Otherwise Chrome 128 can deliver
- // pagehide while prepare is awaiting a storage reply: the early cancel sees
- // no prepared token, then prepare publishes fresh roots with nobody left to
- // discard them. Admit only one cleanup request at a time.
- const cancel = async () => {
- if (preparation) await preparation.settled;
- return relay(message);
- };
- const cancelled = backupCancelTail.then(cancel, cancel);
- backupCancelTail = cancelled.catch(() => {});
- return cancelled;
- }
- if (!["hd_backup_prepare", "hd_backup_auto_prepare"].includes(message.type)) return relay(message);
- let settlePreparation;
- const preparation = {
- cancelled: false,
- settled: new Promise(resolve => { settlePreparation = resolve; }),
- };
- backupPreparations.set(message.token, preparation);
- try {
- return await relay(message, () => !preparation.cancelled);
- } finally {
- settlePreparation();
- if (backupPreparations.get(message.token) === preparation) backupPreparations.delete(message.token);
- }
-}
-
-function validBackupPreparationToken(token) {
- return typeof token === "string" && token.length > 0 && token.length <= 128;
-}
-
-async function cancelOwnedBackupPreparation(token) {
- const reply = await relayEngineRequest({
- target: TARGET,
- type: "hd_backup_cancel",
- token,
- requestId: `backup-lifecycle-${crypto.randomUUID()}`,
- });
- if (!reply?.ok) throw new Error(reply?.error || "The abandoned backup preparation could not be discarded.");
- return reply;
-}
-
-chrome.runtime.onConnect.addListener((port) => {
- if (port.name !== BACKUP_LIFECYCLE_PORT) return;
- if (!ankiSettingsSender(port.sender)) {
- port.disconnect();
- return;
- }
- const owned = new Set();
- port.onMessage.addListener((message) => {
- if (!validBackupPreparationToken(message?.token)) return;
- if (message.type === "track" && typeof message.active === "boolean") {
- if (message.active) owned.add(message.token);
- else owned.delete(message.token);
- return;
- }
- if (message.type === "cancel" && owned.has(message.token)) {
- void cancelOwnedBackupPreparation(message.token).then(
- () => owned.delete(message.token),
- error => console.warn("hachidori: could not discard an abandoned backup preparation:", describe(error)),
- );
- }
- });
- port.onDisconnect.addListener(() => {
- const abandoned = [...owned];
- owned.clear();
- for (const token of abandoned) {
- void cancelOwnedBackupPreparation(token).catch(
- error => console.warn("hachidori: could not discard an abandoned backup preparation:", describe(error)),
- );
- }
- });
-});
-
-// The reader's popup cancels browser zoom, which only extension APIs report.
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== PAGE_ZOOM_TARGET) return false;
- const tabId = sender.tab?.id;
- if (sender.id !== chrome.runtime.id || message.type !== "hd_page_zoom" || !Number.isInteger(tabId)) {
- sendResponse(failureReply(message, new Error("Unknown page zoom request.")));
- return false;
- }
- chrome.tabs.getZoom(tabId).then((zoomFactor) => sendResponse(workerReply(message, { zoomFactor })),
- (error) => sendResponse(failureReply(message, error)));
- return true;
-});
-
-// Startup and Settings attach to one recommended-install run. The welcome gate
-// belongs to startup; opening Settings never requires an onboarding record.
-async function handleRecommendedInstall(message, sender, shared = false) {
- const startup = startupSender(sender);
- const settings = sender?.id === chrome.runtime.id
- && sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL("settings.html");
- if (!shared && !startup && !settings) throw new Error("Recommended installation is available only from Hachidori startup or Settings.");
- if (message.type !== "hd_setup_install") throw new Error("Unknown recommended installation request.");
- if (startup) {
- const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
- const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
- if (current === null) throw new Error("Setup has not started on this installation.");
- if (current.stage === "welcome") throw new Error("Start setup before downloading dictionaries.");
- }
- // Never hold the storage queue here: each engine commit calls back into it.
- await sharingReady;
- return sharingLinked ? forwardToHost(message) : relay({ ...message, recordSetup: startup });
-}
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== SETUP_TARGET || message.relayed === true) return false;
- handleRecommendedInstall(message, sender).then(sendResponse, (error) => sendResponse(failureReply(message, error)));
- return true;
-});
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (!message || (message.target !== TARGET && message.target !== AUDIO_TARGET) || message.relayed === true) {
- return false;
- }
- let stillCurrent = null;
- let operation = null;
- if (message.target === AUDIO_TARGET) {
- try {
- if (!["hd_audio_test", "hd_audio_play", "hd_audio_candidates", "hd_audio_stop", "hd_audio_voices"].includes(message.type)) throw new Error("Unknown audio request.");
- validateAudioRequest(message);
- // Chrome supplies the document ID, so an old Settings tab cannot stop a
- // pronunciation subsequently started by a different document.
- message = { ...message, owner: sender.documentId };
- if (["hd_audio_test", "hd_audio_play", "hd_audio_candidates"].includes(message.type)) {
- operation = { ...message, tabId: sender.tab?.id, startup: startupSender(sender) };
- latestAudioOperation = operation;
- stillCurrent = () => latestAudioOperation === operation;
- } else if (message.type === "hd_audio_stop"
- && latestAudioOperation?.owner === message.owner && latestAudioOperation?.requestId === message.playRequestId) {
- // Retire it before awaiting offscreen startup. Otherwise its relay
- // retry could start playback after this Stop has already completed.
- latestAudioOperation = null;
- }
- } catch (error) {
- sendResponse(failureReply(message, error));
- return false;
- }
- }
- const response = message.target === AUDIO_TARGET
- ? prepareAudioRequest(message).then(prepared => relay(prepared, stillCurrent)) : relayEngineRequest(message);
- response.then(sendResponse, error => {
- sendResponse(failureReply(message, error));
- }).finally(() => { if (operation && latestAudioOperation === operation) latestAudioOperation = null; });
- return true;
-});
-
-function validateAudioRequest(message) {
- if (message.type === "hd_audio_test") {
- globalThis.HDReaderOptions.validateOptionsPatch({ audioSources: [message.source] });
- return;
- }
- if (message.type !== "hd_audio_play" && message.type !== "hd_audio_candidates") return;
- if (typeof message.term?.expression !== "string" || !message.term.expression
- || typeof message.term.reading !== "string") throw new Error("A pronunciation needs an expression and reading.");
- const choice = message.selection;
- if (choice !== undefined && (!choice || !Number.isInteger(choice.index) || choice.index < 0
- || !["sourceId", "sourceKey", "expression", "reading", "name"].every(key => typeof choice[key] === "string")
- || (choice.url !== null && typeof choice.url !== "string"))) throw new Error("Invalid pronunciation selection.");
-}
-
-async function prepareAudioRequest(message) {
- if (message.type !== "hd_audio_play" && message.type !== "hd_audio_candidates") return message;
- const stored = await chrome.storage.local.get(OPTIONS_KEY);
- const options = globalThis.HDReaderOptions.normaliseOptions(stored[OPTIONS_KEY]);
- return { ...message, sources: options.audioSources.filter(source => source.enabled) };
-}
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== "hachidori-audio-events") return false;
- const operation = latestAudioOperation;
- if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)
- || !operation || (!operation.startup && operation.tabId === undefined) || operation.owner !== message.owner
- || operation.requestId !== message.requestId || message.type !== "hd_audio_playing") return false;
- const progress = { ...message, target: "hachidori-audio-content" };
- // The packaged startup reader lives in an extension page, outside the
- // content-script audience of tabs.sendMessage. Its controller accepts only
- // its active random request ID; the worker already checked the document owner.
- const delivery = operation.startup ? chrome.runtime.sendMessage(progress)
- : chrome.tabs.sendMessage(operation.tabId, progress, { documentId: operation.owner });
- delivery.then(() => sendResponse({ ok: true }), () => sendResponse({ ok: false }));
- return true;
-});
-
-// Never relay(): a WORKER_TARGET request must be answered here, or the engine's
-// storage reads would re-enter the offscreen document.
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (!message || message.target !== WORKER_TARGET) {
- return false;
- }
- handleWorkerRequest(message, sender).then(sendResponse);
- return true;
-});
-
-async function handleWorkerRequest(message, sender) {
- const type = typeof message.type === "string" ? message.type : "";
- if (!Object.prototype.hasOwnProperty.call(WORKER_HANDLERS, type)) {
- return failureReply(message, new Error(`unknown worker request type ${JSON.stringify(type)}`));
- }
- if (type === "hd_backup_download" && typeof chrome.downloads?.download !== "function") {
- return failureReply(message, new Error("Browser downloads are unavailable. Export the backup from Hachidori Settings."));
- }
- if (type === "hd_open_external" && HOST_CAPABILITIES.externalLinkHost) {
- return failureReply(message, new Error(
- "Custom toolbar links open only from lookup popups in this overlay; the Settings preview cannot launch them.",
- ));
- }
- await sharingReady;
- if (["hd_anki_discover", "hd_anki_setup", "hd_setup_anki"].includes(type)) await sharingTransitionTail;
- if (sharingLinked && !OVERLAY_MODE && ["hd_anki_discover", "hd_anki_setup"].includes(type)) {
- try {
- if (!ankiSettingsSender(sender)) {
- throw new Error(`${type === "hd_anki_setup" ? "Anki setup discovery" : "Anki discovery"} is available only from Hachidori Settings`);
- }
- const allowed = type === "hd_anki_setup"
- ? allowLinkedAnkiSetupRequest(message) : allowLinkedAnkiDiscoveryRequest(message);
- return await getSharingClient().forward(allowed, { capability: LINKED_ANKI_CAPABILITY });
- } catch (error) {
- return failureReply(message, error);
- }
- }
- // The host owns the lookup-count rows a linked engine would otherwise prune.
- if (sharingLinked && type === "hd_lookup_stats_cleanup") return workerReply(message, {});
- if (type === "hd_options_write") {
- let error = null;
- if (!validResponseRequestId(message.requestId ?? null)) {
- error = "the options write request carried an invalid request ID";
- } else if (!responseFits(message)) {
- error = responseLimitError(type);
- }
- if (error !== null) {
- return failureReply(message, error);
- }
- }
- const invoke = async () => WORKER_HANDLERS[type](await compatibleLinkedWorkerMessage(message, sender), sender);
- if (sharingLinked && !engineSender(sender) && WORKER_FORWARDS.has(type)) {
- return forwardWorkerRequest(message).catch(error => failureReply(message, error));
- }
- // Navigation and read-only Anki discovery must not hold up storage commits.
- const run = () => [
- "hd_open_external", "hd_anki_discover", "hd_anki_setup", "hd_setup_anki", "hd_backup_download",
- "hd_lookup_stats_record", "hd_lookup_stats_read",
- ].includes(type) ? invoke() : serialiseStorage(invoke);
- const operation = ["hd_anki_discover", "hd_anki_setup", "hd_setup_anki"].includes(type)
- ? trackAnkiOperation(run) : run();
- return operation.then(
- async (result) => {
- if (type === "hd_backup_cas" && result.ok !== false) {
- try { await reconcileUpdateAlarm(); }
- catch (error) { result.warning = `Restored successfully; update alarm could not be refreshed: ${describe(error)}`; }
- }
- return workerReply(message, result);
- },
- (error) => failureReply(message, error),
- );
-}
-
-// Update cycles relay imports back through the engine, which calls into the
-// storage handlers above while committing. Keep this listener outside
-// serialiseStorage() so the engine can complete that callback.
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== UPDATE_TARGET) {
- return false;
- }
- handleUpdatesRequest(message).then(sendResponse);
- return true;
-});
-
-async function handleUpdatesRequest(message) {
- const type = typeof message.type === "string" ? message.type : "";
- if (!Object.hasOwn(UPDATE_HANDLERS, type)) {
- return failureReply(message, new Error(`unknown update request type ${JSON.stringify(type)}`));
- }
- await sharingReady;
- if (sharingLinked) return forwardToHost(message);
- return Promise.resolve().then(() => UPDATE_HANDLERS[type](message)).then(
- (result) => {
- const { ok = true, error = null, ...payload } = result ?? {};
- return { type: `${type}_result`, requestId: message.requestId ?? null, ok, error, ...payload };
- },
- (error) => failureReply(message, error),
- );
-}
-
-// A browser linked through the sharing bridge sends ordinary runtime messages;
-// each one is answered by the handler for its target, as if a page sent it.
-const SHARING_TARGET = "hachidori-sharing";
-
-async function dispatchSharedRequest(message, clientId, capabilities = []) {
- const sender = {
- id: chrome.runtime.id,
- url: `hachidori-sharing://client/${clientId}`,
- linkedCapabilities: capabilities,
- };
- const ordinary = () => {
- if (!forwardableRequest(message)) {
- throw new Error(`unsupported shared request ${JSON.stringify(message.target)} ${JSON.stringify(message.type)}`);
- }
- };
- try {
- switch (message.target) {
- case TARGET:
- if (API_REQUESTS.has(message.type)) return await getApiHost()(message);
- ordinary();
- return await relayEngineRequest(message);
- case WORKER_TARGET:
- if (message.type === "hd_anki_discover") {
- const allowed = allowLinkedAnkiDiscoveryRequest(message);
- ankiGateway ??= createAnkiGateway();
- const options = await readAnkiOptions();
- return workerReply(allowed, await trackAnkiOperation(
- () => ankiGateway.discover({ ...options.anki, model: allowed.model }),
- ));
- }
- if (message.type === "hd_anki_setup") {
- const allowed = allowLinkedAnkiSetupRequest(message);
- const options = await readAnkiOptions();
- const config = ankiTemplateConfig(options.anki, allowed.templateId);
- if (config === null) throw new Error("The selected Anki Template is no longer available.");
- return workerReply(allowed, await trackAnkiOperation(() => checkAnkiSetup(config)));
- }
- ordinary();
- return await handleWorkerRequest(message, sender);
- case UPDATE_TARGET:
- ordinary();
- return await handleUpdatesRequest(message);
- case SETUP_TARGET:
- ordinary();
- return await handleRecommendedInstall(message, sender, true);
- case "hachidori-anki": return await trackAnkiOperation(
- () => answerAnkiRequest(allowLinkedAnkiRequest(message), sender, true),
- );
- default: throw new Error(`unsupported shared request target ${JSON.stringify(message.target)}`);
- }
- } catch (error) {
- return failureReply(message, error);
- }
-}
-
-async function writeSharingConfig(patch) {
- await serialiseStorage(async () => {
- const stored = await chrome.storage.local.get(SHARING_KEY);
- await writeLocalState({ [SHARING_KEY]: { ...stored[SHARING_KEY], ...patch } });
- });
-}
-
-// An empty address means this computer, on the port set under Advanced.
-function linkTarget(text) {
- const trimmed = String(text ?? "").trim();
- return parseLinkAddress(trimmed === "" ? `127.0.0.1:${getSharingHost().status().port}` : trimmed);
-}
-
-// Own the whole user action, including its probe, independently of storage.
-// Network waits must leave the storage queue free for engine callbacks and
-// local edits, and a failed action must not block the next Settings tab.
-function serialiseSharingTransition(job) {
- const run = sharingTransitionTail.then(() => sharingReady).then(job);
- sharingTransitionTail = run.catch(() => {});
- return run;
-}
-
-const SHARING_HANDLERS = {
- hd_sharing_status() {
- return { sharing: sharingStatus() };
- },
- async hd_sharing_client_probe(message) {
- const { address, display } = linkTarget(message.address);
- const hello = await getSharingClient().probe(address);
- return { address, display, host: { version: hello.version, name: hello.name, dictionaryCount: hello.dictionaryCount } };
- },
- // A linked install has nothing of its own to share, and the relay holds one
- // host, so this install stops hosting before it looks for the other one;
- // linking to its own address then finds nothing. The install's own shared
- // values are kept aside for unlinking, then the host's snapshot takes their
- // place under the live keys.
- async hd_sharing_client_link(message) {
- const { address } = linkTarget(message.address);
- const config = (await serialiseStorage(() => chrome.storage.local.get(SHARING_KEY)))[SHARING_KEY];
- if (config?.client?.address === address) return { sharing: sharingStatus() };
- const host = getSharingHost();
- const hosting = host.status();
- let suspendedIndex = false;
- if (hosting.enabled) host.disable();
- try {
- const hello = await getSharingClient().probe(address);
- await waitForAnkiIdle();
- await getAnkiDuplicateIndex().suspend();
- suspendedIndex = true;
- await serialiseStorage(async () => {
- const stored = await chrome.storage.local.get([...SHARED_STATE_KEYS, SHARING_KEY]);
- if (!sameJsonValue(stored[SHARING_KEY], config)) {
- throw new Error("Sharing changed while linking. Try again.");
- }
- const values = {
- [SHARING_KEY]: { ...config, host: { ...config?.host, enabled: false }, client: { address } },
- };
- // Switching hosts keeps the original local state too. Only an install
- // that is currently unlinked may capture the live keys as local data.
- if (!config?.client?.address) {
- values[SHARING_LOCAL_STATE_KEY] = Object.fromEntries(SHARED_STATE_KEYS.map(key => [key, stored[key] ?? null]));
- }
- if (OVERLAY_MODE) values[SHARING_OPTIONS_VERSION_KEY] = null;
- await chrome.storage.local.set(values);
- // Publish routing at the confirmed commit, before another storage job
- // can let the local engine see (or clean up against) the host inventory.
- sharingLinked = true;
- sharingEpoch += 1;
- getSharingClient().link(address);
- await applyMirror(hello.snapshot, true);
- });
- } catch (error) {
- if (suspendedIndex && !sharingLinked) await getAnkiDuplicateIndex().resume();
- if (hosting.enabled && !sharingLinked) host.enable({ port: hosting.port, network: hosting.network.enabled });
- throw error;
- }
- await reconcileUpdateAlarm();
- await reconcileAutomaticBackupsAfterSharingTransition();
- await applyAnkiIndexRole();
- return { sharing: sharingStatus() };
- },
- // Restored values outrank the mirror in every reader's revision comparison,
- // and the host's lookup-count rows leave with it.
- async hd_sharing_client_unlink() {
- // Finish any request admitted under the linked route before restoring the
- // local route. In particular, do not let media exported for one host be
- // sent to local Anki or abandoned merely because Unlink won a race.
- await waitForAnkiIdle();
- await serialiseStorage(async () => {
- const stored = await chrome.storage.local.get(null);
- // client:null is the durable completion marker. A repeated Unlink must
- // not restore an old snapshot even if its final cleanup failed.
- if (!stored[SHARING_KEY]?.client?.address) return;
- const captured = stored[SHARING_LOCAL_STATE_KEY];
- if (captured) {
- const values = {};
- const removals = [];
- for (const key of SHARED_STATE_KEYS) {
- const local = captured[key];
- if (local === null || local === undefined) {
- if (stored[key] !== undefined) removals.push(key);
- continue;
- }
- values[key] = { ...local, revision: Math.max(optionsRevision(local), optionsRevision(stored[key])) + 1 };
- }
- const prefix = lookupStatsPrefix(values[LOOKUP_STATS_KEY] ?? emptyLookupStats());
- removals.push(...Object.keys(stored).filter(key => key.startsWith(LOOKUP_STATS_ROW_PREFIX) && !key.startsWith(prefix)));
- await writeLocalState(values);
- if (removals.length > 0) await chrome.storage.local.remove(removals);
- }
- // An absent snapshot never authorizes deleting live user data. Keep
- // both the snapshot and linked routing until restoration has succeeded.
- await chrome.storage.local.set({ [SHARING_KEY]: { ...stored[SHARING_KEY], client: null } });
- sharingLinked = false;
- sharingEpoch += 1;
- getSharingClient().unlink();
- await chrome.storage.local.remove(OVERLAY_MODE ? [SHARING_LOCAL_STATE_KEY, SHARING_OPTIONS_VERSION_KEY] : SHARING_LOCAL_STATE_KEY).catch(error => {
- console.warn("hachidori: could not clean up the restored sharing snapshot:", describe(error));
- });
- });
- await reconcileUpdateAlarm();
- await reconcileAutomaticBackupsAfterSharingTransition();
- await applyAnkiIndexRole();
- return { sharing: sharingStatus() };
- },
- async hd_sharing_host_enable(message) {
- const host = getSharingHost();
- host.enable({ port: message.port, network: message.network === true });
- await writeSharingConfig({ host: { enabled: true, port: host.status().port, network: message.network === true } });
- return { sharing: sharingStatus() };
- },
- async hd_sharing_host_disable() {
- getSharingHost().disable();
- await writeSharingConfig({ host: null });
- return { sharing: sharingStatus() };
- },
-};
-
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== SHARING_TARGET) return false;
- const type = typeof message.type === "string" ? message.type : "";
- Promise.resolve().then(() => {
- if (!Object.hasOwn(SHARING_HANDLERS, type)) throw new Error(`unknown sharing request type ${JSON.stringify(type)}`);
- const invoke = () => SHARING_HANDLERS[type](message, sender);
- return ["hd_sharing_status", "hd_sharing_client_probe"].includes(type)
- ? sharingReady.then(invoke) : serialiseSharingTransition(invoke);
- }).then(result => sendResponse(workerReply(message, result)), error => sendResponse(failureReply(message, error)));
- return true;
-});
-
-chrome.storage.onChanged.addListener((changes, area) => {
- sharingHost?.storageChanged(changes, area);
- if (area !== "local") return;
- const relevant = Object.keys(changes).some(key =>
- SHARED_STATE_KEYS.includes(key) || key.startsWith(LOOKUP_STATS_ROW_PREFIX) || key === SHARING_KEY);
- if (relevant && (automaticBackupWaitingForState
- || automaticBackupNextAt !== null && Date.now() >= automaticBackupNextAt)) {
- void queueAutomaticBackup();
- }
-});
-
-// A browser install shares by default; the overlay copy is a client, so it
-// does not. Turning sharing off stores `host: null`; linking stores it off.
-async function initialiseSharing() {
- const stored = await chrome.storage.local.get([SHARING_KEY, DICTIONARY_STATE_KEY]);
- const host = stored[SHARING_KEY]?.host;
- if (host?.enabled === true || (host === undefined && !OVERLAY_MODE)) {
- getSharingHost().enable({ port: host?.port, network: host?.network === true, dictionaries: dictionaryCount(stored[DICTIONARY_STATE_KEY]) });
- }
- const address = stored[SHARING_KEY]?.client?.address;
- if (typeof address === "string" && address !== "") {
- sharingLinked = true;
- if (OVERLAY_MODE) await serialiseStorage(async () => {
- const current = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
- if (!current[SHARING_OPTIONS_VERSION_KEY]) await applyMirror({ options: current[OPTIONS_KEY] ?? null }, true);
- });
- getSharingClient().link(address);
- }
-}
-
-function handleAlarm(alarm) {
- if (alarm.name === AUTOMATIC_BACKUP_ALARM) {
- automaticBackupNextAt = null;
- void queueAutomaticBackup(true);
- return;
- }
- if (alarm.name === ANKI_INDEX_ALARM) {
- void reconcileAnkiIndex();
- return;
- }
- if (alarm.name === SHARING_HOST_ALARM) {
- getSharingHost().reconnect();
- return;
- }
- if (alarm.name !== UPDATE_ALARM) {
- return;
- }
- void sharingReady.then(() => (sharingLinked ? undefined : queueManagedUpdate({ install: true, dueOnly: true }))).catch((error) => {
- console.error("hoshidicts: scheduled dictionary updates failed:", describe(error));
- });
-}
-
-if (alarms === chrome.alarms) chrome.alarms.onAlarm.addListener(handleAlarm);
-
-chrome.downloads?.onChanged?.addListener(delta => {
- if (!delta.state || delta.state.current === "in_progress") return;
- getBackupDownloads().changed(delta.id).catch(error => {
- console.warn("hoshidicts: could not release a finished backup download:", describe(error));
- });
-});
-
-function warmUp() {
- void reconcileAnkiIndex();
- void queueAutomaticBackup(true);
- ensureOffscreen().catch((error) => {
- console.error("hoshidicts: could not create the offscreen document:", describe(error));
- });
- reconcileUpdateAlarm().catch((error) => {
- console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
- });
-}
-
-// A fresh installation seeds its setup state and initial preferences once, then
-// opens one startup tab. Only values that are still absent are written, so a
-// profile that already carries settings keeps them. Chrome reports "install"
-// again on every launch for an unpacked extension loaded from the command line,
-// so the absence of a setup record, not the reason alone, identifies a new
-// installation.
-async function beginFirstRunSetup() {
- const created = await serialiseStorage(async () => {
- const stored = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
- const values = {};
- if (stored[SETUP_STATE_KEY] === undefined) {
- values[SETUP_STATE_KEY] = initialSetupState(new Date().toISOString());
- }
- if (stored[OPTIONS_KEY] === undefined) {
- values[OPTIONS_KEY] = { ...validateOptionsPatch(FIRST_INSTALL_OPTIONS), revision: 1 };
- }
- if (Object.keys(values).length > 0) await writeLocalState(values);
- return Object.hasOwn(values, SETUP_STATE_KEY);
- });
- if (created) await chrome.tabs.create({ url: chrome.runtime.getURL(STARTUP_PAGE) });
-}
-
-// An overlay host has no tab to show setup in, so its first launch only seeds
-// the initial preferences. It runs on worker start because a host may never
-// report onInstalled.
-async function seedOverlayModeOptions() {
- await serialiseStorage(async () => {
- const stored = await chrome.storage.local.get(OPTIONS_KEY);
- if (stored[OPTIONS_KEY] !== undefined) return;
- const options = validateOptionsPatch({
- ...FIRST_INSTALL_OPTIONS,
- ...OVERLAY_MODE_OPTIONS,
- anki: overlayAnkiOptions(DEFAULT_OPTIONS).anki,
- });
- await writeLocalState({ [OPTIONS_KEY]: { ...options, revision: 1 } });
- });
-}
-
-// Load the dictionaries before the first hover asks for them. Extension updates,
-// browser starts and service-worker restarts never reach the first-run path, so
-// they cannot reopen setup or reset preferences.
-chrome.runtime.onInstalled.addListener((details) => {
- warmUp();
- if (details.reason !== "install" || OVERLAY_MODE) return;
- beginFirstRunSetup().catch((error) => {
- console.error("hoshidicts: could not start first-run setup:", describe(error));
- });
-});
-chrome.runtime.onStartup.addListener(warmUp);
-
-// Yomitan's native browser shortcuts for the features Hachidori has. The toggle
-// makes the toolbar switch's revisioned write inside the storage queue.
-async function toggleLookupsFromCommand() {
- await sharingReady;
- const toggle = async () => {
- const { options } = await readDictionaryStorage();
- return { target: WORKER_TARGET, type: "hd_options_write", requestId: null,
- baseRevision: optionsRevision(options), options: { hoverEnabled: !normaliseOptions(options).hoverEnabled } };
- };
- if (sharingLinked) return forwardWorkerRequest(await toggle());
- return serialiseStorage(async () => WORKER_HANDLERS.hd_options_write(await toggle()));
-}
-
-// Popup-action shortcuts run their in-page keybind action in the active tab.
-// Every frame's reader receives the command; one without an open popup, or
-// without a selection for the scans, does nothing.
-const READER_CONTENT_TARGET = "hachidori-reader";
-const READER_COMMANDS = new Set(["close", "addNote", "viewNotes", "playAudio", "nextEntry", "previousEntry",
- "firstEntry", "lastEntry", "nextEntryDifferentDictionary", "previousEntryDifferentDictionary", "historyBackward",
- "scanSelectedText", "scanTextAtSelection"]);
-
-chrome.commands?.onCommand?.addListener((command, tab) => {
- if (command === "openSettingsPage") {
- chrome.runtime.openOptionsPage().catch((error) => {
- console.error("hachidori: could not open settings:", describe(error));
- });
- } else if (command === "toggleTextScanning") {
- toggleLookupsFromCommand().catch((error) => {
- console.error("hachidori: could not toggle lookups:", describe(error));
- });
- } else if (READER_COMMANDS.has(command) && tab?.id !== undefined) {
- // Pages Chrome keeps content scripts out of, such as chrome://, have no reader.
- chrome.tabs.sendMessage(tab.id, { target: READER_CONTENT_TARGET, type: "hd_reader_command", action: command })
- .catch(() => {});
- }
-});
-
-// Alarms may be cleared across browser restarts. Module evaluation is the one
-// startup path every MV3 worker takes, including starts not caused by either
-// lifecycle event above.
-async function initialiseUpdateAlarm() {
- try {
- await reconcileUpdateAlarm();
- } catch (error) {
- console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
- }
-}
-
-async function initialiseAutomaticBackupAlarm() {
- try {
- await sharingReady;
- if (sharingLinked) {
- await alarms.clear(AUTOMATIC_BACKUP_ALARM);
- return;
- }
- const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
- if (stored === undefined) {
- await queueAutomaticBackup(true);
- return;
- }
- const nextAt = nextAutomaticBackupTime(stored);
- if (Date.now() >= nextAt) await queueAutomaticBackup(true);
- else await scheduleAutomaticBackup(nextAt);
- } catch (error) {
- console.warn("hachidori: could not reconcile the automatic backup alarm:", describe(error));
- }
-}
-
-sharingReady = initialiseSharing().catch((error) => {
- console.error("hachidori: could not restore sharing:", describe(error));
-});
-void initialiseUpdateAlarm(); // NOSONAR -- top-level await prevents this MV3 worker from activating.
-void initialiseAutomaticBackupAlarm(); // NOSONAR -- top-level await prevents this MV3 worker from activating.
-void chrome.storage.local.get(OPTIONS_KEY).then(stored =>
- applyCustomJavaScript(chrome, normaliseOptions(stored[OPTIONS_KEY]).customPopupJavascript));
-
-if (OVERLAY_MODE) {
- seedOverlayModeOptions().catch((error) => {
- console.error("hoshidicts: could not seed overlay mode options:", describe(error));
- });
-}
-void reconcileAnkiIndex(); // NOSONAR -- initialize without delaying worker activation.
diff --git a/vendor/hachidori/extension/backup-archive.js b/vendor/hachidori/extension/backup-archive.js
deleted file mode 100644
index 931f20b9..00000000
--- a/vendor/hachidori/extension/backup-archive.js
+++ /dev/null
@@ -1,116 +0,0 @@
-// Hachidori's own stored ZIP64 format; loaded only for explicit backup work.
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { BlobReader, BlobWriter, ZipReader, ZipWriter } from "./vendor/zip.js";
-import { assertLookupStatsRows, emptyLookupStats } from "./lookup-stats.js";
-
-const MANIFEST = "hachidori-backup.json";
-const ZIP_OPTIONS = {
- useWebWorkers: false,
- level: 0,
- zip64: true,
- extendedTimestamp: false,
- lastModDate: new Date(1980, 0, 1),
-};
-
-export function assertBackupPath(path) {
- if (typeof path !== "string" || /[\\\u0000-\u001f\u007f]/u.test(path)
- || path.split("/").some(part => part === "" || part === "." || part === "..")) {
- throw new Error(`Invalid backup file path: ${String(path)}`);
- }
-}
-
-function assertPayloadPath(path) {
- assertBackupPath(path);
- if (!/^dictionaries\/(?:0|[1-9]\d*)\/.+/u.test(path)) {
- throw new Error(`Unexpected backup payload path: ${path}`);
- }
-}
-
-function assertFileList(files) {
- if (!Array.isArray(files)) throw new Error("The backup has no file list.");
- const paths = new Set();
- for (const file of files) {
- assertPayloadPath(file?.path);
- if (!Number.isSafeInteger(file.size) || file.size < 0) throw new Error("Invalid backup file size.");
- if (paths.has(file.path)) throw new Error(`Duplicate backup file: ${file.path}`);
- paths.add(file.path);
- }
- for (const path of paths) {
- const components = path.split("/");
- components.pop();
- while (components.length > 0) {
- if (paths.has(components.join("/"))) throw new Error(`Backup file is also a directory: ${path}`);
- components.pop();
- }
- }
-}
-
-export async function createBackupArchive(snapshot, files, lookupStatsRows, createdAt = new Date().toISOString()) {
- const entries = files.map(({ path, data }) => ({ path, size: data.size }));
- assertFileList(entries);
- assertLookupStatsRows(snapshot?.lookupStats, lookupStatsRows);
- const manifest = { format: "hachidori-backup", version: 2, createdAt, snapshot, lookupStatsRows, files: entries };
- /** @type {{add(name: string, reader: object): Promise, close(): Promise}} */
- const writer = new ZipWriter(new BlobWriter("application/zip"), ZIP_OPTIONS);
- await writer.add(MANIFEST, new BlobReader(new Blob([JSON.stringify(manifest)])));
- for (const file of files) await writer.add(file.path, new BlobReader(file.data));
- return writer.close();
-}
-
-function assertRegularEntry(entry) {
- assertBackupPath(entry.filename);
- const kind = entry.unixMode === undefined ? 0 : entry.unixMode & 0o170000;
- if (entry.directory || entry.symlink || (kind !== 0 && kind !== 0o100000)) {
- throw new Error(`The backup entry is not a regular file: ${entry.filename}`);
- }
- if (entry.encrypted || entry.compressionMethod !== 0) {
- throw new Error("Hachidori backups contain only unencrypted, stored ZIP entries.");
- }
-}
-
-function readEntry(entry) {
- return entry.getData(new BlobWriter(), {
- useWebWorkers: false, checkSignature: true, checkOverlappingEntry: true, checkAmbiguity: true,
- });
-}
-
-export async function openBackupArchive(blob) {
- const reader = new ZipReader(new BlobReader(blob), { useWebWorkers: false, checkAmbiguity: true });
- try {
- const entries = await reader.getEntries();
- const byPath = new Map();
- for (const entry of entries) {
- assertRegularEntry(entry);
- if (byPath.has(entry.filename)) throw new Error(`Duplicate backup file: ${entry.filename}`);
- byPath.set(entry.filename, entry);
- }
- const manifestEntry = byPath.get(MANIFEST);
- if (!manifestEntry) throw new Error("The selected archive is not a Hachidori backup.");
- const manifest = JSON.parse(await (await readEntry(manifestEntry)).text());
- if (manifest?.format !== "hachidori-backup" || ![1, 2].includes(manifest.version)
- || typeof manifest.createdAt !== "string" || Number.isNaN(Date.parse(manifest.createdAt))) {
- throw new Error("The selected archive is not a supported Hachidori backup.");
- }
- const snapshot = manifest.version === 1 ? { ...manifest.snapshot, lookupStats: emptyLookupStats() } : manifest.snapshot;
- // Older backups include the retired external corpus integration. Drop only
- // those fields before the complete snapshot contract validates the restore.
- delete snapshot?.options?.corpusSeenEnabled;
- delete snapshot?.options?.corpusSeenUrl;
- const lookupStatsRows = manifest.version === 1 ? [] : manifest.lookupStatsRows;
- assertLookupStatsRows(snapshot?.lookupStats, lookupStatsRows);
- assertFileList(manifest.files);
- if (entries.length !== manifest.files.length + 1) throw new Error("The backup contains unlisted or missing files.");
- const files = [];
- for (const file of manifest.files) {
- const entry = byPath.get(file.path);
- if (!entry) throw new Error(`The backup is missing ${file.path}`);
- if (entry.uncompressedSize !== file.size) throw new Error(`Incorrect backup file size: ${file.path}`);
- const data = await readEntry(entry);
- if (data.size !== file.size) throw new Error(`Incorrect extracted backup file size: ${file.path}`);
- files.push({ path: file.path, data });
- }
- return { snapshot, lookupStatsRows, createdAt: manifest.createdAt, files };
- } finally {
- await reader.close();
- }
-}
diff --git a/vendor/hachidori/extension/backup-automatic.js b/vendor/hachidori/extension/backup-automatic.js
deleted file mode 100644
index e524cf91..00000000
--- a/vendor/hachidori/extension/backup-automatic.js
+++ /dev/null
@@ -1,130 +0,0 @@
-// Local daily automatic snapshots using the complete manual-backup payload.
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { assertLookupStatsRows } from "./lookup-stats.js";
-import { assertBackupSnapshot } from "./backup-state.js";
-
-export const AUTOMATIC_BACKUPS_KEY = "automaticBackups";
-export const AUTOMATIC_BACKUP_ALARM = "hachidori-automatic-backup";
-export const AUTOMATIC_BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
-export const AUTOMATIC_BACKUP_SCHEMA_VERSION = 1;
-
-export function emptyAutomaticBackupStore() {
- return { schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION, backups: [] };
-}
-
-export function automaticBackupStore(value) {
- if (value === undefined) return emptyAutomaticBackupStore();
- if (!value || typeof value !== "object" || Array.isArray(value)
- || value.schemaVersion !== AUTOMATIC_BACKUP_SCHEMA_VERSION
- || !Array.isArray(value.backups)) {
- throw new Error("The automatic backup index has an unsupported schema.");
- }
- return value;
-}
-
-export function automaticBackupTime(record) {
- const timestamp = typeof record?.createdAt === "string" ? Date.parse(record.createdAt) : Number.NaN;
- return Number.isFinite(timestamp) ? timestamp : null;
-}
-
-export function newestAutomaticBackupTime(value) {
- const store = automaticBackupStore(value);
- let newest = null;
- for (const record of store.backups) {
- const timestamp = automaticBackupTime(record);
- if (timestamp !== null && (newest === null || timestamp > newest)) newest = timestamp;
- }
- return newest;
-}
-
-export function automaticBackupDue(value, now = Date.now()) {
- const newest = newestAutomaticBackupTime(value);
- return newest === null || now - newest >= AUTOMATIC_BACKUP_INTERVAL_MS;
-}
-
-export function nextAutomaticBackupTime(value, now = Date.now()) {
- const newest = newestAutomaticBackupTime(value);
- return newest === null ? now : newest + AUTOMATIC_BACKUP_INTERVAL_MS;
-}
-
-export async function assertAutomaticBackupRecord(record) {
- if (!record || typeof record !== "object" || Array.isArray(record)
- || typeof record.id !== "string" || record.id === ""
- || automaticBackupTime(record) === null
- || !Array.isArray(record.lookupStatsRows)) {
- throw new Error("The automatic backup record is invalid.");
- }
- await assertBackupSnapshot(record.snapshot);
- assertLookupStatsRows(record.snapshot.lookupStats, record.lookupStatsRows);
- return record;
-}
-
-function newestFirst(left, right) {
- return automaticBackupTime(right) - automaticBackupTime(left)
- || String(right.id).localeCompare(String(left.id));
-}
-
-export async function validAutomaticBackups(value) {
- const store = automaticBackupStore(value);
- const valid = [];
- let corruptCount = 0;
- for (const record of store.backups) {
- try {
- await assertAutomaticBackupRecord(record);
- valid.push(record);
- } catch {
- corruptCount += 1;
- }
- }
- valid.sort(newestFirst);
- const ids = new Set();
- const backups = [];
- for (const record of valid) {
- if (ids.has(record.id)) {
- corruptCount += 1;
- continue;
- }
- ids.add(record.id);
- backups.push(record);
- }
- return { backups, corruptCount };
-}
-
-// `limit` is the user's `automaticBackupDays` option: one snapshot per day, so
-// the retained count is the number of days kept. A lowered limit prunes when
-// the next snapshot is written, not when the option changes.
-export async function replaceAutomaticBackup(value, record, limit) {
- await assertAutomaticBackupRecord(record);
- const { backups } = await validAutomaticBackups(value);
- const retained = backups.filter(candidate => candidate.id !== record.id);
- retained.push(record);
- retained.sort(newestFirst);
- return {
- schemaVersion: AUTOMATIC_BACKUP_SCHEMA_VERSION,
- backups: retained.slice(0, limit),
- };
-}
-
-export function automaticBackupJsonBytes(value) {
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
-}
-
-export function formatAutomaticBackupAge(createdAt, now = Date.now(), locale = undefined) {
- const timestamp = Date.parse(createdAt);
- if (!Number.isFinite(timestamp)) return "unknown time";
- const delta = timestamp - now;
- const absolute = Math.abs(delta);
- if (absolute < 60_000) return delta > 0 ? "in less than a minute" : "less than a minute ago";
- let unit = "day";
- let unitMilliseconds = 24 * 60 * 60_000;
- if (absolute < 60 * 60_000) {
- unit = "minute";
- unitMilliseconds = 60_000;
- } else if (absolute < 24 * 60 * 60_000) {
- unit = "hour";
- unitMilliseconds = 60 * 60_000;
- }
- const magnitude = Math.max(1, Math.floor(absolute / unitMilliseconds));
- return new Intl.RelativeTimeFormat(locale, { numeric: "always" })
- .format(delta < 0 ? -magnitude : magnitude, unit);
-}
diff --git a/vendor/hachidori/extension/backup-downloads.js b/vendor/hachidori/extension/backup-downloads.js
deleted file mode 100644
index ee85d76b..00000000
--- a/vendor/hachidori/extension/backup-downloads.js
+++ /dev/null
@@ -1,54 +0,0 @@
-// Keep engine-owned backup URLs alive until Chrome finishes saving the file.
-// Session storage survives service-worker restarts, but is not backup content.
-// SPDX-License-Identifier: GPL-3.0-or-later
-const KEY = "backupDownloads";
-const TARGET = "hoshidicts-offscreen";
-
-export function createBackupDownloads(chrome, relay) {
- let tail = Promise.resolve();
- const serialise = job => {
- const result = tail.then(job, job);
- tail = result.catch(() => {});
- return result;
- };
- const release = blobUrl => relay({ target: TARGET, type: "hd_backup_release", blobUrl });
- const read = async () => (await chrome.storage.session.get(KEY))[KEY] ?? {};
-
- async function finish(id) {
- const tracked = await read();
- if (!Object.hasOwn(tracked, id)) return;
- const [download] = await chrome.downloads.search({ id: Number(id) });
- if (download?.state === "in_progress") return;
- const reply = await release(tracked[id]);
- if (!reply?.ok) throw new Error(reply?.error || "Could not release the saved backup archive.");
- delete tracked[id];
- await chrome.storage.session.set({ [KEY]: tracked });
- }
-
- return {
- async download() {
- const exported = await relay({ target: TARGET, type: "hd_backup_export" });
- if (!exported?.ok) throw new Error(exported?.error || "Could not create a backup archive.");
- let id;
- try {
- id = await chrome.downloads.download({ url: exported.blobUrl, saveAs: true,
- filename: `hachidori-backup-${new Date().toISOString().slice(0, 10)}.zip`, conflictAction: "uniquify" });
- } catch (error) {
- await serialise(() => release(exported.blobUrl));
- throw error;
- }
- let warning = null;
- try {
- await serialise(async () => {
- await chrome.storage.session.set({ [KEY]: { ...await read(), [id]: exported.blobUrl } });
- // A small download can complete before its ownership record is written.
- await finish(id);
- });
- } catch (error) {
- warning = `Download started, but its temporary archive could not be tracked: ${error.message}`;
- }
- return { downloadId: id, warning };
- },
- changed(id) { return serialise(() => finish(id)); },
- };
-}
diff --git a/vendor/hachidori/extension/backup-settings.js b/vendor/hachidori/extension/backup-settings.js
deleted file mode 100644
index 5d458acc..00000000
--- a/vendor/hachidori/extension/backup-settings.js
+++ /dev/null
@@ -1,277 +0,0 @@
-// Explicit complete backup/restore controls; the engine owns preparation tokens.
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { formatAutomaticBackupAge } from "./backup-automatic.js";
-import { downloadBlob } from "./blob-download.js";
-
-export function createBackupSettingsController({
- document, send, download, listAutomatic = null, checkReady, setBusy, status, refresh,
- trackPreparation = () => {}, cancelPreparation = () => {}, browserName = "Chrome",
-}) {
- const element = id => document.getElementById(id);
- const window = document.defaultView;
- let busy = false, prepared = null;
- let preparingToken = null;
- let exportedUrl = null;
- let pageEpoch = 0;
- let automaticBackups = [];
- let automaticTimer = null;
- let automaticLoadEpoch = 0;
-
- function render() {
- element("backup-export").disabled = busy;
- element("backup-file").disabled = busy;
- element("backup-cancel").disabled = busy;
- element("backup-restore").disabled = busy || !prepared || !element("backup-confirm").checked;
- element("backup-confirm").disabled = busy;
- element("backup-preview").hidden = prepared === null;
- for (const button of element("automatic-backup-list").querySelectorAll("button")) {
- button.disabled = busy;
- }
- }
-
- async function run(message, operation, requireReady = true) {
- if (busy) return;
- try {
- if (requireReady) checkReady();
- busy = true;
- setBusy(true);
- render();
- status(message, "");
- await operation();
- } catch (error) {
- status(error.message || String(error), "error");
- } finally {
- busy = false;
- setBusy(false);
- render();
- }
- }
-
- async function cancelPrepared() {
- if (!prepared) return;
- const token = prepared.token;
- const reply = await send("hd_backup_cancel", { token });
- if (!reply.ok) throw new Error(reply.error || "Could not discard the prepared restore.");
- trackPreparation(token, false);
- prepared = null;
- element("backup-confirm").checked = false;
- render();
- }
-
- async function releaseExport() {
- if (!exportedUrl) return;
- const reply = await send("hd_backup_release", { blobUrl: exportedUrl });
- if (!reply?.ok) throw new Error(reply?.error || "Could not release the temporary backup archive. Try exporting again.");
- exportedUrl = null;
- }
-
- function renderAutomaticAges() {
- for (const row of element("automatic-backup-list").children) {
- const backup = automaticBackups.find(candidate => candidate.id === row.dataset.backupId);
- if (!backup) continue;
- const age = formatAutomaticBackupAge(backup.createdAt);
- row.querySelector(".automatic-backup-age").textContent = age;
- row.querySelector(".automatic-backup-restore").textContent = `Restore from ${age}`;
- }
- }
-
- function renderAutomaticBackups() {
- const rows = document.createDocumentFragment();
- for (const backup of automaticBackups) {
- const row = element("automatic-backup-template").content.firstElementChild.cloneNode(true);
- row.dataset.backupId = backup.id;
- row.querySelector(".automatic-backup-detail").textContent =
- `${backup.dictionaries.length} dictionaries · ${backup.customEntryCount} personal entries`;
- const created = row.querySelector(".automatic-backup-created");
- created.dateTime = backup.createdAt;
- created.textContent = new Date(backup.createdAt).toLocaleString();
- row.querySelector(".automatic-backup-restore").addEventListener("click", () => {
- const age = formatAutomaticBackupAge(backup.createdAt);
- void run("Checking the automatic backup and validating its dictionary files…", () =>
- prepareRestore("hd_backup_auto_prepare", { id: backup.id }, `Automatic backup from ${age}`));
- });
- rows.append(row);
- }
- element("automatic-backup-list").replaceChildren(rows);
- renderAutomaticAges();
- render();
- }
-
- async function loadAutomaticBackups() {
- if (!listAutomatic) return;
- const epoch = ++automaticLoadEpoch;
- try {
- const reply = await listAutomatic();
- if (epoch !== automaticLoadEpoch) return;
- if (!reply?.ok) throw new Error(reply?.error || "Could not read automatic backups.");
- automaticBackups = Array.isArray(reply.backups) ? reply.backups : [];
- renderAutomaticBackups();
- if (reply.corruptCount > 0) {
- const count = reply.corruptCount === 1 ? "One automatic backup is" : `${reply.corruptCount} automatic backups are`;
- element("automatic-backup-status").textContent =
- `${count} damaged. Any valid older backup remains available below.`;
- } else if (automaticBackups.length === 0) {
- element("automatic-backup-status").textContent =
- "No automatic backup has been created yet.";
- } else {
- element("automatic-backup-status").textContent = "";
- }
- } catch (error) {
- if (epoch !== automaticLoadEpoch) return;
- automaticBackups = [];
- renderAutomaticBackups();
- element("automatic-backup-status").textContent =
- error.message || String(error);
- }
- }
-
- function startAutomaticBackups() {
- if (!listAutomatic) return;
- void loadAutomaticBackups();
- if (automaticTimer === null) {
- automaticTimer = window.setInterval(renderAutomaticAges, 60_000);
- }
- }
-
- function showPrepared(reply, label) {
- prepared = reply;
- element("backup-confirm").checked = false;
- element("backup-file-name").textContent = label;
- const created = element("backup-created");
- created.dateTime = reply.createdAt;
- created.textContent = new Date(reply.createdAt).toLocaleString();
- element("backup-count").textContent = `${reply.dictionaries.length} dictionaries · ${reply.customEntryCount} personal entries`;
- const list = document.createDocumentFragment();
- for (const dictionary of reply.dictionaries) {
- const item = document.createElement("li");
- item.textContent = `${dictionary.title}${dictionary.enabled ? "" : " (disabled)"}`;
- list.append(item);
- }
- element("backup-dictionaries").replaceChildren(list);
- status(reply.warning || "Backup checked. Nothing has been replaced.", reply.warning ? "" : "ready");
- render();
- element("backup-preview-heading").focus();
- }
-
- async function prepareRestore(type, fields, label) {
- const epoch = pageEpoch;
- await cancelPrepared();
- if (epoch !== pageEpoch) return;
- const token = window.crypto.randomUUID();
- preparingToken = token;
- trackPreparation(token, true);
- let reply;
- try {
- reply = await send(type, { ...fields, token });
- } catch (error) {
- // The engine may have prepared successfully before its reply was lost.
- try {
- const cancelled = await send("hd_backup_cancel", { token });
- if (cancelled.ok) trackPreparation(token, false);
- } catch { /* Keep the original failure. */ }
- throw error;
- } finally {
- preparingToken = null;
- }
- if (!reply.ok) {
- trackPreparation(token, false);
- throw new Error(reply.error || "This backup could not be prepared.");
- }
- if (epoch !== pageEpoch) return;
- showPrepared(reply, label);
- }
-
- element("backup-export").addEventListener("click", () => {
- void run("Creating the backup archive…", async () => {
- if (!download) {
- await releaseExport();
- const exported = await send("hd_backup_export");
- if (!exported.ok) throw new Error(exported.error || "Could not create the backup.");
- exportedUrl = exported.blobUrl;
- let blob;
- // A failure here leaves exportedUrl set; the next export click retries the release.
- const response = await window.fetch(exported.blobUrl);
- if (!response.ok) throw new Error("Could not read the backup archive.");
- blob = await response.blob();
- await releaseExport();
- downloadBlob(document, blob, `hachidori-backup-${new Date().toISOString().slice(0, 10)}.zip`);
- status("Save requested. Choose where to save the backup in your app’s save dialog.", "ready", true);
- return;
- }
- const reply = await download();
- if (!reply.ok) throw new Error(reply.error || "Could not create the backup.");
- status(reply.warning || `Download started. Check ${browserName}’s downloads for progress.`, reply.warning ? "" : "ready", true);
- });
- });
-
- element("backup-file").addEventListener("change", () => {
- const file = element("backup-file").files?.[0];
- element("backup-file").value = "";
- if (!file) return;
- void run("Checking the archive and preparing fresh dictionary files…", async () => {
- const blobUrl = window.URL.createObjectURL(file);
- try {
- await prepareRestore("hd_backup_prepare", { blobUrl }, file.name);
- } finally {
- window.URL.revokeObjectURL(blobUrl);
- }
- });
- });
-
- element("backup-confirm").addEventListener("change", render);
- element("backup-cancel").addEventListener("click", () => run("Discarding the prepared restore…", async () => {
- await cancelPrepared();
- status("Restore cancelled. Your data has not changed.", "");
- element("backup-file").focus();
- }, false));
- element("backup-restore").addEventListener("click", () => {
- if (!prepared || !element("backup-confirm").checked) return;
- void run("Restoring dictionaries and settings…", async () => {
- const token = prepared.token;
- // A restore attempt consumes its token, including an uncertain reply.
- prepared = null;
- trackPreparation(token, false);
- element("backup-confirm").checked = false;
- const reply = await send("hd_backup_restore", { token });
- if (!reply.ok) throw new Error(reply.error || "The restore could not be confirmed. Check the current library before trying again.");
- status(reply.warning || "Restored successfully.", reply.warning ? "" : "ready", true);
- try { await refresh(); }
- catch { status("Restored successfully. Reopen Settings to refresh this page.", "ready", true); }
- await loadAutomaticBackups();
- });
- });
-
- window.addEventListener("pagehide", () => {
- pageEpoch += 1;
- automaticLoadEpoch += 1;
- if (automaticTimer !== null) {
- window.clearInterval(automaticTimer);
- automaticTimer = null;
- }
- const token = preparingToken ?? prepared?.token;
- preparingToken = null;
- prepared = null;
- element("backup-confirm").checked = false;
- render();
- if (token) status("Restore cancelled. Choose the backup again to prepare it.", "");
- if (token) {
- // Port delivery is synchronous and its disconnect is a second cleanup
- // signal when an older Chrome drops this page's final runtime message.
- cancelPreparation(token);
- void send("hd_backup_cancel", { token })
- .then(reply => { if (reply.ok) trackPreparation(token, false); })
- .catch(() => {});
- }
- if (exportedUrl) {
- const blobUrl = exportedUrl;
- exportedUrl = null;
- void send("hd_backup_release", { blobUrl }).catch(() => {});
- }
- });
- window.addEventListener("pageshow", event => {
- if (event.persisted) startAutomaticBackups();
- });
- render();
- startAutomaticBackups();
- return { render, refreshAutomaticBackups: loadAutomaticBackups };
-}
diff --git a/vendor/hachidori/extension/backup-state.js b/vendor/hachidori/extension/backup-state.js
deleted file mode 100644
index 5ba73c95..00000000
--- a/vendor/hachidori/extension/backup-state.js
+++ /dev/null
@@ -1,115 +0,0 @@
-// Complete persisted-state contract shared by the engine and storage owner.
-// SPDX-License-Identifier: GPL-3.0-or-later
-import "./reader-options.js";
-import "./dictionary-group-state.js";
-import {
- assertCustomSourceState, customDictionarySemanticRevision,
- normaliseCustomDictionaryDocument, parseCustomDictionary,
-} from "./custom-dictionary.js";
-import { assertDictionaryUpdateSchedule, assertRecommendedDictionary, normaliseUpdateSettings, recommendedDictionarySource } from "./managed-dictionary-source.js";
-import { sameJsonValue } from "./json-value.js";
-import { assertLookupStatsDescriptor } from "./lookup-stats.js";
-
-export function backupRevisions(snapshot) {
- return Object.fromEntries(["state", "options", "document", "updates", "lookupStats"].map(key => {
- const revision = snapshot[key]?.revision;
- return [key, Number.isSafeInteger(revision) && revision >= 0 ? revision : 0];
- }));
-}
-
-export function restoredBackupSnapshot(current, archived, dictionaries) {
- return Object.fromEntries(Object.entries(backupRevisions(current)).map(([key, revision]) => [key, {
- ...(key === "options"
- ? globalThis.HDReaderOptions.projectStoredOptions(archived[key])
- : archived[key]),
- ...(key === "state" ? { dictionaries } : {}),
- ...(key === "lookupStats" ? { generation: crypto.randomUUID() } : {}),
- revision: revision + 1,
- }]));
-}
-
-function assertDictionaryList(dictionaries) {
- const ids = new Set(), titles = new Set();
- for (const entry of dictionaries) {
- assertDictionaryUpdateSchedule(entry);
- if (typeof entry?.id !== "string" || entry.id === "" || ids.has(entry.id)
- || typeof entry.title !== "string" || entry.title === "" || titles.has(entry.title)
- || /[\\/]/u.test(entry.title) || entry.title.includes("\0") || [".", ".."].includes(entry.title)
- || typeof entry.revision !== "string"
- || typeof entry.enabled !== "boolean" || typeof entry.favorite !== "boolean"
- || (entry.displayName !== null && typeof entry.displayName !== "string")
- || ["termCount", "frequencyCount", "pitchCount", "kanjiCount", "mediaCount"].some(key =>
- !Number.isSafeInteger(entry[key]) || entry[key] < 0)) {
- throw new Error("The backup contains invalid or duplicate dictionary packages.");
- }
- const recommended = recommendedDictionarySource(entry.sourceId);
- if (recommended) {
- assertRecommendedDictionary(recommended, entry);
- if (entry.downloadUrl !== recommended.downloadUrl) {
- throw new Error("The backup dictionary does not match its recommended source.");
- }
- }
- ids.add(entry.id);
- titles.add(entry.title);
- }
-}
-
-function assertGroups(groups, dictionaries) {
- const { normaliseDictionaryGroups, groupNameKey } = globalThis.HDDictionaryGroups;
- if (!Array.isArray(groups) || !sameJsonValue(groups, normaliseDictionaryGroups(groups, dictionaries))) {
- throw new Error("The backup contains invalid dictionary groups.");
- }
- const ids = new Set(), names = new Set(["all"]);
- for (const group of groups) {
- const key = groupNameKey(group.name);
- if (ids.has(group.id) || names.has(key)) throw new Error("The backup contains duplicate dictionary groups.");
- ids.add(group.id);
- names.add(key);
- }
-}
-
-function validBackupReaderOptions(options) {
- if (!options || typeof options !== "object" || Array.isArray(options)) return false;
- const allowed = new Set([...Object.keys(globalThis.HDReaderOptions.DEFAULT_OPTIONS), "modifier"]);
- if (Object.keys(options).some(key => !allowed.has(key))) return false;
- let projected;
- try {
- projected = globalThis.HDReaderOptions.validateOptionsPatch(options);
- } catch {
- return false;
- }
- // A legacy backup may have only `customLinks`, and its singleton Anki
- // object has no `templates`. If the richer fields are present, however, they
- // must already be canonical instead of relying on migration to resolve two
- // conflicting representations.
- if (Object.hasOwn(options, "customButtons") && Object.hasOwn(options, "customLinks")
- && !sameJsonValue(options.customLinks, projected.customLinks)) return false;
- if (options.anki && Object.hasOwn(options.anki, "templates")
- && !sameJsonValue(options.anki, projected.anki)) return false;
- return true;
-}
-
-export async function assertBackupSnapshot(snapshot) {
- if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
- || ["state", "options", "document", "updates"].some(key =>
- !Number.isSafeInteger(snapshot[key]?.revision) || snapshot[key].revision < 0)
- || snapshot.state?.schemaVersion !== 1 || !Array.isArray(snapshot.state.dictionaries)) {
- throw new Error("The backup contains invalid dictionary state.");
- }
- assertDictionaryList(snapshot.state.dictionaries);
- assertLookupStatsDescriptor(snapshot.lookupStats);
- assertGroups(snapshot.state.groups, snapshot.state.dictionaries);
- const document = normaliseCustomDictionaryDocument(snapshot.document);
- const entries = parseCustomDictionary(document.text).entries;
- const semanticRevision = await customDictionarySemanticRevision(entries);
- if (document.semanticRevision !== semanticRevision) throw new Error("The backup custom source has invalid semantics.");
- assertCustomSourceState(snapshot.state.dictionaries, semanticRevision, entries.length);
- const { revision, ...options } = snapshot.options ?? {};
- if (!Number.isSafeInteger(revision) || revision < 0
- || !validBackupReaderOptions(options)) {
- throw new Error("The backup contains invalid reader settings.");
- }
- if (!sameJsonValue(snapshot.updates, normaliseUpdateSettings(snapshot.updates))) {
- throw new Error("The backup contains invalid update settings.");
- }
-}
diff --git a/vendor/hachidori/extension/base64.js b/vendor/hachidori/extension/base64.js
deleted file mode 100644
index 1b99fa76..00000000
--- a/vendor/hachidori/extension/base64.js
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-// Base64 for media, captured audio and screenshots. Uint8Array.prototype.toBase64
-// and Uint8Array.fromBase64 (Chrome 143+, Firefox 133+) do in about 0.5 ms per
-// megabyte what the String.fromCodePoint/btoa and atob/Uint8Array.from loops
-// take 40–55 ms for, with identical results; both remain as the fallback.
-
-const CHUNK = 0x8000;
-
-export function encodeBase64(data, { btoa: toAscii = globalThis.btoa } = {}) {
- const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
- if (typeof bytes.toBase64 === "function") {
- try {
- return bytes.toBase64();
- } catch {
- // Fall through to the portable path.
- }
- }
- let binary = "";
- for (let offset = 0; offset < bytes.length; offset += CHUNK) {
- binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + CHUNK));
- }
- return toAscii(binary);
-}
-
-export function decodeBase64(text, { atob: fromAscii = globalThis.atob } = {}) {
- if (typeof Uint8Array.fromBase64 === "function") {
- try {
- return Uint8Array.fromBase64(text);
- } catch {
- // Let the portable path produce its own result or error.
- }
- }
- const binary = fromAscii(text);
- const bytes = new Uint8Array(binary.length);
- for (let index = 0; index < binary.length; index += 1) {
- bytes[index] = binary.charCodeAt(index);
- }
- return bytes;
-}
diff --git a/vendor/hachidori/extension/blob-download.js b/vendor/hachidori/extension/blob-download.js
deleted file mode 100644
index 4035f28b..00000000
--- a/vendor/hachidori/extension/blob-download.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-export function downloadBlob(document, blob, filename) {
- const window = document.defaultView;
- const url = window.URL.createObjectURL(blob);
- try {
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = filename;
- anchor.click();
- } catch (error) {
- window.URL.revokeObjectURL(url);
- throw error;
- }
- window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
-}
diff --git a/vendor/hachidori/extension/browser-api.js b/vendor/hachidori/extension/browser-api.js
deleted file mode 100644
index ae33eaeb..00000000
--- a/vendor/hachidori/extension/browser-api.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Browser API and extension-origin facts shared by module contexts.
-// Content scripts intentionally keep their callback-style `chrome` calls.
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-export function selectExtensionApi(scope = globalThis) {
- return scope.browser ?? scope.chrome ?? null;
-}
-
-export const extensionApi = selectExtensionApi();
-
-export function extensionProtocol(api = extensionApi) {
- const url = api?.runtime?.getURL?.("");
- if (typeof url !== "string" || url === "") return "";
- try {
- return new URL(url).protocol;
- } catch {
- return "";
- }
-}
-
-export function browserKind(api = extensionApi) {
- return extensionProtocol(api) === "moz-extension:" ? "firefox" : "chrome";
-}
-
-export const BROWSER_KIND = browserKind();
-export const IS_FIREFOX = BROWSER_KIND === "firefox";
-
-export function extensionDocumentUrl(path, api = extensionApi) {
- return api?.runtime?.getURL?.(path) ?? path;
-}
-
-export function expectedBackgroundUrl(api = extensionApi) {
- return extensionDocumentUrl(
- browserKind(api) === "firefox" ? "firefox-background.html" : "background.js",
- api,
- );
-}
-
-export function isExactExtensionSender(sender, path, api = extensionApi, { tab = null } = {}) {
- if (sender?.id !== api?.runtime?.id || sender?.url !== extensionDocumentUrl(path, api)) return false;
- if (tab === false && sender.tab !== undefined) return false;
- if (tab === true && sender.tab === undefined) return false;
- return true;
-}
diff --git a/vendor/hachidori/extension/capture-audio-worklet.js b/vendor/hachidori/extension/capture-audio-worklet.js
deleted file mode 100644
index cd14ccc3..00000000
--- a/vendor/hachidori/extension/capture-audio-worklet.js
+++ /dev/null
@@ -1,53 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-class HachidoriCaptureProcessor extends AudioWorkletProcessor {
- constructor() {
- super();
- this.batch = new Float32Array(2048);
- this.length = 0;
- this.startFrame = 0;
- this.nextFrame = null;
- }
-
- inputInterrupted(frameCount) {
- if (this.nextFrame !== null && (frameCount === 0 || currentFrame !== this.nextFrame)) {
- const expectedFrame = this.nextFrame;
- this.length = 0;
- this.nextFrame = frameCount ? currentFrame + frameCount : null;
- this.port.postMessage({
- discontinuity: {
- expectedFrame,
- actualFrame: frameCount ? currentFrame : null,
- },
- });
- return true;
- }
- this.nextFrame = frameCount ? currentFrame + frameCount : null;
- return false;
- }
-
- // Empty input can precede the first active quantum. Returning false allows
- // Chrome to permanently retire this processor before the source is ready.
- process(inputs) { // NOSONAR -- S3516: the AudioWorklet lifetime contract requires true.
- const channels = inputs[0];
- const frameCount = channels?.[0]?.length ?? 0;
- this.inputInterrupted(frameCount);
- if (frameCount === 0) return true;
- for (let frame = 0; frame < frameCount; frame += 1) {
- if (this.length === 0) this.startFrame = currentFrame + frame;
- let sample = 0;
- for (const channel of channels) sample += channel[frame] ?? 0;
- this.batch[this.length] = sample / channels.length;
- this.length += 1;
- if (this.length === this.batch.length) {
- const samples = this.batch;
- this.port.postMessage({ startFrame: this.startFrame, samples: samples.buffer }, [samples.buffer]);
- this.batch = new Float32Array(2048);
- this.length = 0;
- }
- }
- return true;
- }
-}
-
-registerProcessor("hachidori-capture-audio", HachidoriCaptureProcessor);
diff --git a/vendor/hachidori/extension/capture-buffer.js b/vendor/hachidori/extension/capture-buffer.js
deleted file mode 100644
index 3a820cc7..00000000
--- a/vendor/hachidori/extension/capture-buffer.js
+++ /dev/null
@@ -1,225 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-import {
- CAPTURE_SAMPLE_RATE,
- MAX_FRAME_BYTES,
- MAX_LIVE_FRAME_BYTES,
- MAX_PINNED_FRAME_BYTES,
- MAX_WAV_BYTES,
-} from "./media-limits.js";
-
-export {
- CAPTURE_SAMPLE_RATE,
- MAX_FRAME_BYTES,
- MAX_LIVE_FRAME_BYTES,
- MAX_PINNED_FRAME_BYTES,
- MAX_WAV_BYTES,
-};
-
-function finiteTime(value, label) {
- if (!Number.isFinite(value)) throw new Error(`${label} must be finite`);
- return value;
-}
-
-function bytes(value) {
- if (value instanceof Uint8Array) return value;
- if (value instanceof ArrayBuffer) return new Uint8Array(value);
- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
- throw new Error("capture frame data must be bytes");
-}
-
-export function createFrameRing({
- maxBytes = MAX_LIVE_FRAME_BYTES,
- maxAgeMs,
- maxFrameBytes = MAX_FRAME_BYTES,
-} = {}) {
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 1
- || !Number.isFinite(maxAgeMs) || maxAgeMs <= 0
- || !Number.isSafeInteger(maxFrameBytes) || maxFrameBytes < 1) {
- throw new Error("frame ring limits are invalid");
- }
- const frames = [];
- let totalBytes = 0;
-
- function evict(nowMs) {
- // Keep the predecessor that is still displayed at the retention boundary.
- while (frames.length && (totalBytes > maxBytes
- || (frames.length > 1 && frames[1].timestampMs <= nowMs - maxAgeMs))) {
- totalBytes -= frames.shift().data.byteLength;
- }
- }
-
- function append(value) {
- const data = bytes(value?.data);
- const timestampMs = finiteTime(value?.timestampMs, "frame timestamp");
- if (!Number.isSafeInteger(value?.width) || value.width < 1
- || !Number.isSafeInteger(value?.height) || value.height < 1) {
- throw new Error("capture frame dimensions are invalid");
- }
- if (data.byteLength === 0 || data.byteLength > maxFrameBytes) {
- throw new Error(`capture frame exceeds the ${maxFrameBytes}-byte limit`);
- }
- if (frames.length && timestampMs <= frames.at(-1).timestampMs) {
- throw new Error("capture frame timestamps must increase");
- }
- const frame = { timestampMs, width: value.width, height: value.height, data: data.slice() };
- frames.push(frame);
- totalBytes += frame.data.byteLength;
- evict(timestampMs);
- return { ...frame, data: frame.data.slice() };
- }
-
- function select(startMs, endMs, pinnedLimit = MAX_PINNED_FRAME_BYTES) {
- finiteTime(startMs, "pin start");
- finiteTime(endMs, "pin end");
- if (endMs <= startMs) throw new Error("capture pin interval is empty");
- const first = frames.findLastIndex(frame => frame.timestampMs <= startMs);
- if (first < 0) throw new Error("No retained video frame covers the start of this lookup.");
- const selected = frames.slice(first).filter(frame => frame.timestampMs < endMs);
- const size = selected.reduce((sum, frame) => sum + frame.data.byteLength, 0);
- if (size > pinnedLimit) throw new Error("The selected video exceeds the pinned-frame memory limit.");
- return selected.map((frame, index) => ({ ...frame,
- timestampMs: index === 0 ? startMs : frame.timestampMs, data: frame.data.slice() }));
- }
-
- return {
- append,
- select,
- clear() { frames.length = 0; totalBytes = 0; },
- oldestTimestamp: () => frames.length
- ? Math.max(frames[0].timestampMs, frames.at(-1).timestampMs - maxAgeMs) : null,
- newestTimestamp: () => frames.at(-1)?.timestampMs ?? null,
- size: () => ({ count: frames.length, bytes: totalBytes }),
- };
-}
-
-function sampleRange(block, startMs, endMs, length, rate) {
- return {
- first: Math.max(0, Math.round((block.startMs - startMs) * rate / 1000)),
- last: block.endMs >= endMs ? length
- : Math.min(length, Math.round((block.endMs - startMs) * rate / 1000)),
- };
-}
-
-export function createAudioRing({ maxAgeMs } = {}) {
- if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) throw new Error("audio ring age is invalid");
- const blocks = [];
-
- function append(value) {
- const startMs = finiteTime(value?.startMs, "audio timestamp");
- const sampleRate = Number(value?.sampleRate);
- if (!Number.isSafeInteger(sampleRate) || sampleRate < 8_000 || sampleRate > 192_000) {
- throw new Error("capture audio sample rate is invalid");
- }
- const samples = value?.samples instanceof Float32Array
- ? value.samples : new Float32Array(value?.samples ?? []);
- if (!samples.length) return null;
- const endMs = startMs + samples.length * 1000 / sampleRate;
- const block = { startMs, endMs, sampleRate, samples: samples.slice() };
- blocks.push(block);
- const cutoff = endMs - maxAgeMs;
- while (blocks.length && blocks[0].endMs < cutoff) blocks.shift();
- return { startMs, endMs, sampleRate, sampleCount: samples.length };
- }
-
- function covers(startMs, endMs, rate = CAPTURE_SAMPLE_RATE) {
- const length = Math.ceil((endMs - startMs) * rate / 1000);
- const ranges = blocks.map(block => sampleRange(block, startMs, endMs, length, rate))
- .filter(range => range.last > range.first).sort((a, b) => a.first - b.first);
- let coveredUntil = 0;
- for (const range of ranges) {
- if (range.first > coveredUntil) return false;
- coveredUntil = Math.max(coveredUntil, range.last);
- }
- return coveredUntil >= length;
- }
-
- function select(startMs, endMs, outputRate = CAPTURE_SAMPLE_RATE) {
- finiteTime(startMs, "audio pin start");
- finiteTime(endMs, "audio pin end");
- if (endMs <= startMs) throw new Error("capture audio interval is empty");
- const rate = Math.min(CAPTURE_SAMPLE_RATE, Math.trunc(outputRate));
- const length = Math.ceil((endMs - startMs) * rate / 1000);
- const output = new Float32Array(length);
- for (const block of blocks) {
- const { first, last } = sampleRange(block, startMs, endMs, length, rate);
- for (let index = first; index < last; index += 1) {
- const sourceTime = startMs + index * 1000 / rate;
- const sourceIndex = Math.min(block.samples.length - 1,
- Math.max(0, Math.floor((sourceTime - block.startMs) * block.sampleRate / 1000)));
- output[index] = block.samples[sourceIndex];
- }
- }
- return { samples: output, sampleRate: rate, partial: !covers(startMs, endMs, rate) };
- }
-
- return {
- append,
- covers,
- select,
- clear() { blocks.length = 0; },
- oldestTimestamp: () => blocks[0]?.startMs ?? null,
- newestTimestamp: () => blocks.at(-1)?.endMs ?? null,
- size: () => ({ blocks: blocks.length, samples: blocks.reduce((sum, block) => sum + block.samples.length, 0) }),
- };
-}
-
-function setAscii(view, offset, value) {
- for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.codePointAt(index));
-}
-
-export function encodeMonoWav(samples, sampleRate, maxBytes = MAX_WAV_BYTES) {
- if (!(samples instanceof Float32Array)) throw new Error("WAV input must be mono Float32 samples");
- if (!Number.isSafeInteger(sampleRate) || sampleRate < 8_000 || sampleRate > CAPTURE_SAMPLE_RATE) {
- throw new Error("WAV sample rate is invalid");
- }
- const byteLength = 44 + samples.length * 2;
- if (byteLength > maxBytes) throw new Error("Captured audio exceeds the 1 MiB WAV limit.");
- const output = new ArrayBuffer(byteLength);
- const view = new DataView(output);
- setAscii(view, 0, "RIFF");
- view.setUint32(4, byteLength - 8, true);
- setAscii(view, 8, "WAVE");
- setAscii(view, 12, "fmt ");
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, 1, true);
- view.setUint32(24, sampleRate, true);
- view.setUint32(28, sampleRate * 2, true);
- view.setUint16(32, 2, true);
- view.setUint16(34, 16, true);
- setAscii(view, 36, "data");
- view.setUint32(40, samples.length * 2, true);
- for (let index = 0; index < samples.length; index += 1) {
- const sample = Math.max(-1, Math.min(1, samples[index]));
- view.setInt16(44 + index * 2, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
- }
- return new Uint8Array(output);
-}
-
-export function createCapturePinStore({ now = Date.now, lifetimeMs = 2 * 60 * 1000 } = {}) {
- let pin = null;
- function releaseExpired() {
- if (pin && now() >= pin.expiresAt) pin = null;
- }
- return {
- create(value) {
- releaseExpired();
- if (pin) throw new Error("Another lookup already owns the capture pin.");
- const token = crypto.randomUUID();
- pin = { ...value, token, expiresAt: now() + lifetimeMs };
- return { ...pin };
- },
- get(token) {
- releaseExpired();
- return pin?.token === token ? pin : null;
- },
- release(token) {
- if (pin?.token !== token) return false;
- pin = null;
- return true;
- },
- clear() { pin = null; },
- active() { releaseExpired(); return pin !== null; },
- };
-}
diff --git a/vendor/hachidori/extension/capture-content.js b/vendor/hachidori/extension/capture-content.js
deleted file mode 100644
index af5909e2..00000000
--- a/vendor/hachidori/extension/capture-content.js
+++ /dev/null
@@ -1,721 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-(function () {
- "use strict";
-
- const CAPTURE_TARGET = "hachidori-capture";
- const CONTENT_TARGET = "hachidori-capture-content";
- const MAX_TEXT_LENGTH = 4096;
- const MAX_LINES = 1000;
- const TYPEWRITER_GAP_MS = 750;
- const TYPEWRITER_GROWTH_LIMIT = 12;
- const INLINE_DISPLAY_PATTERN = /^(?:inline|ruby|contents)/u;
- const OMIT_TEXT_SELECTOR = [
- "button", "input", "select", "textarea", "[contenteditable]",
- "rt", "rp", "script", "style", "noscript", "hachidori-host",
- ].join(",");
- const videoIds = new WeakMap();
- const trackIds = new WeakMap();
- const cueIds = new WeakMap();
- let nextVideoId = 0;
- let nextTrackId = 0;
- let nextCueId = 0;
- let nextRequestId = 0;
- let nextLineId = 0;
- let linked = false;
- let linkedDocumentId = null;
- let options = null;
- let documentEpoch = crypto.randomUUID();
- let selectedVideoCleanup = null;
- let trackedElement = null;
- let trackedEpoch = "";
- let trackedLines = [];
- let trackedObserver = null;
- let trackedVisibilityObserver = null;
- let trackedMountObserver = null;
- let trackedQueued = false;
- let pickerCleanup = null;
- let rootPin = null;
- let rootPinTail = Promise.resolve();
-
- const now = () => performance.timeOrigin + performance.now();
- const normalize = value => typeof value === "string"
- ? value.normalize("NFC").replace(/\s+/gu, " ").trim() : "";
-
- async function send(type, fields = {}) {
- const reply = await chrome.runtime.sendMessage({
- target: CAPTURE_TARGET,
- type,
- requestId: `capture-content-${++nextRequestId}`,
- ...fields,
- });
- if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
- return reply;
- }
-
- async function identify(captureSessionId) {
- return send("hd_capture_content_identify", { captureSessionId });
- }
-
- function report(message, fields = {}) {
- void send("hd_capture_page_status", { message, ...fields }).catch(() => {});
- }
-
- function videoId(video) {
- let id = videoIds.get(video);
- if (!id) {
- id = `video-${++nextVideoId}`;
- videoIds.set(video, id);
- }
- return id;
- }
-
- function videoTracks(video) {
- const tracks = [];
- try {
- for (const track of video.textTracks ?? []) {
- if (track.mode !== "disabled") tracks.push(track);
- }
- } catch {
- return [];
- }
- return tracks;
- }
-
- function activeTrackCues(track) {
- try {
- return [...(track.activeCues ?? [])];
- } catch {
- return [];
- }
- }
-
- function trackId(track) {
- let id = trackIds.get(track);
- if (!id) {
- id = `track-${++nextTrackId}`;
- trackIds.set(track, id);
- }
- return id;
- }
-
- function videos() {
- return [...document.querySelectorAll("video")].map((video, index) => ({
- id: videoId(video),
- label: String(video.getAttribute("aria-label") || video.title
- || `Video ${index + 1} (${video.videoWidth || "?"}×${video.videoHeight || "?"})`).slice(0, 200),
- trackCount: videoTracks(video).length,
- }));
- }
-
- function cueId(cue) {
- if (cue.id) return String(cue.id).slice(0, 160);
- let id = cueIds.get(cue);
- if (!id) {
- id = `cue-${++nextCueId}`;
- cueIds.set(cue, id);
- }
- return id;
- }
-
- function emitBegin(record) {
- void send("hd_capture_text_begin", { record }).catch(() => {});
- }
-
- function emitClose(identity, endMs = now()) {
- void send("hd_capture_text_close", { identity, endMs }).catch(() => {});
- }
-
- function attachVideo(video) {
- selectedVideoCleanup?.();
- selectedVideoCleanup = null;
- if (!video) return;
- const attachmentEpoch = crypto.randomUUID();
- let epochCounter = 0;
- let sourceEpoch = "";
- let interrupted = true;
- const active = new Map();
- const cleanups = [];
- const trackCleanups = new Map();
-
- function identity(id) {
- return { sourceKind: "cue", sourceEpoch, occurrenceId: id };
- }
-
- function closeAll(at = now()) {
- for (const id of active.keys()) emitClose(identity(id), at);
- active.clear();
- }
-
- function resetEpoch(at = now()) {
- closeAll(at);
- sourceEpoch = `video:${videoId(video)}:${attachmentEpoch}:${++epochCounter}`;
- }
-
- function sync(onsetKnown) {
- const at = now();
- if (video.paused || video.seeking || video.ended || !video.isConnected) {
- closeAll(at);
- return;
- }
- const next = new Map();
- for (const track of videoTracks(video)) {
- for (const cue of activeTrackCues(track)) {
- const text = String(cue.text ?? "");
- if (!normalize(text) || text.length > MAX_TEXT_LENGTH) continue;
- const id = `${trackId(track)}:${cueId(cue)}`;
- next.set(id, text);
- if (active.get(id) !== text) {
- emitBegin({
- sourceKind: "cue",
- sourceEpoch,
- occurrenceId: id,
- text,
- startMs: at,
- onsetKnown,
- });
- }
- }
- }
- for (const id of active.keys()) if (!next.has(id)) emitClose(identity(id), at);
- active.clear();
- for (const entry of next) active.set(...entry);
- }
-
- function listen(target, type, listener) {
- target.addEventListener(type, listener);
- cleanups.push(() => target.removeEventListener(type, listener));
- }
-
- function bindTracks() {
- const available = new Set(videoTracks(video));
- for (const [track, cleanup] of trackCleanups) {
- if (available.has(track)) continue;
- cleanup();
- trackCleanups.delete(track);
- }
- for (const track of available) {
- if (trackCleanups.has(track)) continue;
- const listener = () => sync(true);
- track.addEventListener("cuechange", listener);
- trackCleanups.set(track, () => track.removeEventListener("cuechange", listener));
- }
- }
-
- function interrupt() {
- closeAll(now());
- interrupted = true;
- }
-
- function resume() {
- if (video.paused || video.seeking || video.ended || !video.isConnected) return;
- if (interrupted) resetEpoch();
- interrupted = false;
- sync(false);
- }
-
- function resetAndSync() {
- resetEpoch();
- bindTracks();
- interrupted = video.paused || video.seeking || video.ended;
- if (!interrupted) sync(false);
- }
-
- resetEpoch();
- bindTracks();
- listen(video, "play", resume);
- listen(video, "pause", interrupt);
- listen(video, "seeking", interrupt);
- listen(video, "seeked", resetAndSync);
- listen(video, "ended", interrupt);
- listen(video, "emptied", () => { resetEpoch(); interrupted = true; });
- listen(video, "loadedmetadata", resetAndSync);
- if (video.textTracks?.addEventListener) {
- listen(video.textTracks, "addtrack", resetAndSync);
- listen(video.textTracks, "removetrack", resetAndSync);
- listen(video.textTracks, "change", resetAndSync);
- }
- resume();
- selectedVideoCleanup = () => {
- closeAll();
- for (const cleanup of trackCleanups.values()) cleanup();
- trackCleanups.clear();
- for (const cleanup of cleanups.splice(0).reverse()) cleanup();
- };
- }
-
- function renderedElement(element, requireLayout = false, style = null) {
- if (!element?.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true"
- || element.closest("hachidori-host")) return false;
- style ??= getComputedStyle(element);
- return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"
- && Number(style.opacity) !== 0 && (!requireLayout || element.getClientRects().length > 0);
- }
-
- function visible(element) {
- if (!renderedElement(element, true)) return false;
- for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
- if (!renderedElement(ancestor)) return false;
- }
- return true;
- }
-
- function validTrackedElement(element) {
- return element instanceof Element && !["HTML", "BODY"].includes(element.tagName)
- && !element.closest("input, textarea, select, button, [contenteditable], hachidori-host");
- }
-
- function domRangeFor(boundaries, previous) {
- if (previous?.startContainer === boundaries.startContainer && previous.startOffset === boundaries.startOffset
- && previous.endContainer === boundaries.endContainer && previous.endOffset === boundaries.endOffset) {
- return previous;
- }
- const range = document.createRange();
- range.setStart(boundaries.startContainer, boundaries.startOffset);
- range.setEnd(boundaries.endContainer, boundaries.endOffset);
- return range;
- }
-
- function extractLines(element) {
- if (!visible(element)) return [];
- const lines = [];
- let text = "";
- let range = null;
- const lineBreak = () => {
- const normalized = normalize(text);
- if (normalized && lines.length < MAX_LINES) {
- lines.push({ text: normalized.slice(0, MAX_TEXT_LENGTH),
- range: domRangeFor(range, trackedLines[lines.length]?.range) });
- }
- text = "";
- range = null;
- };
- function appendPart(node, value, offset) {
- range ??= { startContainer: node, startOffset: offset };
- range.endContainer = node;
- range.endOffset = offset + value.length;
- text += value;
- }
- function appendText(node) {
- const value = node.nodeValue || "";
- if (!/[\r\n]/u.test(value)) {
- appendPart(node, value, 0);
- return;
- }
- for (const part of value.matchAll(/[^\r\n]+|[\r\n]+/gu)) {
- if (/^[\r\n]/u.test(part[0])) {
- lineBreak();
- continue;
- }
- appendPart(node, part[0], part.index);
- }
- }
- function visit(node, root = false) {
- if (node.nodeType === Node.TEXT_NODE) {
- appendText(node);
- return;
- }
- if (!(node instanceof Element) || (!root && node.matches(OMIT_TEXT_SELECTOR))) return;
- const style = getComputedStyle(node);
- if (!renderedElement(node, false, style)) return;
- if (node.tagName === "BR") {
- lineBreak();
- return;
- }
- const block = !root && !INLINE_DISPLAY_PATTERN.test(style.display);
- if (block) lineBreak();
- for (const child of node.childNodes) visit(child);
- if (block) lineBreak();
- }
- visit(element, true);
- lineBreak();
- return lines;
- }
-
- function domIdentity(line) {
- return { sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id };
- }
-
- function closeTrackedLines(at = now()) {
- for (const line of trackedLines) emitClose(domIdentity(line), at);
- trackedLines = [];
- }
-
- function isTypewriterContinuation(previous, text, at) {
- if (!previous || !text || text === previous.text || !text.startsWith(previous.text)) return false;
- return Array.from(text.slice(previous.text.length)).length <= TYPEWRITER_GROWTH_LIMIT
- && at - previous.updatedMs <= TYPEWRITER_GAP_MS;
- }
-
- function lineIndexes(values) {
- const indexesByText = new Map();
- for (const [index, text] of values.entries()) {
- const indexes = indexesByText.get(text) ?? [];
- indexes.push(index);
- indexesByText.set(text, indexes);
- }
- return indexesByText;
- }
-
- function retainedLinesFor(values) {
- const previousByText = lineIndexes(trackedLines.map(line => line.text));
- const nextByText = lineIndexes(values);
- const retained = new Map();
- const usedPrevious = new Set();
- for (const [text, previousIndexes] of previousByText) {
- const nextIndexes = nextByText.get(text);
- if (previousIndexes.length !== 1 || nextIndexes?.length !== 1) continue;
- retained.set(nextIndexes[0], trackedLines[previousIndexes[0]]);
- usedPrevious.add(previousIndexes[0]);
- }
- return { previousByText, nextByText, retained, usedPrevious };
- }
-
- function refreshUnchangedLineRanges(extracted) {
- if (extracted.length !== trackedLines.length
- || !extracted.every((line, index) => line.text === trackedLines[index].text)) return false;
- for (const [index, line] of trackedLines.entries()) line.range = extracted[index].range;
- return true;
- }
-
- function reconcileTracked(initial = false) {
- trackedQueued = false;
- if (!trackedElement) return;
- if (!trackedElement.isConnected) {
- clearTrackedArea(false);
- report("The tracked text area was replaced. Select it again or use the next lookup to relearn it.",
- { tracked: false });
- return;
- }
- const at = now();
- const extracted = extractLines(trackedElement);
- if (refreshUnchangedLineRanges(extracted)) return;
- const values = extracted.map(line => line.text);
- const { previousByText, nextByText, retained, usedPrevious } = retainedLinesFor(values);
- const previousLastIndex = trackedLines.length - 1;
- const nextLastIndex = values.length - 1;
- const previousLast = trackedLines[previousLastIndex];
- const nextLast = values[nextLastIndex];
- let typewriter = null;
- if (isTypewriterContinuation(previousLast, nextLast, at)
- && previousByText.get(previousLast.text)?.length === 1
- && nextByText.get(nextLast)?.length === 1
- && !usedPrevious.has(previousLastIndex) && !retained.has(nextLastIndex)) {
- typewriter = { index: nextLastIndex, line: previousLast };
- usedPrevious.add(previousLastIndex);
- }
- for (let index = 0; index < trackedLines.length; index += 1) {
- if (!usedPrevious.has(index)) emitClose(domIdentity(trackedLines[index]), at);
- }
- const next = [];
- for (let index = 0; index < values.length; index += 1) {
- const text = values[index];
- const range = extracted[index].range;
- const previous = retained.get(index);
- if (previous) {
- next.push({ ...previous, range });
- continue;
- }
- if (typewriter?.index === index) {
- emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: typewriter.line.id,
- text, startMs: at, onsetKnown: !initial });
- next.push({ ...typewriter.line, text, range, updatedMs: at });
- continue;
- }
- const line = { id: `line-${++nextLineId}`, text, range, updatedMs: at };
- emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id,
- text, startMs: at, onsetKnown: !initial && !previousByText.has(text) });
- next.push(line);
- }
- trackedLines = next;
- }
-
- function queueTrackedReconcile() {
- if (trackedQueued) return;
- trackedQueued = true;
- queueMicrotask(() => reconcileTracked(false));
- }
-
- function clearTrackedArea(reportChange = true) {
- trackedObserver?.disconnect();
- trackedObserver = null;
- trackedVisibilityObserver?.disconnect();
- trackedVisibilityObserver = null;
- trackedMountObserver?.disconnect();
- trackedMountObserver = null;
- closeTrackedLines();
- trackedElement = null;
- trackedEpoch = "";
- if (reportChange) report("Tracked text area cleared.", { tracked: false });
- }
-
- function trackArea(element, manual = false) {
- if (!validTrackedElement(element)) throw new Error("Choose a bounded, non-editable text area.");
- clearTrackedArea(false);
- trackedElement = element;
- trackedEpoch = `dom:${documentEpoch}:${crypto.randomUUID()}`;
- reconcileTracked(true);
- trackedObserver = new MutationObserver(queueTrackedReconcile);
- trackedObserver.observe(element, {
- subtree: true,
- childList: true,
- characterData: true,
- attributes: true,
- attributeFilter: ["class", "style", "hidden", "aria-hidden"],
- });
- trackedVisibilityObserver = new MutationObserver(queueTrackedReconcile);
- for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
- trackedVisibilityObserver.observe(ancestor, {
- attributes: true,
- attributeFilter: ["class", "style", "hidden", "aria-hidden"],
- });
- }
- trackedMountObserver = new MutationObserver(queueTrackedReconcile);
- for (let node = element; node.parentElement; node = node.parentElement) {
- trackedMountObserver.observe(node.parentElement, { childList: true });
- }
- report(manual ? "Text area selected." : "Text area learned from the first lookup.", { tracked: true });
- }
-
- function learnArea(candidate) {
- if (trackedElement || !linked || !options?.mediaCapture.page.domText
- || !options.mediaCapture.page.autoLearnArea) return;
- let element = candidate?.anchor instanceof Element ? candidate.anchor : candidate?.anchor?.parentElement;
- if (!validTrackedElement(element)) return;
- let selected = element;
- for (let depth = 0; depth < 3; depth += 1) {
- const parent = selected.parentElement;
- if (!validTrackedElement(parent)) break;
- const textLength = (parent.textContent || "").length;
- if (textLength > MAX_TEXT_LENGTH || parent.querySelectorAll("*").length > 100) break;
- selected = parent;
- }
- try { trackArea(selected, false); } catch { /* Conservative auto-learning may decline the page. */ }
- }
-
- function occurrenceFor(candidate) {
- if (!trackedElement || !candidate?.anchor || !trackedElement.contains(candidate.anchor)) return null;
- // Keep the reader's DOM evidence local: a sentence or selection may occupy
- // only part of a timed paragraph, and identical lines need their own identity.
- const range = candidate.anchorRange ?? document.createRange();
- if (!candidate.anchorRange) range.selectNodeContents(candidate.anchor);
- const matches = trackedLines.filter(line => line.range?.comparePoint(range.startContainer, range.startOffset) === 0
- && line.range.comparePoint(range.endContainer, range.endOffset) === 0);
- return matches.length === 1 ? { occurrenceId: matches[0].id, occurrenceSourceKind: "dom" } : null;
- }
-
- function blockPickerInput(event) {
- event.preventDefault();
- event.stopImmediatePropagation();
- }
-
- function picker() {
- pickerCleanup?.();
- let candidate = null;
- const outline = document.createElement("div");
- outline.setAttribute("aria-hidden", "true");
- outline.style.cssText = [
- "all: initial !important", "position: fixed !important", "pointer-events: none !important",
- "z-index: 2147483647 !important", "border: 3px solid #36d399 !important",
- "background: rgba(54,211,153,.12) !important", "box-sizing: border-box !important",
- ].join(";");
- document.documentElement.append(outline);
-
- function paint(element) {
- candidate = validTrackedElement(element) ? element : null;
- if (!candidate) {
- outline.style.display = "none";
- return;
- }
- const rect = candidate.getBoundingClientRect();
- outline.style.display = "block";
- outline.style.left = `${rect.left}px`;
- outline.style.top = `${rect.top}px`;
- outline.style.width = `${rect.width}px`;
- outline.style.height = `${rect.height}px`;
- }
-
- function finish(element = null) {
- pickerCleanup?.();
- if (element) {
- try { trackArea(element, true); }
- catch (error) { report(error.message, { tracked: false }); }
- } else {
- report("Text area selection cancelled.", { tracked: Boolean(trackedElement) });
- }
- }
-
- const listeners = [
- ["pointermove", event => {
- blockPickerInput(event);
- paint(document.elementFromPoint(event.clientX, event.clientY));
- }, true],
- ["pointerdown", blockPickerInput, true],
- ["pointerup", blockPickerInput, true],
- ["mousedown", blockPickerInput, true],
- ["mouseup", blockPickerInput, true],
- ["auxclick", blockPickerInput, true],
- ["dblclick", blockPickerInput, true],
- ["contextmenu", blockPickerInput, true],
- ["click", event => { blockPickerInput(event); finish(candidate); }, true],
- ["keydown", event => {
- blockPickerInput(event);
- if (event.key === "Escape") finish();
- else if (event.key === "ArrowUp" && candidate?.parentElement) {
- paint(candidate.parentElement);
- }
- }, true],
- ["keypress", blockPickerInput, true],
- ["keyup", blockPickerInput, true],
- ];
- for (const [type, listener, capture] of listeners) window.addEventListener(type, listener, capture);
- pickerCleanup = () => {
- for (const [type, listener, capture] of listeners) window.removeEventListener(type, listener, capture);
- outline.remove();
- pickerCleanup = null;
- };
- report("Choose a bounded text area. Arrow Up selects its parent; Escape cancels.", { picking: true });
- }
-
- async function link(mediaCapture, captureSessionId) {
- const epoch = crypto.randomUUID();
- documentEpoch = epoch;
- linked = false;
- const identity = await identify(captureSessionId);
- if (documentEpoch !== epoch) throw new Error("The reading page link changed before its identity arrived.");
- if (!mediaCapture || typeof mediaCapture !== "object") {
- throw new Error("The capture service did not provide page timing settings.");
- }
- options = {
- mediaCapture: {
- ...mediaCapture,
- texthooker: { ...mediaCapture.texthooker },
- page: { ...mediaCapture.page },
- },
- };
- linked = true;
- linkedDocumentId = identity.documentId;
- const available = videos();
- if (options.mediaCapture.timingMode !== "recent"
- && available.length === 1 && available[0].trackCount > 0
- && options.mediaCapture.page.nativeCues) {
- attachVideo([...document.querySelectorAll("video")][0]);
- } else {
- attachVideo(null);
- }
- let message = "Reading page linked.";
- if (available.length > 1) message = "Choose which video supplies native subtitle cues.";
- else if (available.length === 0) message = "No native video cues found; page text and recent timing remain available.";
- return { videos: available, message };
- }
-
- function lookupSnapshot(candidate, lookupTimeMs = now()) {
- if (!linked || !options?.mediaCapture.enabled) return null;
- const pageTiming = options.mediaCapture.timingMode !== "recent";
- if (pageTiming) learnArea(candidate);
- const occurrence = pageTiming
- ? occurrenceFor(candidate) ?? { occurrenceId: "", occurrenceSourceKind: "" }
- : { occurrenceId: "", occurrenceSourceKind: "" };
- return {
- documentEpoch,
- lookup: {
- lookupText: String(candidate.sentence || candidate.query || "").slice(0, MAX_TEXT_LENGTH),
- lookupTimeMs,
- ...occurrence,
- },
- };
- }
-
- async function pinLookup(snapshot) {
- if (!snapshot || !linked || snapshot.documentEpoch !== documentEpoch) return null;
- try {
- return await send("hd_capture_pin", { lookup: snapshot.lookup });
- } catch {
- return null;
- }
- }
-
- async function releaseToken(pin) {
- if (pin?.token) {
- try { await send("hd_capture_release", { token: pin.token }); } catch { /* Expired pins need no cleanup. */ }
- }
- }
-
- function rootLookup(candidate) {
- const snapshot = lookupSnapshot(candidate);
- const operation = rootPinTail.then(async () => {
- const previous = rootPin;
- rootPin = null;
- await releaseToken(previous);
- const pin = await pinLookup(snapshot);
- rootPin = pin;
- return pin;
- });
- rootPinTail = operation.catch(() => {});
- return operation;
- }
-
- function release(pin) {
- const operation = rootPinTail.then(async () => {
- const owned = pin === undefined ? rootPin : pin;
- if (!owned?.token || rootPin?.token !== owned.token) return;
- rootPin = null;
- await releaseToken(owned);
- });
- rootPinTail = operation.catch(() => {});
- return operation;
- }
-
- async function unlink() {
- const epoch = crypto.randomUUID();
- documentEpoch = epoch;
- linked = false;
- await release();
- if (documentEpoch !== epoch) return { linked };
- pickerCleanup?.();
- selectedVideoCleanup?.();
- clearTrackedArea(false);
- linked = false;
- linkedDocumentId = null;
- options = null;
- return { linked: false };
- }
-
- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
- if (message?.target !== CONTENT_TARGET) return false;
- Promise.resolve().then(async () => {
- switch (message.type) {
- case "hd_capture_link": return link(message.mediaCapture, message.captureSessionId);
- case "hd_capture_recover": return { linked, documentId: linkedDocumentId };
- case "hd_capture_video_select": {
- if (options?.mediaCapture.timingMode === "recent"
- || !options?.mediaCapture.page.nativeCues) {
- throw new Error("Native cue timing is disabled for this capture session.");
- }
- const video = [...document.querySelectorAll("video")].find(item => videoId(item) === message.videoId);
- if (!video) throw new Error("That video is no longer available.");
- attachVideo(video);
- return { selected: message.videoId };
- }
- case "hd_capture_track_area":
- if (options?.mediaCapture.timingMode === "recent"
- || !options?.mediaCapture.page.domText) {
- throw new Error("Enable webpage timing and watched page text in Media capture settings first.");
- }
- picker();
- return { picking: true };
- case "hd_capture_clear_area":
- clearTrackedArea();
- return { tracked: false };
- case "hd_capture_unlink": return unlink();
- default: throw new Error("Unknown capture command.");
- }
- }).then(result => sendResponse(result), error => sendResponse({ error: error.message || String(error) }));
- return true;
- });
-
- window.addEventListener("pagehide", () => {
- void unlink();
- });
-
- globalThis.HDCapture = { rootLookup, release };
-}());
diff --git a/vendor/hachidori/extension/capture-encoder-client.js b/vendor/hachidori/extension/capture-encoder-client.js
deleted file mode 100644
index 15499483..00000000
--- a/vendor/hachidori/extension/capture-encoder-client.js
+++ /dev/null
@@ -1,63 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-export const CAPTURE_ENCODING_TIMEOUT_MS = 30_000;
-
-export function encodeCapturedAnimation(frames, options, {
- WorkerClass = globalThis.Worker,
- timeoutMs = CAPTURE_ENCODING_TIMEOUT_MS,
- onProgress = () => {},
- signal,
-} = {}) {
- if (typeof WorkerClass !== "function") return Promise.reject(new Error("Media encoder workers are unavailable."));
- const worker = new WorkerClass(new URL("./capture-encoder-worker.js", import.meta.url), {
- type: "module",
- name: "hachidori-capture-encoder",
- });
- const id = crypto.randomUUID();
- return new Promise((resolve, reject) => {
- let settled = false;
- let timer;
- const finish = (callback, value) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- signal?.removeEventListener("abort", onAbort);
- worker.terminate();
- callback(value);
- };
- const onAbort = () => finish(reject, new Error("Media encoding was cancelled."));
- timer = setTimeout(() => {
- finish(reject, new Error("Media encoding exceeded the 30-second deadline."));
- }, timeoutMs);
- if (signal?.aborted) {
- onAbort();
- return;
- }
- signal?.addEventListener("abort", onAbort, { once: true });
- worker.addEventListener("error", event => {
- finish(reject, event.error ?? new Error(event.message || "The media encoder worker stopped."));
- });
- worker.addEventListener("message", event => {
- const response = event.data;
- if (response?.id !== id) return;
- if (response.type === "progress") {
- onProgress(response.completed, response.total, response.heapBytes);
- return;
- }
- if (response.type !== "result") return;
- if (!response.ok) {
- finish(reject, new Error(response.error || "Media encoding failed."));
- return;
- }
- finish(resolve, new Uint8Array(response.data));
- });
- const transferable = [];
- const payloadFrames = frames.map(frame => {
- const data = frame.data instanceof Uint8Array ? frame.data.slice()
- : new Uint8Array(frame.data).slice();
- transferable.push(data.buffer);
- return { ...frame, data: data.buffer };
- });
- worker.postMessage({ type: "encode", id, frames: payloadFrames, ...options }, transferable);
- });
-}
diff --git a/vendor/hachidori/extension/capture-encoder-worker.js b/vendor/hachidori/extension/capture-encoder-worker.js
deleted file mode 100644
index 2e3914b3..00000000
--- a/vendor/hachidori/extension/capture-encoder-worker.js
+++ /dev/null
@@ -1,39 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import createAvifEncoderModule from "./vendor/avif-encoder.mjs";
-import { encodeJpegSequence } from "./avif-sequence.js";
-
-let modulePromise;
-
-function encoderModule() {
- modulePromise ??= createAvifEncoderModule({
- locateFile: name => new URL(`./vendor/${name}`, import.meta.url).href,
- });
- return modulePromise;
-}
-
-// A dedicated worker receives messages only from its owning Worker. Window
-// MessageEvent.origin checks do not form a security boundary in this context.
-self.addEventListener("message", async event => { // NOSONAR -- S2819 applies to Window messaging, not this worker.
- const request = event.data;
- if (request?.type !== "encode" || typeof request.id !== "string") return;
- try {
- const module = await encoderModule();
- const data = await encodeJpegSequence(module, request.frames, {
- endMs: request.endMs,
- quality: request.videoPreset === "compact" ? 50 : 55,
- speed: 8,
- onProgress(completed, total) {
- self.postMessage({ type: "progress", id: request.id, completed, total,
- heapBytes: module.HEAPU8.byteLength });
- },
- });
- // WebAssembly memory only grows; this includes any final muxing allocation.
- self.postMessage({ type: "progress", id: request.id,
- completed: request.frames.length, total: request.frames.length,
- heapBytes: module.HEAPU8.byteLength });
- self.postMessage({ type: "result", id: request.id, ok: true, data: data.buffer }, [data.buffer]);
- } catch (error) {
- self.postMessage({ type: "result", id: request.id, ok: false,
- error: error instanceof Error ? error.message : String(error) });
- }
-});
diff --git a/vendor/hachidori/extension/capture-frame-client.js b/vendor/hachidori/extension/capture-frame-client.js
deleted file mode 100644
index fdd05049..00000000
--- a/vendor/hachidori/extension/capture-frame-client.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-export function createCaptureFrameEncoder({ WorkerClass = globalThis.Worker,
- createBitmap = globalThis.createImageBitmap } = {}) {
- const worker = new WorkerClass(new URL("./capture-frame-worker.js", import.meta.url), {
- type: "module", name: "hachidori-capture-frames",
- });
- let pending = null;
- let closed = false;
- const close = (error = new Error("Frame capture stopped.")) => {
- closed = true;
- worker.terminate();
- pending?.reject(error);
- pending = null;
- };
- worker.addEventListener("error", event => {
- close(event.error ?? new Error(event.message || "The frame encoder stopped."));
- });
- worker.addEventListener("message", ({ data }) => {
- const request = pending;
- pending = null;
- if (!request) return;
- if (data.error) request.reject(new Error(data.error));
- else request.resolve(data.bytes ? new Uint8Array(data.bytes) : null);
- });
- return {
- async encode(source, dimensions) {
- if (closed) throw new Error("Frame capture stopped.");
- // Clone raw VideoFrames without copying pixels; the preview fallback
- // supplies an ImageBitmap. Only one frame is submitted at a time.
- const frame = typeof source.clone === "function" ? source.clone() : await createBitmap(source);
- if (closed || pending) {
- frame.close();
- throw new Error(closed ? "Frame capture stopped." : "A capture frame is already being encoded.");
- }
- return new Promise((resolve, reject) => {
- pending = { resolve, reject };
- try { worker.postMessage({ frame, ...dimensions }, [frame]); }
- catch (error) {
- pending = null;
- frame.close();
- reject(error);
- }
- });
- },
- close,
- };
-}
diff --git a/vendor/hachidori/extension/capture-frame-worker.js b/vendor/hachidori/extension/capture-frame-worker.js
deleted file mode 100644
index 64b40951..00000000
--- a/vendor/hachidori/extension/capture-frame-worker.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { MAX_FRAME_BYTES } from "./capture-buffer.js";
-
-let canvas = null;
-let context = null;
-
-async function encodeFrame({ frame, width, height }) {
- try {
- if (!canvas) {
- canvas = new OffscreenCanvas(width, height);
- context = canvas.getContext("2d", { alpha: false });
- if (!context) throw new Error("Could not create the capture frame canvas.");
- }
- // Keep the initial output size as the captured window changes shape.
- const sourceWidth = frame.displayWidth || frame.width;
- const sourceHeight = frame.displayHeight || frame.height;
- const scale = Math.min(1, canvas.width / sourceWidth, canvas.height / sourceHeight);
- const drawWidth = sourceWidth * scale;
- const drawHeight = sourceHeight * scale;
- context.fillStyle = "#000";
- context.fillRect(0, 0, canvas.width, canvas.height);
- context.drawImage(frame, (canvas.width - drawWidth) / 2,
- (canvas.height - drawHeight) / 2, drawWidth, drawHeight);
- } finally {
- frame.close();
- }
- // Chrome uses idle tasks for main-thread JPEG encoding, delaying hidden
- // capture documents by about a second. Worker encoding runs directly.
- let blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.72 });
- if (blob.size > MAX_FRAME_BYTES) {
- blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.5 });
- }
- return blob.size > MAX_FRAME_BYTES ? null : blob.arrayBuffer();
-}
-
-self.addEventListener("message", async ({ data }) => {
- try {
- const bytes = await encodeFrame(data);
- self.postMessage({ bytes }, bytes ? [bytes] : []);
- } catch (error) {
- self.postMessage({ error: error.message || String(error) });
- }
-});
diff --git a/vendor/hachidori/extension/capture-host.js b/vendor/hachidori/extension/capture-host.js
deleted file mode 100644
index 08ad4a6c..00000000
--- a/vendor/hachidori/extension/capture-host.js
+++ /dev/null
@@ -1,683 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { extensionApi as chrome } from "./browser-api.js";
-import { createCaptureSession } from "./capture-session.js";
-import { createCaptureFrameEncoder } from "./capture-frame-client.js";
-import { recordCapturedSpeech } from "./capture-speech.js";
-import { resolveSpeech } from "./speech.js";
-import {
- MAX_TEXTHOOKER_FRAME_LENGTH,
- MAX_TEXTHOOKER_TEXT_LENGTH,
- parseTexthookerMessage,
-} from "./texthooker-protocol.js";
-
-const CAPTURE_TARGET = "hachidori-capture";
-const preview = document.createElement("video");
-preview.muted = true;
-preview.playsInline = true;
-document.body.append(preview);
-let pageStatus = "";
-let pageVideos = [];
-const session = createCaptureSession();
-let config;
-let starting = false;
-let captureVersion = 0;
-let captureDocumentId = "";
-let stream = null;
-let frameTimer = null;
-let frameCallbackId = null;
-let frameBusy = false;
-let frameEncoder = null;
-let frameClockReady = Promise.resolve(null);
-let resolveFrameClock = null;
-let mediaClockOriginMs = null;
-let frameClockOriginMs = null;
-let videoReader = null;
-let processedVideoTrack = null;
-let audioReader = null;
-let audioTrack = null;
-let audioContext = null;
-let audioNode = null;
-let texthooker = null;
-let selectedTabId = null;
-let linkedDocumentId = "";
-let requestCounter = 0;
-
-const timestamp = () => performance.timeOrigin + performance.now();
-const describe = error => error instanceof Error ? error.message || String(error) : String(error);
-
-async function send(type, fields = {}) {
- const reply = await chrome.runtime.sendMessage({
- target: CAPTURE_TARGET,
- type,
- requestId: `capture-${++requestCounter}`,
- ...fields,
- });
- if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
- return reply;
-}
-
-async function register() {
- const reply = await send("hd_capture_register", { linkedPage: session.status().linkedPage });
- captureDocumentId = reply.documentId;
- configure(reply.mediaCapture);
-}
-
-function captureStatus() {
- return { ...session.status(), starting, config, pageStatus, videos: pageVideos };
-}
-
-function configure(next) {
- if (JSON.stringify(next) === JSON.stringify(config)) return;
- if (config) stopCapture("Capture settings changed. Start capture again to use them.");
- config = structuredClone(next);
- session.configure(config);
-}
-
-function captureDimensions(videoWidth, videoHeight) {
- const [maxWidth, maxHeight] = config.videoPreset === "compact" ? [480, 270] : [640, 360];
- const scale = Math.min(1, maxWidth / videoWidth, maxHeight / videoHeight);
- const width = Math.max(2, Math.floor(videoWidth * scale / 2) * 2);
- const height = Math.max(2, Math.floor(videoHeight * scale / 2) * 2);
- return { width, height };
-}
-
-function resetMediaClock() {
- mediaClockOriginMs = null;
- frameClockOriginMs = null;
- frameClockReady = new Promise(resolve => { resolveFrameClock = resolve; });
-}
-
-function establishMediaClock(metadata, now) {
- if (Number.isFinite(mediaClockOriginMs) || !Number.isFinite(metadata?.mediaTime)) return;
- let frameTime = now;
- if (Number.isFinite(metadata.presentationTime)) frameTime = metadata.presentationTime;
- if (Number.isFinite(metadata.captureTime)) frameTime = metadata.captureTime;
- mediaClockOriginMs = performance.timeOrigin + frameTime - metadata.mediaTime * 1000;
-}
-
-function establishTrackMediaClock(mediaTimeMs, observedAtMs = timestamp()) {
- if (Number.isFinite(frameClockOriginMs)) return;
- if (!Number.isFinite(mediaTimeMs)) throw new Error("The captured video did not provide media timestamps.");
- frameClockOriginMs = observedAtMs - mediaTimeMs;
- resolveFrameClock?.(frameClockOriginMs);
- resolveFrameClock = null;
-}
-
-function clearMediaClock() {
- resolveFrameClock?.(null);
- resolveFrameClock = null;
- mediaClockOriginMs = null;
- frameClockOriginMs = null;
-}
-
-async function captureFrame(dimensions, source, at, ownedStream) {
- if (!stream || stream !== ownedStream) return;
- const data = await frameEncoder.encode(source, dimensions);
- if (!data || stream !== ownedStream) return;
- session.addFrame({
- timestampMs: at,
- ...dimensions,
- data,
- });
-}
-
-function startTimestampedFrames(sharedStream, sourceTrack) {
- if (typeof MediaStreamTrackProcessor !== "function") return false;
- let ownedTrack;
- let reader;
- try {
- ownedTrack = sourceTrack.clone();
- reader = new MediaStreamTrackProcessor({ track: ownedTrack }).readable.getReader();
- } catch {
- ownedTrack?.stop();
- return false;
- }
- const ownedStream = sharedStream;
- let dimensions = null;
- let lastMediaTimestamp = -Infinity;
- videoReader = reader;
- processedVideoTrack = ownedTrack;
- void (async () => {
- while (stream === ownedStream && videoReader === reader) {
- const { done, value } = await reader.read();
- if (done || !value) return;
- try {
- const mediaTimeMs = value.timestamp / 1000;
- if (!Number.isFinite(mediaTimeMs) || mediaTimeMs < lastMediaTimestamp) {
- throw new Error("The captured video clock was interrupted. Start capture again.");
- }
- lastMediaTimestamp = mediaTimeMs;
- establishTrackMediaClock(mediaTimeMs);
- const frameTimestamp = frameClockOriginMs + mediaTimeMs;
- // The source track already has the preset's frame-rate ceiling. Its
- // irregular frame timestamps must survive intact (e.g. a 30 fps video
- // delivered at 8 fps alternates 100 ms and 133 ms frame spacings).
- if (!dimensions) {
- dimensions = captureDimensions(
- value.displayWidth || value.codedWidth,
- value.displayHeight || value.codedHeight,
- );
- }
- await captureFrame(dimensions, value, frameTimestamp, ownedStream);
- if (stream === ownedStream) session.videoDelivered(frameTimestamp);
- } finally {
- value.close();
- }
- }
- })().catch(error => {
- if (stream === ownedStream && videoReader === reader) {
- stopCapture(`Video capture stopped: ${describe(error)}`);
- }
- });
- return true;
-}
-
-function startFallbackFrames() {
- const video = preview;
- const dimensions = captureDimensions(video.videoWidth, video.videoHeight);
- const ownedStream = stream;
- const fps = config.videoPreset === "compact" ? 6 : 8;
- const intervalMs = 1000 / fps;
- let lastStartedAt = -Infinity;
- let lastTimestamp = -Infinity;
- const sample = (at, now) => {
- if (frameBusy || now - lastStartedAt < intervalMs * 0.9) return;
- lastStartedAt = now;
- const frameTimestamp = Math.max(at, lastTimestamp + 0.001);
- lastTimestamp = frameTimestamp;
- frameBusy = true;
- void captureFrame(dimensions, video, frameTimestamp, ownedStream)
- .then(() => { if (stream === ownedStream) session.videoDelivered(frameTimestamp); })
- .catch(error => {
- if (stream === ownedStream) stopCapture(`Video capture stopped: ${describe(error)}`);
- })
- .finally(() => { if (stream === ownedStream) frameBusy = false; });
- };
- if (typeof video.requestVideoFrameCallback === "function") {
- const onFrame = (now, metadata) => {
- frameCallbackId = video.requestVideoFrameCallback(onFrame);
- establishMediaClock(metadata, now);
- let at = performance.timeOrigin + now;
- if (Number.isFinite(metadata.captureTime)) at = performance.timeOrigin + metadata.captureTime;
- if (Number.isFinite(mediaClockOriginMs) && Number.isFinite(metadata.mediaTime)) {
- at = mediaClockOriginMs + metadata.mediaTime * 1000;
- }
- sample(at, now);
- };
- frameCallbackId = video.requestVideoFrameCallback(onFrame);
- }
- frameTimer = setInterval(() => {
- sample(timestamp(), performance.now());
- }, Math.round(1000 / fps));
-}
-
-function startFrames(sharedStream, sourceTrack) {
- frameEncoder = createCaptureFrameEncoder();
- if (!startTimestampedFrames(sharedStream, sourceTrack)) startFallbackFrames();
-}
-
-function audioTimestampOrigin(mediaTimeMs, videoOriginMs, observedAtMs = timestamp()) {
- // Chrome 150 exposes the same monotonic clock for both raw tracks. Chrome
- // 152 makes AudioData timestamps page-relative; video retains the raw clock.
- // Choose between those observed clock domains, never the preview's unrelated
- // playback mediaTime, and keep that origin for the entire audio stream.
- const pageOriginMs = performance.timeOrigin;
- return Math.abs(pageOriginMs + mediaTimeMs - observedAtMs)
- < Math.abs(videoOriginMs + mediaTimeMs - observedAtMs) ? pageOriginMs : videoOriginMs;
-}
-
-function createAudioSampleClock(originMs, observedNow = timestamp) {
- let offsetMs = 0;
- let nextMs = null;
- let sampleRate = null;
- let previousBlockMs = null;
- return value => {
- const rawMs = originMs + value.timestamp / 1000;
- const observedMs = rawMs + offsetMs;
- const blockMs = value.numberOfFrames * 1000 / value.sampleRate;
- if (nextMs === null) {
- sampleRate = value.sampleRate;
- previousBlockMs = blockMs;
- nextMs = observedMs + blockMs;
- return observedMs;
- }
- // AudioData timestamps are privacy-rounded (100 us on observed Chrome
- // 152). Count delivered samples instead of making holes at rounded block
- // boundaries. Forward jumps remain real gaps. A reset or sample-rate
- // change starts a new local epoch after a gap so later blocks can recover.
- let startMs = nextMs;
- if (value.sampleRate !== sampleRate || Math.abs(observedMs - nextMs) >= blockMs / 2) {
- if (value.sampleRate === sampleRate && observedMs > nextMs) {
- startMs = observedMs;
- } else {
- const minimum = nextMs + Math.max(previousBlockMs, blockMs);
- startMs = Math.max(minimum, observedNow() - blockMs);
- offsetMs = startMs - rawMs;
- }
- }
- sampleRate = value.sampleRate;
- previousBlockMs = blockMs;
- nextMs = startMs + blockMs;
- return startMs;
- };
-}
-
-function createMonoAudioMixer() {
- let mono = new Float32Array(0);
- let plane = new Float32Array(0);
- return value => {
- if (mono.length !== value.numberOfFrames) {
- mono = new Float32Array(value.numberOfFrames);
- plane = new Float32Array(value.numberOfFrames);
- } else {
- mono.fill(0);
- }
- for (let channel = 0; channel < value.numberOfChannels; channel += 1) {
- value.copyTo(plane, { planeIndex: channel, format: "f32-planar" });
- for (let frame = 0; frame < mono.length; frame += 1) mono[frame] += plane[frame];
- }
- if (value.numberOfChannels > 1) {
- for (let frame = 0; frame < mono.length; frame += 1) mono[frame] /= value.numberOfChannels;
- }
- return mono;
- };
-}
-
-function startTimestampedAudio(sharedStream, sourceTrack) {
- let ownedTrack;
- let reader;
- try {
- ownedTrack = sourceTrack.clone();
- reader = new MediaStreamTrackProcessor({ track: ownedTrack }).readable.getReader();
- } catch {
- ownedTrack?.stop();
- return false;
- }
- const ownedStream = sharedStream;
- audioReader = reader;
- audioTrack = ownedTrack;
- void (async () => {
- const videoOriginMs = await frameClockReady;
- if (!Number.isFinite(videoOriginMs) || stream !== ownedStream || audioReader !== reader) return;
- let sampleClock = null;
- const mix = createMonoAudioMixer();
- while (stream === ownedStream && audioReader === reader) {
- const { done, value } = await reader.read();
- if (done || !value) return;
- try {
- sampleClock ??= createAudioSampleClock(audioTimestampOrigin(value.timestamp / 1000, videoOriginMs));
- const startMs = sampleClock(value);
- session.addAudio({
- startMs,
- sampleRate: value.sampleRate,
- samples: mix(value),
- });
- } finally {
- value.close();
- }
- }
- })().catch(error => {
- if (stream === ownedStream && audioReader === reader) {
- stopCapture(`Audio capture stopped: ${describe(error)}`);
- }
- });
- return true;
-}
-
-async function startWorkletAudio(sharedStream, sourceTrack) {
- const ownedContext = new AudioContext({ sampleRate: 48_000, latencyHint: "interactive" });
- audioContext = ownedContext;
- await ownedContext.audioWorklet.addModule("capture-audio-worklet.js");
- if (stream !== sharedStream || audioContext !== ownedContext) return;
- const source = ownedContext.createMediaStreamSource(new MediaStream([sourceTrack]));
- const ownedNode = new AudioWorkletNode(ownedContext, "hachidori-capture-audio");
- audioNode = ownedNode;
- const silence = ownedContext.createGain();
- silence.gain.value = 0;
- let originMs = null;
- ownedNode.port.addEventListener("message", event => {
- if (stream !== sharedStream || audioContext !== ownedContext) return;
- if (event.data.discontinuity) return;
- if (!Number.isFinite(originMs)) return;
- const samples = new Float32Array(event.data.samples);
- session.addAudio({
- startMs: originMs + event.data.startFrame * 1000 / ownedContext.sampleRate,
- sampleRate: ownedContext.sampleRate,
- samples,
- });
- });
- ownedNode.port.start();
- source.connect(ownedNode).connect(silence).connect(ownedContext.destination);
- await ownedContext.resume();
- if (stream !== sharedStream || audioContext !== ownedContext) return;
- originMs = timestamp() - ownedContext.currentTime * 1000;
-}
-
-async function startAudio(sharedStream) {
- const sourceTrack = sharedStream.getAudioTracks()[0];
- if (!sourceTrack) return;
- if (!globalThis.__hachidoriForceAudioWorklet
- && config.includeAnimation && videoReader
- && typeof MediaStreamTrackProcessor === "function"
- && startTimestampedAudio(sharedStream, sourceTrack)) {
- return;
- }
- await startWorkletAudio(sharedStream, sourceTrack);
-}
-
-function stopTexthooker() {
- texthooker?.stop();
- texthooker = null;
-}
-
-function createTexthooker() {
- let socket = null;
- let retryTimer = null;
- let stopped = false;
- let attempt = 0;
- let sequence = 0;
- let connectionEpoch = "";
- let sourceEpoch = "";
- let currentSession = "";
- const open = new Map();
-
- function closeOpen(at = timestamp()) {
- for (const record of open.values()) session.textClose(record, at);
- open.clear();
- }
-
- function schedule() {
- if (stopped) return;
- session.setTexthooker("Disconnected", false);
- const delay = Math.min(10_000, 500 * 2 ** Math.min(attempt, 5));
- attempt += 1;
- retryTimer = setTimeout(connect, delay);
- }
-
- function connect() {
- if (stopped) return;
- connectionEpoch = crypto.randomUUID();
- sourceEpoch = connectionEpoch;
- currentSession = "";
- sequence = 0;
- session.setTexthooker("Connecting", false);
- try {
- socket = new WebSocket(config.texthooker.url);
- } catch {
- schedule();
- return;
- }
- const owned = socket;
- owned.addEventListener("open", () => {
- if (socket !== owned) return;
- attempt = 0;
- session.setTexthooker("Connected — waiting for live text", false);
- });
- owned.addEventListener("message", event => {
- if (socket !== owned) return;
- if (typeof event.data !== "string" || event.data.length > MAX_TEXTHOOKER_FRAME_LENGTH) return;
- const parsed = parseTexthookerMessage(config.texthooker.format, event.data);
- if (!parsed || (parsed.type === "line" && parsed.text.length > MAX_TEXTHOOKER_TEXT_LENGTH)) return;
- const at = timestamp();
- if (parsed.type === "reset") {
- closeOpen(at);
- sourceEpoch = `${connectionEpoch}:reset:${++sequence}`;
- session.setTexthooker("Connected — waiting for live text", false);
- return;
- }
- if (parsed.sessionId && parsed.sessionId !== currentSession) {
- closeOpen(at);
- currentSession = parsed.sessionId;
- sourceEpoch = `${connectionEpoch}:${currentSession}`;
- }
- const occurrenceId = parsed.id || `${connectionEpoch}:${++sequence}`;
- if (!open.has(occurrenceId)) closeOpen(at);
- const record = {
- sourceKind: "texthooker",
- sourceId: "loopback-websocket",
- sourceEpoch,
- occurrenceId,
- text: parsed.text,
- startMs: at,
- };
- session.textBegin(record);
- open.set(occurrenceId, record);
- session.setTexthooker("Active", true);
- });
- owned.addEventListener("close", () => {
- if (socket !== owned) return;
- socket = null;
- closeOpen();
- schedule();
- });
- owned.addEventListener("error", () => owned.close());
- }
-
- connect();
- return {
- stop() {
- stopped = true;
- clearTimeout(retryTimer);
- closeOpen();
- const active = socket;
- socket = null;
- active?.close(1000, "Capture stopped");
- session.setTexthooker("Disconnected", false);
- },
- };
-}
-
-function validateCaptureSource(requested) {
- const videoTrack = requested.getVideoTracks()[0];
- if (!videoTrack) {
- requested.getTracks().forEach(track => track.stop());
- throw new Error("The selected source did not provide video.");
- }
- if (!config.includeAnimation && config.includeCapturedAudio
- && requested.getAudioTracks().length === 0) {
- requested.getTracks().forEach(track => track.stop());
- throw new Error("The selected source did not provide audio for media capture.");
- }
- return videoTrack;
-}
-
-function watchCaptureSource(requested) {
- for (const track of requested.getTracks()) {
- track.addEventListener("ended", () => {
- if (stream === requested) stopCapture(`The shared ${track.kind} source ended.`);
- });
- track.addEventListener("mute", () => {
- if (stream === requested) {
- stopCapture(`The shared ${track.kind} source became unavailable. Start capture again when it is available.`);
- }
- });
- }
-}
-
-async function startCapture() {
- if (starting || stream) throw new Error("A capture source is already being selected or recorded.");
- if (!config?.enabled) throw new Error("Enable media capture in Settings first.");
- const version = ++captureVersion;
- const frameRate = config.videoPreset === "compact" ? 6 : 8;
- starting = true;
- try {
- const requested = await navigator.mediaDevices.getDisplayMedia({
- video: {
- frameRate: { ideal: frameRate, max: frameRate },
- },
- audio: config.includeCapturedAudio ? {
- echoCancellation: false,
- noiseSuppression: false,
- autoGainControl: false,
- } : false,
- monitorTypeSurfaces: "include",
- selfBrowserSurface: "exclude",
- surfaceSwitching: "exclude",
- });
- if (version !== captureVersion) {
- requested.getTracks().forEach(track => track.stop());
- return;
- }
- const videoTrack = validateCaptureSource(requested);
- const settings = videoTrack.getSettings();
- stream = requested;
- resetMediaClock();
- session.start({
- sourceName: videoTrack.label || "Shared media",
- displaySurface: settings.displaySurface || "browser",
- audioAvailable: requested.getAudioTracks().length > 0,
- });
- watchCaptureSource(requested);
- preview.srcObject = requested;
- preview.hidden = false;
- await preview.play();
- if (stream !== requested) return;
- if (config.includeAnimation) startFrames(requested, videoTrack);
- if (config.includeCapturedAudio) await startAudio(requested);
- if (stream !== requested) return;
- if (config.timingMode === "auto" && config.texthooker.enabled) texthooker = createTexthooker();
- } catch (error) {
- if (version === captureVersion) stopCapture(describe(error));
- } finally {
- if (version === captureVersion) starting = false;
- }
-}
-
-function stopCapture(error = "") {
- const retired = session.status();
- captureVersion += 1;
- starting = false;
- if (frameCallbackId !== null) {
- preview.cancelVideoFrameCallback(frameCallbackId);
- frameCallbackId = null;
- }
- clearInterval(frameTimer);
- frameTimer = null;
- frameBusy = false;
- frameEncoder?.close();
- frameEncoder = null;
- const frames = videoReader;
- videoReader = null;
- void frames?.cancel().catch(() => {});
- processedVideoTrack?.stop();
- processedVideoTrack = null;
- stopTexthooker();
- const reader = audioReader;
- audioReader = null;
- void reader?.cancel().catch(() => {});
- audioTrack?.stop();
- audioTrack = null;
- audioNode?.disconnect();
- audioNode = null;
- void audioContext?.close();
- audioContext = null;
- clearMediaClock();
- stream?.getTracks().forEach(track => track.stop());
- stream = null;
- preview.srcObject = null;
- preview.hidden = true;
- if (retired.linkedPage) {
- void send("hd_capture_host_stopped", { captureDocumentId,
- captureSessionId: retired.captureSessionId, linkedPage: retired.linkedPage }).catch(() => {});
- }
- selectedTabId = null;
- linkedDocumentId = "";
- pageVideos = [];
- pageStatus = "";
- session.stop(error);
-}
-
-function linked(message) {
- return selectedTabId === message.tabId && linkedDocumentId === message.documentId;
-}
-
-function captureJobOwner(message) {
- if (message.tabId === undefined && message.documentId === undefined) return null;
- return { tabId: message.tabId, documentId: message.documentId };
-}
-
-function beginCaptureExport(message) {
- if (!linked(message)) throw new Error("This export is not from the linked reading page.");
- return session.beginExport(message.token, message.requirements, captureJobOwner(message));
-}
-
-function linkCaptureReader(message) {
- const status = session.status();
- if (status.state !== "recording" || message.captureSessionId !== status.captureSessionId) {
- throw new Error("The capture session changed before the reading page was linked.");
- }
- selectedTabId = message.page.tabId;
- linkedDocumentId = message.page.documentId;
- session.setLinkedPage(message.page);
- pageStatus = message.page.message || "Reading page linked.";
- pageVideos = message.page.videos;
- return session.status();
-}
-
-// Kept inline (test/capture-routing.test.mjs evaluates this file's source in
-// a vm context without its imports). Uint8Array.prototype.toBase64 does the
-// megabyte in half a millisecond where the loop takes about forty.
-function bytesToBase64(data) {
- if (typeof data.toBase64 === "function") return data.toBase64();
- let binary = "";
- for (let offset = 0; offset < data.length; offset += 0x8000) {
- binary += String.fromCodePoint(...data.subarray(offset, offset + 0x8000));
- }
- return btoa(binary);
-}
-
-export async function recordSpeechAudio(source, term, signal, { record = true } = {}) {
- session.assertAudioCapture();
- if (!record) {
- await resolveSpeech(globalThis, source, term, signal);
- return { recordingRequired: true };
- }
- return recordCapturedSpeech(globalThis, session, source, term, signal, { now: timestamp });
-}
-
-export async function handleCaptureMessage(message) {
- if (message.captureDocumentId !== captureDocumentId) throw new Error("The capture host identity changed.");
- switch (message.type) {
- case "hd_capture_configure": configure(message.mediaCapture); return captureStatus();
- case "hd_capture_status": return captureStatus();
- case "hd_capture_start": await startCapture(); return captureStatus();
- case "hd_capture_stop": stopCapture(); return captureStatus();
- case "hd_capture_linked": return linkCaptureReader(message);
- case "hd_capture_unlinked":
- if (!linked(message)) return { ignored: true };
- selectedTabId = null;
- linkedDocumentId = "";
- session.setLinkedPage(null);
- pageStatus = message.reason || "The reading page navigated. Link it again.";
- pageVideos = [];
- return session.status();
- case "hd_capture_text_begin":
- if (!linked(message)) return { ignored: true };
- return session.textBegin(message.record);
- case "hd_capture_text_close":
- if (!linked(message)) return { ignored: true };
- return session.textClose(message.identity, message.endMs);
- case "hd_capture_text_source_close":
- if (!linked(message)) return { ignored: true };
- return session.closeTextSource(message.sourceKind, message.sourceId, message.sourceEpoch, message.endMs);
- case "hd_capture_page_status":
- if (!linked(message)) return { ignored: true };
- pageStatus = String(message.message || "").slice(0, 500);
- return { displayed: true };
- case "hd_capture_pin":
- if (!linked(message)) throw new Error("This lookup is not from the linked reading page.");
- return session.pinLookup(message.lookup);
- case "hd_capture_release": return { released: session.releasePin(message.token) };
- case "hd_capture_export": return beginCaptureExport(message);
- case "hd_capture_job_status": return session.jobStatus(message.jobId, captureJobOwner(message));
- case "hd_capture_asset": {
- const asset = session.jobAsset(message.jobId, message.kind);
- return { filename: asset.filename, data: bytesToBase64(asset.data) };
- }
- case "hd_capture_complete": return { completed: session.completeExport(message.jobId) };
- case "hd_capture_cancel": return { cancelled: session.cancelExport(message.jobId, captureJobOwner(message)) };
- default: throw new Error("Unknown capture page request.");
- }
-}
-
-await register();
diff --git a/vendor/hachidori/extension/capture-session.js b/vendor/hachidori/extension/capture-session.js
deleted file mode 100644
index 7c6de0e3..00000000
--- a/vendor/hachidori/extension/capture-session.js
+++ /dev/null
@@ -1,559 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import {
- createAudioRing,
- createCapturePinStore,
- createFrameRing,
- encodeMonoWav,
-} from "./capture-buffer.js";
-import { encodeCapturedAnimation } from "./capture-encoder-client.js";
-import { MAX_ANIMATED_AVIF_BYTES } from "./avif-sequence.js";
-import { createCaptureTimeline, resolveCaptureInterval } from "./capture-timeline.js";
-
-const JOB_LIFETIME_MS = 2 * 60 * 1000;
-export const MEDIA_DRAIN_MS = 250;
-
-function safeAssetId(value = crypto.randomUUID()) {
- const id = value.toLowerCase().replace(/[^a-z0-9]/gu, "");
- if (!id) throw new Error("Could not allocate a media asset identity.");
- return id;
-}
-
-function waitUntil(deadline, now, setTimer) {
- const remaining = Math.max(0, deadline - now());
- if (remaining === 0) return Promise.resolve();
- return new Promise(resolve => setTimer(resolve, remaining));
-}
-
-function assertJobOwner(job, owner) {
- if (owner && (owner.tabId !== job.owner?.tabId || owner.documentId !== job.owner?.documentId)) {
- throw new Error("This reading document does not own the media export job.");
- }
-}
-
-export function createCaptureSession({
- now = () => performance.timeOrigin + performance.now(),
- wallNow = Date.now,
- setTimer = setTimeout,
- clearTimer = clearTimeout,
- encodeAnimation = encodeCapturedAnimation,
- randomId = () => crypto.randomUUID(),
-} = {}) {
- let config = null;
- let captureSessionId = "";
- let state = "disabled";
- let statusError = "";
- let mediaSource = null;
- let capturedAudioAvailable = false;
- let linkedPage = null;
- let texthookerStatus = "Disabled";
- let texthookerActive = false;
- let texthookerSource = null;
- let videoDeliveredThroughMs = -Infinity;
- const timeline = createCaptureTimeline();
- let frameRing = null;
- let audioRing = null;
- let pins = createCapturePinStore({ now: wallNow });
- const jobs = new Map();
- let activeJobId = null;
-
- function status() {
- const frameSize = frameRing?.size() ?? { count: 0, bytes: 0 };
- const audioSize = audioRing?.size() ?? { blocks: 0, samples: 0 };
- return {
- state,
- error: statusError,
- captureSessionId,
- mediaSource,
- linkedPage,
- texthookerStatus,
- texthookerActive,
- history: {
- frameCount: frameSize.count,
- frameBytes: frameSize.bytes,
- audioBlocks: audioSize.blocks,
- audioSamples: audioSize.samples,
- frameOldestMs: frameRing?.oldestTimestamp() ?? null,
- frameNewestMs: frameRing?.newestTimestamp() ?? null,
- audioOldestMs: audioRing?.oldestTimestamp() ?? null,
- audioNewestMs: audioRing?.newestTimestamp() ?? null,
- oldestMs: oldestRequiredTimestamp(),
- newestMs: (() => {
- const newest = Math.max(frameRing?.newestTimestamp() ?? -Infinity, audioRing?.newestTimestamp() ?? -Infinity);
- return Number.isFinite(newest) ? newest : null;
- })(),
- },
- pinActive: pins.active() || activeJobId !== null,
- };
- }
-
- function oldestRequiredTimestamp() {
- if (!config) return null;
- const values = [];
- if (config.includeAnimation) values.push(frameRing?.oldestTimestamp());
- if (config.includeCapturedAudio && capturedAudioAvailable) values.push(audioRing?.oldestTimestamp());
- const available = values.filter(Number.isFinite);
- return available.length ? Math.max(...available) : null;
- }
-
- function configure(value) {
- cancelOwnedCapture("Capture settings changed.");
- config = structuredClone(value);
- frameRing = createFrameRing({ maxAgeMs: config.historySeconds * 1000 });
- audioRing = createAudioRing({ maxAgeMs: config.historySeconds * 1000 });
- pins = createCapturePinStore({ now: wallNow });
- timeline.reset();
- jobs.clear();
- activeJobId = null;
- texthookerActive = false;
- texthookerSource = null;
- videoDeliveredThroughMs = -Infinity;
- texthookerStatus = config.timingMode === "auto" && config.texthooker.enabled
- ? "Disconnected" : "Disabled";
- state = config.enabled ? "stopped" : "disabled";
- statusError = "";
- }
-
- function start({ sourceName = "Shared tab", displaySurface = "browser", audioAvailable = true } = {}) {
- if (!config?.enabled) throw new Error("Enable media capture in Settings first.");
- captureSessionId = randomId();
- capturedAudioAvailable = audioAvailable === true;
- mediaSource = {
- name: String(sourceName).slice(0, 200),
- displaySurface,
- audioAvailable: capturedAudioAvailable,
- };
- state = "recording";
- statusError = "";
- return status();
- }
-
- function stop(error = "") {
- cancelOwnedCapture(error || "Capture stopped.");
- state = config ? "stopped" : "disabled";
- statusError = error;
- captureSessionId = "";
- mediaSource = null;
- capturedAudioAvailable = false;
- linkedPage = null;
- texthookerActive = false;
- texthookerSource = null;
- videoDeliveredThroughMs = -Infinity;
- texthookerStatus = config?.timingMode === "auto" && config.texthooker.enabled
- ? "Disconnected" : "Disabled";
- frameRing?.clear();
- audioRing?.clear();
- timeline.reset();
- pins.clear();
- jobs.clear();
- activeJobId = null;
- }
-
- function requireRecording() {
- if (state !== "recording" || !captureSessionId) throw new Error("Start capture before looking up text.");
- }
-
- function addFrame(frame) {
- requireRecording();
- const added = frameRing.append(frame);
- videoDelivered(added.timestampMs);
- return added;
- }
-
- function videoDelivered(timestampMs) {
- requireRecording();
- videoDeliveredThroughMs = Math.max(videoDeliveredThroughMs, timestampMs);
- currentPin()?.checkDrain?.();
- }
-
- function addAudio(block) {
- requireRecording();
- const added = audioRing.append(block);
- currentPin()?.checkDrain?.();
- return added;
- }
-
- function assertAudioCapture() {
- if (state !== "recording" || !captureSessionId) {
- throw new Error("Start media capture with shared audio before attaching browser text-to-speech to Anki.");
- }
- if (!config.includeCapturedAudio || !capturedAudioAvailable) {
- throw new Error("The active media capture has no shared audio. Start capture again and enable audio in Chrome's share picker.");
- }
- }
-
- function selectAudio(startMs, endMs) {
- assertAudioCapture();
- const selected = audioRing.select(startMs, endMs);
- if (selected.partial) {
- throw new Error("The active media capture did not record all browser text-to-speech samples. Try adding the note again after shared audio resumes.");
- }
- return selected;
- }
-
- function setLinkedPage(page) {
- const previous = linkedPage;
- if (previous && (previous.tabId !== page?.tabId || previous.documentId !== page?.documentId)
- && activePin) releasePin(activePin.token);
- if (previous) {
- const sourceId = `tab:${previous.tabId}`;
- const endMs = now();
- for (const sourceKind of ["cue", "dom"]) {
- for (const record of timeline.closeSource(sourceKind, sourceId, undefined, endMs)) {
- adjustOpenPin(record);
- }
- }
- }
- linkedPage = page ? {
- tabId: page.tabId,
- documentId: page.documentId,
- title: String(page.title || "").slice(0, 200),
- url: String(page.url || "").slice(0, 2048),
- } : null;
- }
-
- function textBegin(record) {
- requireRecording();
- const begun = timeline.begin(record);
- if (record.sourceKind === "texthooker") {
- texthookerSource = { sourceId: record.sourceId, sourceEpoch: record.sourceEpoch };
- }
- return begun;
- }
-
- function textClose(identity, endMs = now()) {
- const closed = timeline.close(identity, endMs);
- if (closed) adjustOpenPin(closed);
- return closed;
- }
-
- function closeTextSource(sourceKind, sourceId, sourceEpoch, endMs = now()) {
- const closed = timeline.closeSource(sourceKind, sourceId, sourceEpoch, endMs);
- for (const record of closed) adjustOpenPin(record);
- return closed;
- }
-
- function setTexthooker(nextStatus, active = false) {
- texthookerStatus = nextStatus;
- texthookerActive = active;
- if (!active) texthookerSource = null;
- }
-
- function adjustOpenPin(closed) {
- const active = currentPin();
- if (!active || active.finalized || active.sourceKind !== closed.sourceKind
- || active.sourceId !== closed.sourceId || active.sourceEpoch !== closed.sourceEpoch
- || active.occurrenceId !== closed.occurrenceId) return;
- const offset = closed.sourceKind === "cue" ? 0 : config.estimatedOffsetMs;
- const closedEnd = closed.endMs + offset;
- if (closedEnd > active.startMs && closedEnd < active.endMs) {
- active.endMs = closedEnd;
- active.deadlineVersion += 1;
- active.finishDrain?.();
- void finalizeAtDeadline(active, active.deadlineVersion);
- }
- }
-
- function currentPin() {
- return activePin ?? (activeJobId ? jobs.get(activeJobId)?.pin ?? null : null);
- }
-
- let activePin = null;
-
- function cancelOwnedCapture(message) {
- const error = new Error(message);
- if (activePin && !activePin.finalized) {
- activePin.finishDrain?.();
- activePin.rejectReady(error);
- }
- activePin = null;
- for (const job of jobs.values()) {
- job.controller.abort();
- if (!job.pin.finalized) {
- job.pin.finishDrain?.();
- job.pin.rejectReady(error);
- }
- }
- }
-
- function mediaDelivered(pin) {
- return (!config.includeAnimation || videoDeliveredThroughMs >= pin.endMs)
- && (!config.includeCapturedAudio || !pin.audioAvailable
- || audioRing.covers(pin.startMs, pin.endMs));
- }
-
- function drainMedia(pin) {
- if (mediaDelivered(pin)) return Promise.resolve();
- return new Promise(resolve => {
- const timer = setTimer(finish, MEDIA_DRAIN_MS);
- function finish() {
- clearTimer(timer);
- pin.finishDrain = null;
- pin.checkDrain = null;
- resolve();
- }
- pin.finishDrain = finish;
- pin.checkDrain = () => { if (mediaDelivered(pin)) finish(); };
- });
- }
-
- async function finalizeAtDeadline(pin, version) {
- await waitUntil(pin.endMs, now, setTimer);
- if (currentPin() !== pin || pin.deadlineVersion !== version || pin.finalized) return;
- try {
- // Delivery can trail its timestamp (JPEG encoding and worklet batches).
- // Wait for that delivery without moving the lookup's frozen interval.
- await drainMedia(pin);
- if (currentPin() !== pin || pin.deadlineVersion !== version || pin.finalized) return;
- pin.frames = config.includeAnimation ? frameRing.select(pin.startMs, pin.endMs) : [];
- pin.audio = config.includeCapturedAudio && pin.audioAvailable
- ? audioRing.select(pin.startMs, pin.endMs) : null;
- pin.mediaErrors = {};
- // A display-capture track can emit only changed frames. Once the bounded
- // drain ends, its last pixels remain valid until source mute/loss stops us.
- if (pin.audio?.partial) {
- pin.mediaErrors.audio = "Captured audio is missing samples in this clip. Look up the text again.";
- }
- if (config.includeCapturedAudio && !pin.audioAvailable) pin.partial = true;
- pin.partial ||= pin.audio?.partial === true;
- pin.finalized = true;
- pin.resolveReady(pin);
- } catch (error) {
- pin.rejectReady(error);
- pins.release(pin.token);
- activePin = null;
- }
- }
-
- function pinLookup({ lookupText, occurrenceId = "", occurrenceSourceKind = "", lookupTimeMs = now() }) {
- requireRecording();
- pruneJobs();
- if (activeJobId !== null) throw new Error("Another captured clip is still exporting. Finish or cancel it first.");
- if (!config.includeAnimation && config.includeCapturedAudio && !capturedAudioAvailable) {
- throw new Error("The shared source did not provide audio for media capture.");
- }
- const availableStartMs = oldestRequiredTimestamp();
- if (!Number.isFinite(availableStartMs)) throw new Error("Capture history is still warming up.");
- const interval = resolveCaptureInterval({
- records: timeline.snapshot(),
- lookupText,
- occurrenceId,
- occurrenceSourceKind,
- lookupTimeMs,
- availableStartMs,
- timingMode: config.timingMode,
- clipSeconds: config.clipSeconds,
- estimatedOffsetMs: config.estimatedOffsetMs,
- texthookerActive,
- texthookerSource: texthookerActive ? texthookerSource : null,
- });
- if (!interval) throw new Error("No retained capture interval is available for this lookup.");
- const assetId = safeAssetId(randomId());
- let resolveReady, rejectReady;
- const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
- void ready.catch(() => {});
- const created = pins.create({
- ...interval,
- captureSessionId,
- assetId,
- animationFilename: `hachidori-${assetId}.avif`,
- audioFilename: `hachidori-${assetId}.wav`,
- audioAvailable: capturedAudioAvailable,
- deadlineVersion: 1,
- finalized: false,
- frames: null,
- audio: null,
- mediaErrors: {},
- ready,
- resolveReady,
- rejectReady,
- });
- activePin = pins.get(created.token);
- void finalizeAtDeadline(activePin, activePin.deadlineVersion);
- return {
- token: created.token,
- captureSessionId,
- sourceKind: interval.sourceKind,
- sourceLabel: interval.sourceLabel,
- partial: interval.partial === true,
- animationFilename: created.animationFilename,
- audioFilename: created.audioFilename,
- readyAtMs: interval.endMs,
- };
- }
-
- function releasePin(token) {
- let released = pins.release(token);
- // Status may have expired the token before the reader dismisses its clip.
- // The session still owns those media copies until this reference is retired.
- if (activePin !== null && activePin.token === token) {
- activePin.finishDrain?.();
- activePin.rejectReady(new Error("The capture pin was released."));
- activePin = null;
- released = true;
- }
- return released;
- }
-
- function pruneJobs() {
- const cutoff = wallNow() - JOB_LIFETIME_MS;
- for (const [id, job] of jobs) {
- if (job.updatedAt >= cutoff) continue;
- job.controller.abort();
- if (!job.pin.finalized) {
- job.pin.finishDrain?.();
- job.pin.rejectReady(new Error("The media export job expired."));
- }
- jobs.delete(id);
- if (activeJobId === id) activeJobId = null;
- }
- }
-
- function beginExport(token, requirements, owner = linkedPage) {
- pruneJobs();
- if (activeJobId !== null) {
- const existing = jobs.get(activeJobId);
- if (existing?.token === token) {
- assertJobOwner(existing, owner);
- return { jobId: existing.id, state: existing.state,
- sourceLabel: existing.sourceLabel, partial: existing.partial };
- }
- throw new Error("Another captured clip is still exporting.");
- }
- const pin = pins.get(token);
- if (pin?.captureSessionId !== captureSessionId) throw new Error("The capture pin expired. Look up the text again.");
- const includeAnimation = requirements?.includeAnimation === true && config.includeAnimation;
- const includeAudio = requirements?.includeAudio === true && config.includeCapturedAudio && pin.audioAvailable;
- if (!includeAnimation && requirements?.includeAudio === true
- && config.includeCapturedAudio && !pin.audioAvailable) {
- throw new Error("The shared source did not provide audio for the mapped captured-audio field.");
- }
- if (!includeAnimation && !includeAudio) throw new Error("The selected Anki fields do not reference captured media.");
- const id = randomId();
- const controller = new AbortController();
- const job = { id, token, state: "finishing", error: "", progress: 0, total: 0, encoderHeapBytes: 0,
- sourceLabel: pin.sourceLabel, partial: pin.partial === true, assets: {}, updatedAt: wallNow(),
- controller, pin, owner: owner ? { tabId: owner.tabId, documentId: owner.documentId } : null,
- warnings: requirements?.includeAudio === true && !pin.audioAvailable
- ? ["The shared source did not provide audio; this note will use animation only."] : [] };
- jobs.set(id, job);
- activeJobId = id;
- pins.release(token);
- if (activePin === pin) activePin = null;
- void (async () => {
- try {
- await pin.ready;
- if (controller.signal.aborted) throw new Error("Media encoding was cancelled.");
- if (includeAudio && pin.mediaErrors.audio) throw new Error(pin.mediaErrors.audio);
- job.state = "encoding";
- job.updatedAt = wallNow();
- if (includeAnimation) {
- job.assets.animation = {
- filename: pin.animationFilename,
- data: await encodeAnimation(pin.frames, { endMs: pin.endMs, videoPreset: config.videoPreset }, {
- signal: controller.signal,
- onProgress(completed, total, heapBytes) {
- job.progress = completed;
- job.total = total;
- if (Number.isSafeInteger(heapBytes) && heapBytes > job.encoderHeapBytes) {
- job.encoderHeapBytes = heapBytes;
- }
- job.updatedAt = wallNow();
- },
- }),
- };
- if (job.assets.animation.data.byteLength > MAX_ANIMATED_AVIF_BYTES) {
- throw new Error("Animated AVIF exceeds its 4 MiB output limit.");
- }
- }
- if (includeAudio) {
- job.assets.audio = {
- filename: pin.audioFilename,
- data: encodeMonoWav(pin.audio.samples, pin.audio.sampleRate),
- };
- }
- job.partial ||= pin.partial || pin.audio?.partial === true;
- job.state = "ready";
- } catch (error) {
- job.state = "error";
- job.error = error instanceof Error ? error.message : String(error);
- }
- job.updatedAt = wallNow();
- })();
- return { jobId: id, state: job.state, sourceLabel: job.sourceLabel, partial: job.partial };
- }
-
- function jobStatus(id, owner = null) {
- pruneJobs();
- const job = jobs.get(id);
- if (!job) throw new Error("The media export job expired.");
- assertJobOwner(job, owner);
- return {
- jobId: id,
- state: job.state,
- error: job.error,
- progress: job.progress,
- total: job.total,
- encoderHeapBytes: job.encoderHeapBytes,
- sourceLabel: job.sourceLabel,
- partial: job.partial,
- warnings: [...job.warnings],
- assets: Object.fromEntries(Object.entries(job.assets).map(([kind, asset]) =>
- [kind, { filename: asset.filename, byteLength: asset.data.byteLength }])),
- };
- }
-
- function jobAsset(id, kind) {
- const job = jobs.get(id);
- if (job?.state !== "ready" || !["animation", "audio"].includes(kind) || !job.assets[kind]) {
- throw new Error("The requested captured media asset is unavailable.");
- }
- const asset = job.assets[kind];
- return { filename: asset.filename, data: asset.data.slice() };
- }
-
- function completeExport(id) {
- const job = jobs.get(id);
- if (!job) return false;
- jobs.delete(id);
- if (activeJobId === id) activeJobId = null;
- return true;
- }
-
- function cancelExport(id, owner = null) {
- const job = jobs.get(id);
- if (!job) return false;
- assertJobOwner(job, owner);
- job.controller.abort();
- if (!job.pin.finalized) {
- job.pin.finishDrain?.();
- job.pin.rejectReady(new Error("The media export job was cancelled."));
- }
- jobs.delete(id);
- if (activeJobId === id) activeJobId = null;
- return true;
- }
-
- return {
- configure,
- start,
- stop,
- status,
- addFrame,
- videoDelivered,
- addAudio,
- assertAudioCapture,
- selectAudio,
- setLinkedPage,
- textBegin,
- textClose,
- closeTextSource,
- setTexthooker,
- pinLookup,
- releasePin,
- beginExport,
- jobStatus,
- jobAsset,
- completeExport,
- cancelExport,
- };
-}
diff --git a/vendor/hachidori/extension/capture-speech.js b/vendor/hachidori/extension/capture-speech.js
deleted file mode 100644
index 8bf9c7ed..00000000
--- a/vendor/hachidori/extension/capture-speech.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-import { encodeMonoWav } from "./capture-buffer.js";
-import { MEDIA_DRAIN_MS } from "./capture-session.js";
-import { resolveSpeech } from "./speech.js";
-
-const SPEECH_PREROLL_MS = 100;
-const SPEECH_TAIL_MS = 200;
-const MIN_AUDIBLE_PEAK = 1 / 4096;
-
-function waitUntil(deadline, signal, now, setTimer, clearTimer) {
- signal.throwIfAborted();
- const remaining = Math.max(0, deadline - now());
- if (remaining === 0) return Promise.resolve();
- return new Promise((resolve, reject) => {
- const timer = setTimer(done, remaining);
- function clean() { clearTimer(timer); signal.removeEventListener("abort", aborted); }
- function done() { clean(); resolve(); }
- function aborted() { clean(); reject(signal.reason); }
- signal.addEventListener("abort", aborted, { once: true });
- });
-}
-
-export async function recordCapturedSpeech(window, session, source, term, signal, {
- now = () => performance.timeOrigin + performance.now(),
- setTimer = setTimeout,
- clearTimer = clearTimeout,
- preRollMs = SPEECH_PREROLL_MS,
- tailMs = SPEECH_TAIL_MS,
- drainMs = MEDIA_DRAIN_MS,
-} = {}) {
- session.assertAudioCapture();
- const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal);
- signal.throwIfAborted();
- let startMs = null;
- let endMs = null;
- let abort;
- try {
- await new Promise((resolve, reject) => {
- utterance.onstart = () => { startMs ??= now() - preRollMs; };
- utterance.onend = () => {
- if (!Number.isFinite(startMs)) {
- reject(new Error("Browser text-to-speech ended before audio capture started."));
- return;
- }
- endMs = now() + tailMs;
- resolve();
- };
- utterance.onerror = event => {
- const detail = event.error ? ` (${event.error})` : "";
- reject(new Error(`Text-to-speech could not be recorded${detail}.`));
- };
- abort = () => {
- // Cancel synchronously before another operation can own the global
- // speech queue; the detached callbacks cannot affect newer speech.
- utterance.onend = utterance.onerror = utterance.onstart = null;
- speech.cancel();
- reject(signal.reason);
- };
- signal.addEventListener("abort", abort, { once: true });
- // A pronunciation already queued by the popup must not become part of
- // this note's recording.
- speech.cancel();
- speech.speak(utterance);
- });
- } finally {
- signal.removeEventListener("abort", abort);
- utterance.onend = utterance.onerror = utterance.onstart = null;
- }
- await waitUntil(endMs + drainMs, signal, now, setTimer, clearTimer);
- const selected = session.selectAudio(startMs, endMs);
- let peak = 0;
- for (const sample of selected.samples) peak = Math.max(peak, Math.abs(sample));
- if (peak < MIN_AUDIBLE_PEAK) {
- throw new Error("The active capture did not hear browser text-to-speech. Share system audio or choose a downloadable audio source.");
- }
- return {
- data: encodeMonoWav(selected.samples, selected.sampleRate),
- candidate,
- };
-}
diff --git a/vendor/hachidori/extension/capture-timeline.js b/vendor/hachidori/extension/capture-timeline.js
deleted file mode 100644
index 4721c8ad..00000000
--- a/vendor/hachidori/extension/capture-timeline.js
+++ /dev/null
@@ -1,192 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-export const CAPTURE_RECORD_LIMIT = 1000;
-export const CAPTURE_TEXT_LIMIT = 4096;
-const TIMED_SOURCES = new Set(["texthooker", "cue", "dom"]);
-
-export function normaliseCaptureText(value) {
- if (typeof value !== "string") return "";
- return value.normalize("NFC").replace(/\s+/gu, " ").trim();
-}
-
-function finiteTime(value, label) {
- if (!Number.isFinite(value)) throw new Error(`${label} must be a finite timeline value`);
- return value;
-}
-
-function recordKey(record) {
- return `${record.sourceKind}\0${record.sourceId}\0${record.sourceEpoch}\0${record.occurrenceId}`;
-}
-
-export function createCaptureTimeline({ limit = CAPTURE_RECORD_LIMIT, textLimit = CAPTURE_TEXT_LIMIT } = {}) {
- if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(textLimit) || textLimit < 1) {
- throw new Error("capture timeline limits must be positive integers");
- }
- const records = [];
- const current = new Map();
-
- function begin(value) {
- if (!TIMED_SOURCES.has(value?.sourceKind)
- || typeof value.sourceId !== "string" || !value.sourceId
- || typeof value.sourceEpoch !== "string" || !value.sourceEpoch
- || typeof value.occurrenceId !== "string" || !value.occurrenceId) {
- throw new Error("capture timeline record identity is incomplete");
- }
- if (typeof value.text !== "string" || value.text.length > textLimit) {
- throw new Error(`capture timeline text must be at most ${textLimit} characters`);
- }
- const text = normaliseCaptureText(value.text);
- if (!text) throw new Error("capture timeline text is empty");
- const startMs = finiteTime(value.startMs, "capture start");
- const candidate = {
- sourceKind: value.sourceKind,
- sourceId: value.sourceId,
- sourceEpoch: value.sourceEpoch,
- occurrenceId: value.occurrenceId,
- text: value.text,
- normalizedText: text,
- startMs,
- endMs: null,
- onsetKnown: value.onsetKnown !== false,
- };
- const key = recordKey(candidate);
- const existing = current.get(key);
- if (existing) {
- existing.text = candidate.text;
- existing.normalizedText = candidate.normalizedText;
- existing.onsetKnown = existing.onsetKnown && candidate.onsetKnown;
- return { ...existing };
- }
- records.push(candidate);
- current.set(key, candidate);
- while (records.length > limit) {
- const removed = records.shift();
- if (current.get(recordKey(removed)) === removed) current.delete(recordKey(removed));
- }
- return { ...candidate };
- }
-
- function close(identity, endMs) {
- const key = recordKey(identity);
- const record = current.get(key);
- if (!record) return null;
- const end = finiteTime(endMs, "capture end");
- if (end < record.startMs) throw new Error("capture end precedes its start");
- record.endMs = end;
- current.delete(key);
- return { ...record };
- }
-
- function closeSource(sourceKind, sourceId, sourceEpoch, endMs) {
- const closed = [];
- for (const record of current.values()) {
- if (record.sourceKind !== sourceKind || record.sourceId !== sourceId
- || (sourceEpoch !== undefined && record.sourceEpoch !== sourceEpoch)) continue;
- const result = close(record, endMs);
- if (result) closed.push(result);
- }
- return closed;
- }
-
- function reset() {
- records.length = 0;
- current.clear();
- }
-
- return {
- begin,
- close,
- closeSource,
- reset,
- snapshot: () => records.map(record => ({ ...record })),
- };
-}
-
-function temporalMatches(record, lookupTimeMs) {
- return record.startMs <= lookupTimeMs && (record.endMs == null || lookupTimeMs <= record.endMs);
-}
-
-function matchingRecord(records, sourceKind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
- texthookerSource) {
- const normalized = normaliseCaptureText(lookupText);
- const matches = records.filter(record => record.sourceKind === sourceKind
- && record.onsetKnown !== false
- && (sourceKind === "texthooker"
- ? record.sourceId === texthookerSource?.sourceId && record.sourceEpoch === texthookerSource?.sourceEpoch
- && record.startMs <= lookupTimeMs
- : temporalMatches(record, lookupTimeMs))
- && (occurrenceId && occurrenceSourceKind === sourceKind ? record.occurrenceId === occurrenceId
- : (record.normalizedText ?? normaliseCaptureText(record.text)) === normalized));
- return matches.length === 1 ? matches[0] : null;
-}
-
-function timedInterval(record, lookupTimeMs, clipMs, offsetMs) {
- const startMs = record.startMs + offsetMs;
- const naturalEnd = record.endMs == null ? startMs + clipMs : record.endMs + offsetMs;
- return {
- startMs,
- endMs: Math.min(naturalEnd, startMs + clipMs),
- pendingTail: record.endMs == null && lookupTimeMs < naturalEnd,
- };
-}
-
-/**
- * Resolve one lookup to exactly one source interval. `availableStartMs` is the
- * oldest retained recorder timestamp and may shorten only the recent fallback.
- */
-export function resolveCaptureInterval({
- records,
- lookupText,
- occurrenceId = "",
- occurrenceSourceKind = "",
- lookupTimeMs,
- availableStartMs,
- timingMode = "auto",
- clipSeconds = 10,
- estimatedOffsetMs = -500,
- texthookerActive = false,
- texthookerSource = null,
-}) {
- finiteTime(lookupTimeMs, "lookup time");
- finiteTime(availableStartMs, "available capture start");
- const clipMs = clipSeconds * 1000;
- if (![5000, 10000].includes(clipMs)) throw new Error("capture clip length is invalid");
- let priorities = [];
- if (timingMode !== "recent") {
- priorities = ["cue", "dom"];
- if (timingMode !== "page" && texthookerActive) priorities.unshift("texthooker");
- }
- const labels = {
- texthooker: "Texthooker estimate",
- cue: "Video cue",
- dom: "Page-text estimate",
- };
- for (const kind of priorities) {
- const record = matchingRecord(records, kind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
- texthookerSource);
- if (!record) continue;
- const interval = timedInterval(record, lookupTimeMs, clipMs, kind === "cue" ? 0 : estimatedOffsetMs);
- if (interval.startMs < availableStartMs || interval.endMs <= interval.startMs) continue;
- return {
- ...interval,
- sourceKind: kind,
- sourceLabel: labels[kind],
- sourceId: record.sourceId,
- sourceEpoch: record.sourceEpoch,
- occurrenceId: record.occurrenceId,
- };
- }
- const startMs = Math.max(availableStartMs, lookupTimeMs - clipMs);
- if (lookupTimeMs <= startMs) return null;
- return {
- sourceKind: "recent",
- sourceLabel: "Recent clip",
- sourceId: "",
- sourceEpoch: "",
- occurrenceId: "",
- startMs,
- endMs: lookupTimeMs,
- pendingTail: false,
- partial: startMs > lookupTimeMs - clipMs,
- };
-}
diff --git a/vendor/hachidori/extension/capture.css b/vendor/hachidori/extension/capture.css
deleted file mode 100644
index 2c057b64..00000000
--- a/vendor/hachidori/extension/capture.css
+++ /dev/null
@@ -1,51 +0,0 @@
-/* SPDX-License-Identifier: GPL-3.0-or-later */
-:root {
- color-scheme: light dark;
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
- background: #101316;
- color: #edf2f4;
-}
-
-* { box-sizing: border-box; }
-body { margin: 0; min-width: 520px; background: radial-gradient(circle at top, #26343a 0, #101316 50%); }
-main { width: min(820px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
-header, .card { border: 1px solid #ffffff1f; background: #171c20e8; box-shadow: 0 18px 50px #0006; }
-header { display: flex; align-items: start; justify-content: space-between; gap: 24px; padding: 28px; border-radius: 18px; }
-h1, h2, p { margin-top: 0; }
-h1 { margin-bottom: 8px; font-size: 2rem; }
-h2 { margin-bottom: 6px; font-size: 1.05rem; }
-header p, .card p, .privacy { color: #b8c4c9; }
-.eyebrow { margin-bottom: 4px; color: #7ee0bd; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
-.card { margin-top: 16px; padding: 22px; border-radius: 14px; }
-.capture-controls { display: grid; grid-template-columns: 1fr auto; gap: 14px 24px; }
-.button-row, .field-row { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; }
-.field-row { margin-top: 16px; }
-label { display: grid; flex: 1; gap: 6px; color: #b8c4c9; font-size: .82rem; }
-button, select {
- min-height: 38px;
- border: 1px solid #ffffff24;
- border-radius: 9px;
- background: #252d32;
- color: inherit;
- padding: 8px 12px;
- font: inherit;
-}
-button { cursor: pointer; font-weight: 700; }
-button:hover:not(:disabled) { border-color: #7ee0bd; }
-button:disabled { cursor: not-allowed; opacity: .45; }
-button.primary { border-color: #70d9b4; background: #18805e; }
-.status-badge { border-radius: 999px; background: #30383d; padding: 7px 12px; font-size: .82rem; font-weight: 800; }
-.status-badge.recording { background: #1d7257; }
-.error { grid-column: 1 / -1; color: #ffaaa4; }
-video { grid-column: 1 / -1; width: 100%; max-height: 360px; border-radius: 10px; background: #000; object-fit: contain; }
-.status-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
-.status-grid h2 { grid-column: 1 / -1; }
-.status-grid div { display: flex; justify-content: space-between; gap: 16px; border-radius: 9px; background: #ffffff09; padding: 12px; }
-.status-grid span { color: #b8c4c9; }
-.privacy { margin: 20px 8px 0; font-size: .82rem; line-height: 1.5; }
-@media (max-width: 620px) {
- .capture-controls, .status-grid { grid-template-columns: 1fr; }
- .status-grid h2, .error, video { grid-column: 1; }
-}
-/* Author display rules must not reveal controls marked unavailable. */
-[hidden] { display: none !important; }
diff --git a/vendor/hachidori/extension/capture.html b/vendor/hachidori/extension/capture.html
deleted file mode 100644
index d1618d22..00000000
--- a/vendor/hachidori/extension/capture.html
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-
- Hachidori — Anki context capture
-
-
-
-
-
-
-
Hachidori
-
Anki context capture
-
Capture context from your study material to attach to Japanese vocabulary cards. Capture keeps running when you close this page — reopen it to stop or change the linked reading page.
-
- Stopped
-
-
-
-
-
Shared media
-
No tab, window, or screen is being captured.
-
-
-
-
-
-
-
-
-
-
-
Reading page
-
No reading page is linked.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Timing sources
-
TexthookerDisabled
-
Video history0 frames
-
Audio history0 samples
-
Lookup pinNone
-
-
-
- Choose a tab, window, or entire screen. Screen sharing includes everything visible on that screen.
- Audio availability and scope depend on Chrome's selected share. Hachidori does not request microphone access,
- persist capture history, or send captured media anywhere except your configured AnkiConnect server.
-
-
-
-
-
diff --git a/vendor/hachidori/extension/capture.js b/vendor/hachidori/extension/capture.js
deleted file mode 100644
index 35d2f912..00000000
--- a/vendor/hachidori/extension/capture.js
+++ /dev/null
@@ -1,145 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-// This page controls the offscreen recorder; closing it leaves capture running.
-import { extensionApi as chrome } from "./browser-api.js";
-
-const CAPTURE_TARGET = "hachidori-capture";
-const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map(node => [node.id, node]));
-let config;
-let selectedTabId = null;
-let requestCounter = 0;
-let pendingControl = false;
-let lastVideos = "";
-let lastPageStatus = "";
-const describe = error => error instanceof Error ? error.message || String(error) : String(error);
-async function send(type, fields = {}) {
- const reply = await chrome.runtime.sendMessage({
- target: CAPTURE_TARGET,
- type,
- requestId: `capture-${++requestCounter}`,
- ...fields,
- });
- if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
- return reply;
-}
-
-function render(status) {
- config = status.config;
- selectedTabId = status.linkedPage?.tabId ?? null;
- const recording = status.state === "recording";
- const linkedPage = Boolean(status.linkedPage);
- const videos = status.videos ?? [];
- const serializedVideos = JSON.stringify(videos);
- if (serializedVideos !== lastVideos) {
- elements["reading-video"].replaceChildren(...videos.map(video => new Option(video.label, video.id)));
- elements["video-row"].hidden = videos.length < 2;
- lastVideos = serializedVideos;
- }
- if (lastPageStatus !== status.pageStatus) {
- elements["page-status"].textContent = status.pageStatus || "";
- lastPageStatus = status.pageStatus;
- }
- const duration = (oldest, newest) => Number.isFinite(oldest) && Number.isFinite(newest)
- ? `${Math.max(0, (newest - oldest) / 1000).toFixed(1)} s` : "0.0 s";
- const stateLabels = { recording: "Recording", disabled: "Disabled", stopped: "Stopped" };
- elements["capture-state"].textContent = stateLabels[status.state];
- elements["capture-state"].classList.toggle("recording", recording);
- elements["capture-start"].disabled = pendingControl || status.starting || recording || !config?.enabled;
- elements["capture-stop"].disabled = !recording && !status.starting;
- elements["link-page"].disabled = !recording;
- elements["select-video"].disabled = !recording || !linkedPage
- || config?.timingMode === "recent" || !config?.page.nativeCues;
- elements["track-area"].disabled = !recording || !linkedPage
- || config?.timingMode === "recent" || !config?.page.domText;
- elements["clear-area"].disabled = !recording || !linkedPage;
- elements["media-source"].textContent = captureSourceLabel(status.mediaSource);
- elements["capture-error"].textContent = status.error || "";
- elements["texthooker-status"].textContent = status.texthookerStatus;
- elements["video-history"].textContent = config?.includeAnimation
- ? `${duration(status.history.frameOldestMs, status.history.frameNewestMs)} · ${status.history.frameCount} frames · ${Math.round(status.history.frameBytes / 1024)} KiB`
- : "Disabled";
- let audioHistory = "Disabled";
- if (status.mediaSource && !status.mediaSource.audioAvailable) audioHistory = "Source audio unavailable";
- else if (config?.includeCapturedAudio) {
- audioHistory = `${duration(status.history.audioOldestMs, status.history.audioNewestMs)} · ${status.history.audioSamples.toLocaleString()} samples`;
- }
- elements["audio-history"].textContent = audioHistory;
- elements["pin-status"].textContent = status.pinActive ? "Pinned" : "None";
- elements["linked-page"].textContent = status.linkedPage?.title || "No reading page is linked.";
-}
-
-function captureSourceLabel(source) {
- if (!source) return "No tab, window, or screen is being captured.";
- const kind = { browser: "Browser tab", window: "Application window", monitor: "Entire screen" }[source.displaySurface]
- || "Shared media";
- return !source.name || /^(?:web-contents-media-stream|screen|window):/u.test(source.name)
- ? kind : `${source.name} · ${kind}`;
-}
-
-async function refreshTabs() {
- try {
- const reply = await send("hd_capture_tabs");
- elements["reading-tab"].replaceChildren(...reply.tabs.map(tab => new Option(tab.title || tab.url, String(tab.id))));
- if (reply.tabs.some(tab => tab.id === selectedTabId)) elements["reading-tab"].value = String(selectedTabId);
- } catch (error) {
- elements["page-status"].textContent = describe(error);
- }
-}
-
-async function control(type) {
- pendingControl = true;
- elements["capture-start"].disabled = true;
- elements["capture-stop"].disabled = false;
- try { render(await send(type)); }
- catch (error) { elements["capture-error"].textContent = describe(error); }
- finally { pendingControl = false; await refreshStatus(); }
-}
-
-elements["capture-start"].addEventListener("click", () => { void control("hd_capture_start"); });
-elements["capture-stop"].addEventListener("click", () => { void control("hd_capture_stop"); });
-elements["reading-tab"].addEventListener("focus", () => { void refreshTabs(); });
-elements["link-page"].addEventListener("click", async () => {
- try {
- const tabId = Number(elements["reading-tab"].value);
- const reply = await send("hd_capture_link", { tabId });
- selectedTabId = reply.page.tabId;
- await refreshStatus();
- } catch (error) {
- elements["page-status"].textContent = describe(error);
- }
-});
-elements["select-video"].addEventListener("click", async () => {
- try {
- await send("hd_capture_video_select", {
- tabId: selectedTabId,
- videoId: elements["reading-video"].value,
- });
- elements["page-status"].textContent = "Video source selected.";
- } catch (error) {
- elements["page-status"].textContent = describe(error);
- }
-});
-elements["track-area"].addEventListener("click", async () => {
- try {
- await send("hd_capture_track_area", { tabId: selectedTabId });
- elements["page-status"].textContent = "Choose an area on the linked page; press Escape to cancel.";
- } catch (error) {
- elements["page-status"].textContent = describe(error);
- }
-});
-elements["clear-area"].addEventListener("click", async () => {
- try {
- await send("hd_capture_clear_area", { tabId: selectedTabId });
- elements["page-status"].textContent = "Tracked text area cleared.";
- } catch (error) {
- elements["page-status"].textContent = describe(error);
- }
-});
-
-async function refreshStatus() {
- try { render(await send("hd_capture_status")); }
- catch (error) { elements["capture-error"].textContent = describe(error); }
-}
-
-await refreshStatus();
-await refreshTabs();
-setInterval(() => { void refreshStatus(); }, 1000);
diff --git a/vendor/hachidori/extension/chrome-offscreen.js b/vendor/hachidori/extension/chrome-offscreen.js
deleted file mode 100644
index 315c98c3..00000000
--- a/vendor/hachidori/extension/chrome-offscreen.js
+++ /dev/null
@@ -1,44 +0,0 @@
-// Chrome MV3 lifecycle for the shared offscreen engine document.
-// Firefox imports this shared module but never calls its guarded Chrome path.
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-import { extensionApi as chrome } from "./browser-api.js";
-
-let creating = null;
-
-export function chromeOffscreenSupported() {
- return typeof chrome.runtime.getContexts === "function"
- && typeof chrome.offscreen?.createDocument === "function";
-}
-
-async function offscreenExists(url) {
- const contexts = await chrome.runtime.getContexts({
- contextTypes: ["OFFSCREEN_DOCUMENT"],
- documentUrls: [chrome.runtime.getURL(url)],
- });
- return contexts.length > 0;
-}
-
-async function createOffscreen(url) {
- try {
- await chrome.offscreen.createDocument({
- url,
- reasons: ["DOM_SCRAPING", "AUDIO_PLAYBACK", "DISPLAY_MEDIA"],
- justification:
- "Runs the dictionary engine and pronunciation audio, and owns explicitly started local display capture across control-page closure.",
- });
- } catch (error) {
- // Another extension context may have won the race; only a genuine absence
- // is a failure.
- if (!(await offscreenExists(url))) throw error;
- } finally {
- creating = null;
- }
-}
-
-export async function ensureChromeOffscreen(url) {
- if (!chromeOffscreenSupported()) return;
- if (await offscreenExists(url)) return;
- if (creating === null) creating = createOffscreen(url);
- await creating;
-}
diff --git a/vendor/hachidori/extension/content.css b/vendor/hachidori/extension/content.css
deleted file mode 100644
index 770793e6..00000000
--- a/vendor/hachidori/extension/content.css
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Page-document styles for Hachidori's hover highlight.
- *
- * Everything else the extension draws lives in the popup's shadow root and is
- * styled by render/reader.css. This rule cannot: `::highlight()` resolves
- * only against the ranges' own tree, and the ranges CSS.highlights receives
- * point at the page's text nodes -- a `::highlight()` rule inside the shadow
- * root would never match them. Fallback paint is extension-owned, inside the
- * popup host's shadow root; it never adds classes to page elements.
- *
- * Colours here are the initial default. The shared popup appearance helper
- * updates an owned CSSOM sheet from the shadow host's selected palette without
- * modifying the page's html element or inheriting its theme policy.
- *
- * Copyright (C) 2026 Manhhao
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-::highlight(gsm-hoshidicts-match) {
- color: inherit;
- background-color: color-mix(
- in srgb,
- var(--hoshidicts-palette-primary, #7aa2ff) 34%,
- transparent
- );
-}
diff --git a/vendor/hachidori/extension/content.js b/vendor/hachidori/extension/content.js
deleted file mode 100644
index a1b1118d..00000000
--- a/vendor/hachidori/extension/content.js
+++ /dev/null
@@ -1,4063 +0,0 @@
-/*
- * Hover scanning, popup hosting, and offscreen-engine messaging for
- * Hachidori.
- *
- * Rendering lives in render/popup.js and render/glossary.js (ported from
- * GameSentenceMiner PR #549); this file only produces the
- * {sentence, matchOffset, sourceElements} candidates those modules consume and
- * drives the request/reply state machine. Like Yomitan's default layout-unaware
- * scan, page text is read in DOM order regardless of how it is boxed, and a
- * pointer candidate's sources are the text nodes around the hovered glyph.
- *
- * Copyright (C) 2026 Manhhao
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-(function () {
- "use strict";
-
- const TARGET = "hoshidicts-offscreen";
- const PAGE_ZOOM_TARGET = "hachidori-page-zoom";
- const WORKER_TARGET = "hoshidicts-worker";
- const READER_TARGET = "hachidori-reader";
- const HIGHLIGHT_NAME = "gsm-hoshidicts-match";
- const READER_STYLESHEET = "render/reader.css";
- const HOST_TAG = "hachidori-host";
- const POPUP_SHOWN_EVENT = "hachidori-popup-shown";
- const POPUP_HIDDEN_EVENT = "hachidori-popup-hidden";
- const EXTENSION_PROTOCOL = (() => {
- try {
- return new URL(chrome.runtime.getURL("")).protocol;
- } catch {
- return "";
- }
- })();
-
- const {
- DEFAULT_OPTIONS,
- KEYBIND_MODIFIERS,
- KEYBIND_MODIFIER_CODES,
- clampOption,
- definitionBlurFrequencyEvidence,
- definitionBlurQualifies,
- normaliseActivationKey,
- projectContentOptions,
- } = globalThis.HDReaderOptions;
- const { normaliseDictionaryGroups } = globalThis.HDDictionaryGroups;
- const { normaliseLookupTerm, lookupStatsKey } = globalThis.HDLookupStats;
- const { normaliseDictionaryTab: normalizedDictionaryTab } = globalThis.HDPopup;
- const MODIFIER_PROPERTIES = new Map([
- ["Shift", "shiftKey"],
- ["Control", "ctrlKey"],
- ["Alt", "altKey"],
- ["Meta", "metaKey"],
- ]);
-
- const POPUP_GAP_PX = 4;
- const POPUP_PADDING_PX = 6;
- const MAX_MEDIA_CACHE_BYTES = 16 * 1024 * 1024;
- const MAX_MEDIA_CACHE_ENTRIES = 64;
- const MAX_MEDIA_CONCURRENT_REQUESTS = 4;
- const MAX_MEDIA_PENDING_REQUESTS = 128;
- const MEDIA_REQUEST_TIMEOUT_MS = 4000;
- // Yomitan's sentence scan extent: how far the sentence reaches to either
- // side of the hovered glyph before a newline cuts it.
- const SENTENCE_SCAN_EXTENT = 200;
-
- // Same character set PR #549 gates lookups on: kana, halfwidth katakana, CJK
- // ideographs (including ext-A and ext-B), and the iteration/repeat marks.
- const JAPANESE_CHARACTER_PATTERN =
- /[々-〇〻-ヿㇰ-ㇿ㐀-䶿一-鿿豈-ヲ-゚\u{20000}-\u{2fa1f}]/u;
- const TOKEN_BOUNDARY_PATTERN = /[\p{White_Space}\p{Punctuation}\p{Symbol}]/u;
- const COLLAPSIBLE_WHITESPACE_PATTERN = /[\t\n\r\f ]/u;
- const SEGMENT_BREAK_PATTERN = /[\n\r]/u;
- // Deliberately narrow: "receiving end does not exist" also fires while the
- // service worker is still waking up, and tearing down on that would kill the
- // content script over a transient race.
- const INVALIDATED_MESSAGE_PATTERN = /context invalidated/iu;
-
- // Text in these never belongs to the running prose: script and style hold
- // source, rt/rp hold reading annotations that must not splice into the
- // scanned string, and form controls hold values rather than page text.
- const OPAQUE_TAGS = new Set([
- "audio",
- "canvas",
- "embed",
- "head",
- "iframe",
- "math",
- "noscript",
- "object",
- "option",
- "optgroup",
- "rp",
- "rt",
- "script",
- "select",
- "style",
- "svg",
- "template",
- "textarea",
- "title",
- "video",
- ]);
- const EDITING_TAGS = new Set(["button", "input", "select", "textarea"]);
- const EDITING_SELECTOR = [...EDITING_TAGS, "[contenteditable]"].join(",");
- // A whitespace-only text node with a line break separates blocks in the
- // source ("
\n
", an overlay's block separator) and ends the sentence.
- const BLOCK_SEPARATOR_PATTERN = /^\s*[\n\r]\s*$/u;
- const PRESERVED_WHITESPACE = new Set([
- "pre",
- "pre-wrap",
- "pre-line",
- "break-spaces",
- ]);
-
- if (typeof document.createTreeWalker !== "function") {
- return;
- }
- // The reader belongs to ordinary pages. First-run setup loads these same
- // scripts into its own startup page for the practice step. Its native skip
- // link may leave the known heading fragment before the module loads or on
- // reload; query variants and every other internal page stay excluded.
- if (
- location.protocol === EXTENSION_PROTOCOL &&
- location.href !== chrome.runtime.getURL("startup.html") &&
- location.href !== chrome.runtime.getURL("startup.html#setup-heading")
- ) {
- return;
- }
-
- let disposed = false;
- let appearance;
- let customStyle;
- let audio, mining;
- let options = { ...DEFAULT_OPTIONS };
- let dictionaries = [];
- let dictionaryGroups = [];
- let nextRequestId = 0;
- let currentGeneration = -1;
-
- const rootLevel = createLevelState(0);
- const levels = [rootLevel];
- let nextLevelId = 0;
-
- function createLevelState(depth) {
- return {
- depth, popup: null, view: null, highlighter: null, retired: false,
- activeCandidate: null, activeSignature: null, activeHighlightText: "",
- activeTermRender: null, currentViewRequest: null, noteEditing: false,
- pendingCustomAppends: 0, deferredDictionaryInvalidationRevision: -1,
- deferredRefresh: null, lookupToken: 0, pendingHover: null, pendingLink: null,
- pendingPopupInteraction: null,
- retainedView: false,
- pendingViewReplay: null,
- blurTimer: null,
- capturePin: null,
- capturePinPromise: null,
- };
- }
-
- let host = null;
- let shadow = null;
- let highlighter = null;
- let uiPromise = null;
- let popupLayoutFrame = null;
- let popupLayouts = new Map();
- let pageZoom = 1;
- let pageZoomRatio = null;
- let pageZoomRequest = 0;
- let sessionPopupSize = null;
- let popupResize = null;
-
- let styleGeneration = -1;
- let styleRequest = null;
- const mediaCache = new Map();
- const pendingMedia = new Map();
- let mediaCacheBytes = 0;
- let activeMediaRequests = 0;
- let mediaQueue = [];
- let popupImageSources = null;
-
- let lastPointer = null;
- let scanTimer = null;
- let hideTimer = null;
- let transferTimer = null;
- let descendantTimer = null;
- let pointerLevel = null;
- let pointerInPopup = false;
- let activationPressed = false;
- let activationCode = null;
- let pendingCandidateLookup = null;
- let selectionDragActive = false;
- let activeSelectionCandidate = null;
- // A drag the reader selects itself, glyph by glyph, in an overlay host.
- let dragSelection = null;
- let overlayMode = false;
- let hostCapabilities = { linkButtons: true, externalLinkHost: false, mediaCapture: true };
- let hostAttentionPublished = false;
- let hostAttentionHold = 0;
-
- let optionsStorageRevision = -1;
- let ankiMaturityEpoch = 0;
- let dictionaryStateRevision = -1;
- let lookupStatsDescriptor = { generation: null, revision: -1 };
- const DEFINITION_BLUR_KEYS = [
- "definitionBlurEnabled", "definitionBlurAnkiMature", "definitionBlurFrequencyEnabled",
- "definitionBlurFrequencyDictionary", "definitionBlurFrequencyOrder", "definitionBlurFrequencyThreshold",
- "definitionBlurDirection", "definitionBlurThreshold", "definitionBlurReveal", "definitionBlurDelayMs",
- ];
-
- function extensionAlive() {
- try {
- return Boolean(chrome && chrome.runtime && chrome.runtime.id);
- } catch {
- return false;
- }
- }
-
- // An overlay host such as GSM passes clicks through to the game unless the
- // reader says it needs the window. A popup needs it, and so does a drag that
- // is selecting text: the host answers a mousedown by turning click-through on,
- // which would lose the drag before release could look anything up. The claim
- // carries over to the selection's pending lookup, so the host never sees a
- // gap between the drag and the popup it produces.
- function syncHostAttention() {
- const wanted = Boolean(rootLevel.popup && !rootLevel.popup.hidden) || selectionDragActive
- || hostAttentionHold > 0 || pendingCandidateLookup?.candidate?.exactSelection === true;
- if (wanted === hostAttentionPublished) return;
- hostAttentionPublished = wanted;
- globalThis.SubMinerHachidori?.attention(host, wanted);
- window.dispatchEvent(new CustomEvent(wanted ? POPUP_SHOWN_EVENT : POPUP_HIDDEN_EVENT));
- }
-
- function setSelectionDrag(active) {
- selectionDragActive = active;
- if (!active) dragSelection = null;
- syncHostAttention();
- }
-
- // Overlay hosts (docs/overlay-mode.md) set the flag in overlay-mode.js. This
- // classic script reads the module through its extension URL; a host without
- // it, such as a test page, gets the browser behaviour.
- async function loadOverlayMode() {
- try {
- const module = await import(chrome.runtime.getURL("overlay-mode.js"));
- overlayMode = module.OVERLAY_MODE === true;
- const advertised = module.HOST_CAPABILITIES ?? {};
- hostCapabilities = { ...hostCapabilities, ...advertised };
- if (!Object.hasOwn(advertised, "linkButtons") && Object.hasOwn(advertised, "customLinks")) {
- hostCapabilities.linkButtons = advertised.customLinks;
- }
- const next = applyHostCapabilities(options);
- const customButtonsChanged = JSON.stringify(next.customButtons) !== JSON.stringify(options.customButtons);
- const miningChanged = customButtonsChanged
- || JSON.stringify(next.mediaCapture) !== JSON.stringify(options.mediaCapture);
- options = next;
- if (customButtonsChanged) {
- for (const level of levels) level.view?.setCustomButtons(options.customButtons);
- }
- if (miningChanged) mining?.update(options, optionsStorageRevision >= 0);
- } catch {
- overlayMode = false;
- }
- }
-
- function applyHostCapabilities(projected) {
- if (!hostCapabilities.mediaCapture) {
- projected = { ...projected, mediaCapture: { ...projected.mediaCapture, enabled: false } };
- }
- if (!hostCapabilities.linkButtons) projected = {
- ...projected,
- customLinks: [],
- customButtons: projected.customButtons.filter(button => button.type !== "link"),
- };
- return projected;
- }
-
- const projectHostOptions = stored => applyHostCapabilities(projectContentOptions(stored));
-
- function nonnegativeCount(value) {
- const count = Math.trunc(Number(value));
- return Number.isFinite(count) && count > 0 ? count : 0;
- }
-
- function normalizeDictionaryState(stored) {
- const state = stored && typeof stored === "object" ? stored : {};
- const rows = Array.isArray(state.dictionaries) ? state.dictionaries : [];
- const normalized = rows.flatMap((entry) => {
- const title = typeof entry?.title === "string" ? entry.title : "";
- if (!title) {
- return [];
- }
- return [{
- id: typeof entry.id === "string" ? entry.id : "",
- title,
- displayName: typeof entry.displayName === "string" && entry.displayName.trim() !== ""
- ? entry.displayName.trim()
- : null,
- path: typeof entry.path === "string" ? entry.path : "",
- revision: typeof entry.revision === "string" ? entry.revision : "",
- enabled: entry.enabled !== false,
- favorite: entry.favorite === true,
- termCount: nonnegativeCount(entry.termCount),
- frequencyCount: nonnegativeCount(entry.frequencyCount),
- frequencyMode: entry.frequencyMode,
- pitchCount: nonnegativeCount(entry.pitchCount),
- kanjiCount: nonnegativeCount(entry.kanjiCount),
- longKeyLength: nonnegativeCount(entry.longKeyLength),
- }];
- });
- return {
- revision: Number.isInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
- dictionaries: normalized,
- groups: normaliseDictionaryGroups(state.groups, normalized),
- };
- }
-
- function sameDictionaries(left, right) {
- return JSON.stringify(left) === JSON.stringify(right);
- }
-
- function sameDictionaryContents(left, right) {
- const contents = (entries) => entries.map(({ displayName, favorite, frequencyMode, ...dictionary }) => dictionary);
- return left === right || sameDictionaries(contents(left), contents(right));
- }
-
- function dictionaryPresentation() {
- return dictionaries
- .filter((entry) => entry.enabled !== false)
- .map((entry) => ({
- id: entry.id,
- title: entry.title,
- favorite: entry.favorite,
- frequencyMode: entry.frequencyMode,
- ...(entry.displayName ? { displayName: entry.displayName } : {}),
- }));
- }
-
- function dictionaryTabGroups() {
- const titles = new Map(dictionaries.filter((entry) => entry.enabled)
- .map((entry) => [entry.id, entry.title]));
- return dictionaryGroups.map((group) => ({
- id: group.id,
- name: group.name,
- dictionaries: group.dictionaryIds.filter((id) => titles.has(id)).map((id) => titles.get(id)),
- }));
- }
-
- function selectedKanjiDictionaryCapability() {
- return globalThis.HDReaderOptions.resolveKanjiDictionary(options.kanjiClickDictionary, dictionaries);
- }
-
- function projectResultsToDictionary(results, title) {
- const projected = [];
- for (const result of results) {
- const glossaries = Array.isArray(result?.term?.glossaries)
- ? result.term.glossaries.filter((glossary) => glossary && glossary.dictionary === title)
- : [];
- if (glossaries.length > 0) {
- projected.push({
- ...result,
- term: { ...result.term, glossaries },
- });
- }
- }
- return projected;
- }
-
- function isJapaneseToken(text) {
- const token = text.split(TOKEN_BOUNDARY_PATTERN, 1)[0];
- return JAPANESE_CHARACTER_PATTERN.test(token);
- }
-
- function computedStyleFor(element, styleCache) {
- let style = styleCache.get(element);
- if (!style) {
- style = window.getComputedStyle(element);
- styleCache.set(element, style);
- }
- return style;
- }
-
- function isHiddenElement(element, styleCache) {
- const style = computedStyleFor(element, styleCache);
- return style.display === "none" ||
- style.visibility === "hidden" ||
- style.visibility === "collapse";
- }
-
- function preservesWhitespace(element, styleCache) {
- if (!element) {
- return false;
- }
- const style = computedStyleFor(element, styleCache);
- const collapse = style.whiteSpaceCollapse;
- if (typeof collapse === "string" && collapse) {
- return collapse !== "collapse";
- }
- return PRESERVED_WHITESPACE.has(style.whiteSpace);
- }
-
- function isOurNode(node) {
- if (!host) {
- return false;
- }
- // The popup lives in a closed shadow root, so a caret or event inside it is
- // retargeted to the host; a node whose root is not the page document also
- // means "not page text" (user-agent shadow DOM of , page shadow DOM).
- return node === host || (node.nodeType === Node.ELEMENT_NODE
- ? host.contains(node)
- : host.contains(node.parentNode));
- }
-
- /**
- * Both caret APIs return the nearest caret *boundary*, so a pointer in the
- * right half of a glyph reports the offset after it and the scan would start
- * one character late -- pointing straight at 食 in 食べたかった would look up
- * べたかった. Step back onto the preceding character when the pointer is
- * actually inside its box.
- */
- function alignToCharacter(range, clientX, clientY) {
- const node = range.startContainer;
- const offset = range.startOffset;
- if (!range.collapsed || offset === 0 || node.nodeType !== Node.TEXT_NODE) {
- return range;
- }
- const probe = document.createRange();
- try {
- probe.setStart(node, offset - 1);
- probe.setEnd(node, offset);
- } catch {
- return range;
- }
- for (const rect of probe.getClientRects()) {
- if (
- clientX >= rect.left && clientX <= rect.right &&
- clientY >= rect.top && clientY <= rect.bottom
- ) {
- range.setStart(node, offset - 1);
- range.collapse(true);
- return range;
- }
- }
- return range;
- }
-
- function rangeFromCaretPosition(position, clientX, clientY) {
- if (!position) {
- return null;
- }
- const range = document.createRange();
- try {
- range.setStart(position.offsetNode, position.offset);
- range.setEnd(position.offsetNode, position.offset);
- } catch {
- return null;
- }
- return alignToCharacter(range, clientX, clientY);
- }
-
- function caretRangeAt(clientX, clientY, shadowRoot = null) {
- if (shadowRoot) {
- if (typeof document.caretPositionFromPoint !== "function") {
- return null;
- }
- try {
- return rangeFromCaretPosition(
- document.caretPositionFromPoint(clientX, clientY, {
- shadowRoots: [shadowRoot],
- }),
- clientX,
- clientY
- );
- } catch {
- return null;
- }
- }
- if (typeof document.caretRangeFromPoint === "function") {
- const range = document.caretRangeFromPoint(clientX, clientY);
- return range === null ? null : alignToCharacter(range, clientX, clientY);
- }
- if (typeof document.caretPositionFromPoint === "function") {
- return rangeFromCaretPosition(
- document.caretPositionFromPoint(clientX, clientY),
- clientX,
- clientY
- );
- }
- return null;
- }
-
- /**
- * The glyph under the pointer as a text range, for a drag the reader selects
- * itself. An overlay boxes each glyph in a span wider than the glyph, and from
- * the box's trailing margin the caret APIs report the boundary after its text;
- * the box still belongs to its last glyph.
- */
- function glyphAtPoint(clientX, clientY) {
- const range = caretRangeAt(clientX, clientY);
- const node = range?.startContainer;
- if (!node || !isScannableTextNode(node, new Map())) return null;
- const text = node.nodeValue || "";
- let offset = range.startOffset;
- if (offset >= text.length) {
- const box = node.parentElement.getBoundingClientRect();
- if (text.length === 0 || clientX < box.left || clientX > box.right
- || clientY < box.top || clientY > box.bottom) return null;
- offset = text.length - 1;
- if (offset > 0 && (text.charCodeAt(offset) & 0xfc00) === 0xdc00) offset -= 1;
- }
- return { node, start: offset, end: offset + (text.codePointAt(offset) > 0xffff ? 2 : 1) };
- }
-
- function isEditingElement(element) {
- return element?.isContentEditable === true || EDITING_TAGS.has(element?.localName);
- }
-
- function pageEditorFocused() {
- for (let focused = document.activeElement; focused; focused = focused.shadowRoot?.activeElement) {
- if (isEditingElement(focused)) {
- // Startup is the only extension page allowed above. Its scene arrow
- // keeps keyboard focus without pausing the practice lookup.
- if (location.protocol === EXTENSION_PROTOCOL && focused.matches(".vn-next")) continue;
- return true;
- }
- }
- return false;
- }
-
- function isScannableElement(element, styleCache) {
- if (!element || element.getRootNode() !== document || isOurNode(element) || isHiddenElement(element, styleCache)) {
- return false;
- }
- for (let current = element; current; current = current.parentElement) {
- if (isEditingElement(current) || OPAQUE_TAGS.has(current.localName)
- || computedStyleFor(current, styleCache).display === "none") {
- return false;
- }
- }
- return true;
- }
-
- function isScannableTextNode(node, styleCache) {
- return node?.nodeType === Node.TEXT_NODE && isScannableElement(node.parentElement, styleCache);
- }
-
- function createScanWalker(root, styleCache) {
- return document.createTreeWalker(
- root,
- NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
- {
- acceptNode(node) {
- if (node.nodeType === Node.TEXT_NODE) {
- return isHiddenElement(node.parentElement, styleCache) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
- }
- const editing = isEditingElement(node);
- if (
- (!editing && OPAQUE_TAGS.has(node.localName)) ||
- isOurNode(node) ||
- computedStyleFor(node, styleCache).display === "none"
- ) {
- return NodeFilter.FILTER_REJECT;
- }
- if (editing) return hasVisibleContent(node, styleCache) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
- // Visible controls and line breaks are boundaries. Every other element
- // is crossed whatever its layout, as Yomitan's layout-unaware scan
- // does: a word boxed one glyph per positioned span is still one word.
- // Hidden wrappers are skipped too, since a descendant may restore
- // visibility.
- return node.localName === "br"
- ? NodeFilter.FILTER_ACCEPT
- : NodeFilter.FILTER_SKIP;
- },
- }
- );
- }
-
- /**
- * The text nodes around `startNode`, in document order, that make up the
- * sentence: neighbours up to SENTENCE_SCAN_EXTENT characters each way, cut at
- * a block separator, a line break or a control. They are the candidate's
- * `sourceElements`, so `sourceElements.map(textContent).join("") === sentence`
- * holds by construction, which is what createSourceHighlighter requires.
- */
- function collectSentenceSources(startNode, root, styleCache) {
- const sources = [startNode];
- for (const backward of [true, false]) {
- const walker = createScanWalker(root, styleCache);
- walker.currentNode = startNode;
- let length = 0;
- while (length < SENTENCE_SCAN_EXTENT) {
- const node = backward ? walker.previousNode() : walker.nextNode();
- if (
- !node ||
- node.nodeType !== Node.TEXT_NODE ||
- BLOCK_SEPARATOR_PATTERN.test(node.nodeValue || "") ||
- // The walker stops at a control going forward but reaches its text
- // first going backward.
- (backward && isEditingElement(node.parentElement?.closest(EDITING_SELECTOR)))
- ) {
- break;
- }
- if (backward) sources.unshift(node); else sources.push(node);
- length += (node.nodeValue || "").length;
- }
- }
- return sources;
- }
-
- function withinSources(sources, node) {
- return sources.some((source) => source === node
- || (source.nodeType === Node.ELEMENT_NODE && source.contains(node)));
- }
-
- /** `offset` inside `node` expressed in the concatenated text of `sources`. */
- function sourceOffset(sources, node, offset) {
- let consumed = 0;
- for (const source of sources) {
- if (source === node) return consumed + offset;
- if (source.nodeType === Node.ELEMENT_NODE && source.contains(node)) {
- return consumed + rangeOffsetWithin(source, node, offset);
- }
- consumed += (source.textContent || "").length;
- }
- throw new RangeError("node is not inside the candidate sources");
- }
-
- function pushCollapsedSpace(entries, node, offset, sourceLength, segmentBreak) {
- const previous = entries[entries.length - 1];
- if (previous && previous.collapsed) {
- // A run split across text nodes ("食べ ます")
- // is still one collapsed space in the rendered line.
- previous.segmentBreak = previous.segmentBreak || segmentBreak;
- return;
- }
- entries.push({
- collapsed: true,
- node,
- offset,
- segmentBreak,
- sourceLength,
- text: " ",
- });
- }
-
- /** Appends `node`'s characters from `from` onward; false stops the walk. */
- function appendTextNode(entries, node, from, budget, styleCache) {
- const raw = node.nodeValue || "";
- const preserve = preservesWhitespace(node.parentElement, styleCache);
- let index = from;
- while (index < raw.length && entries.length < budget) {
- const character = String.fromCodePoint(raw.codePointAt(index));
- if (preserve) {
- if (SEGMENT_BREAK_PATTERN.test(character)) {
- return false;
- }
- } else if (COLLAPSIBLE_WHITESPACE_PATTERN.test(character)) {
- let end = index;
- let segmentBreak = false;
- while (
- end < raw.length &&
- COLLAPSIBLE_WHITESPACE_PATTERN.test(raw[end])
- ) {
- segmentBreak = segmentBreak || SEGMENT_BREAK_PATTERN.test(raw[end]);
- end += 1;
- }
- pushCollapsedSpace(entries, node, index, end - index, segmentBreak);
- index = end;
- continue;
- }
- entries.push({
- collapsed: false,
- node,
- offset: index,
- segmentBreak: false,
- sourceLength: character.length,
- text: character,
- });
- index += character.length;
- }
- return true;
- }
-
- function dropCjkSegmentBreaks(entries) {
- for (let index = entries.length - 1; index >= 1; index -= 1) {
- const entry = entries[index];
- const next = entries[index + 1];
- if (!entry.collapsed || !entry.segmentBreak || !next) {
- continue;
- }
- // CSS drops a segment break between two wide characters instead of
- // turning it into a space, so a source-wrapped 「日本\n語」 renders as
- // 日本語 and has to be scanned that way.
- if (
- JAPANESE_CHARACTER_PATTERN.test(entries[index - 1].text) &&
- JAPANESE_CHARACTER_PATTERN.test(next.text)
- ) {
- entries.splice(index, 1);
- }
- }
- }
-
- // How many code points of page text a lookup is given. The engine scans
- // options.scanLength of them as before, and reaches further only when the
- // text begins like a dictionary key longer than that (hoshidicts long-key
- // index; each package row carries the longest such key it lists). Eight more
- // leaves room for an inflected ending, matching the engine. Dictionaries
- // imported before the index existed report 0 and cost nothing extra. Off by
- // default: Settings → Advanced → Experimental features → Long dictionary
- // entries switches it on.
- const LONG_KEY_INFLECTION_SLACK = 8;
- const MAX_SCAN_WINDOW = 256;
-
- function scanWindow() {
- if (options.experimental.longKeyScan !== true) return options.scanLength;
- let longest = 0;
- for (const entry of dictionaries) {
- if (entry.enabled !== false && entry.termCount > 0 && entry.longKeyLength > longest) {
- longest = entry.longKeyLength;
- }
- }
- if (longest === 0) return options.scanLength;
- return Math.min(MAX_SCAN_WINDOW, Math.max(options.scanLength, longest + LONG_KEY_INFLECTION_SLACK));
- }
-
- function collectScanEntries(startNode, startOffset, root, scanLength, styleCache) {
- const walker = createScanWalker(root, styleCache);
- walker.currentNode = startNode;
-
- const entries = [];
- // Collapsing and segment-break removal can only shorten the scan, so
- // over-collect and trim once the string is final.
- const budget = scanLength * 3 + 32;
- let node = startNode;
- let offset = startOffset;
- while (node && node.nodeType === Node.TEXT_NODE && entries.length < budget) {
- if (!appendTextNode(entries, node, offset, budget, styleCache)) {
- break;
- }
- offset = 0;
- node = walker.nextNode();
- // A word never continues into the next block, as Yomitan's kept "\n"
- // ends its match there.
- if (node?.nodeType === Node.TEXT_NODE && BLOCK_SEPARATOR_PATTERN.test(node.nodeValue || "")) {
- break;
- }
- }
- dropCjkSegmentBreaks(entries);
- return entries.slice(0, scanLength);
- }
-
- function rangeOffsetWithin(container, node, offset) {
- const range = document.createRange();
- range.selectNodeContents(container);
- range.setEnd(node, offset);
- return range.toString().length;
- }
-
- /**
- * Builds a candidate for the caret at (clientX, clientY), or null when there
- * is nothing Japanese to look up there.
- */
- function resolveCandidate(clientX, clientY) {
- const caretRange = caretRangeAt(clientX, clientY);
- if (!caretRange) {
- return null;
- }
- return resolveCandidateAt(caretRange.startContainer, caretRange.startOffset);
- }
-
- function resolveCandidateAt(startNode, startOffset) {
- const styleCache = new Map();
- if (!isScannableTextNode(startNode, styleCache)) {
- return null;
- }
- const entries = collectScanEntries(
- startNode,
- Math.min(startOffset, (startNode.nodeValue || "").length),
- document.body,
- scanWindow(),
- styleCache
- );
- if (entries.length === 0) {
- return null;
- }
- const query = entries.map((entry) => entry.text).join("");
- if (options.onlyScanJapaneseText && !isJapaneseToken(query)) {
- return null;
- }
-
- const first = entries[0];
- const sourceElements = collectSentenceSources(first.node, document.body, styleCache);
- let matchOffset;
- let anchorRange;
- try {
- matchOffset = sourceOffset(sourceElements, first.node, first.offset);
- anchorRange = document.createRange();
- anchorRange.setStart(first.node, first.offset);
- anchorRange.setEnd(
- first.node,
- Math.min(
- (first.node.nodeValue || "").length,
- first.offset + first.sourceLength
- )
- );
- } catch {
- return null;
- }
- return {
- anchor: first.node.parentElement,
- anchorRange,
- matchOffset,
- query,
- scanEntries: entries,
- sentence: sourceElements.map((source) => source.nodeValue || "").join(""),
- sourceDepth: -1,
- sourceElements,
- vertical: computedStyleFor(first.node.parentElement, styleCache)
- .writingMode.startsWith("vertical"),
- };
- }
-
- function resolveDefinitionCandidate(clientX, clientY, level) {
- if (
- !shadow ||
- !level ||
- level.retired ||
- levels[level.depth] !== level ||
- !level.popup ||
- level.popup.hidden
- ) {
- return null;
- }
- const styleCache = new Map();
- const caretRange = caretRangeAt(clientX, clientY, shadow);
- if (!caretRange) {
- return null;
- }
- const startNode = caretRange.startContainer;
- if (startNode.nodeType !== Node.TEXT_NODE) {
- return null;
- }
- const lookupText = startNode.parentElement?.closest(
- ".gsm-hoshidicts-glossary-content, .gsm-hoshidicts-compact-definition-summary"
- );
- if (
- !lookupText ||
- !level.popup.contains(lookupText) ||
- !lookupText.contains(startNode)
- ) {
- return null;
- }
- for (
- let current = startNode.parentElement;
- current;
- current = current.parentElement
- ) {
- if (
- isHiddenElement(current, styleCache) ||
- isEditingElement(current) ||
- current.localName === "a" ||
- OPAQUE_TAGS.has(current.localName) ||
- computedStyleFor(current, styleCache).display === "none"
- ) {
- return null;
- }
- if (current === lookupText) {
- break;
- }
- if (current === level.popup) {
- return null;
- }
- }
- let entries = collectScanEntries(
- startNode,
- Math.min(caretRange.startOffset, (startNode.nodeValue || "").length),
- lookupText,
- scanWindow(),
- styleCache
- );
- const linkBoundary = entries.findIndex((entry) =>
- entry.node.parentElement?.closest("a")
- );
- if (linkBoundary >= 0) {
- entries = entries.slice(0, linkBoundary);
- }
- if (entries.length === 0) {
- return null;
- }
- const query = entries.map((entry) => entry.text).join("");
- if (options.onlyScanJapaneseText && !isJapaneseToken(query)) {
- return null;
- }
- const first = entries[0];
- let matchOffset;
- let anchorRange;
- try {
- matchOffset = rangeOffsetWithin(lookupText, first.node, first.offset);
- anchorRange = document.createRange();
- anchorRange.setStart(first.node, first.offset);
- anchorRange.setEnd(
- first.node,
- Math.min(
- (first.node.nodeValue || "").length,
- first.offset + first.sourceLength
- )
- );
- } catch {
- return null;
- }
- return {
- anchor: lookupText,
- anchorRange,
- matchOffset,
- query,
- scanEntries: entries,
- sentence: lookupText.textContent || "",
- sourceDepth: level.depth,
- sourceElements: [lookupText],
- vertical: computedStyleFor(lookupText, styleCache)
- .writingMode.startsWith("vertical"),
- };
- }
-
- function selectionBoundaryElement(node) {
- return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
- }
-
- function hasVisibleContent(element, styleCache) {
- if (computedStyleFor(element, styleCache).display === "none") return false;
- const visible = !isHiddenElement(element, styleCache);
- if (visible && element.getClientRects().length > 0) return true;
- for (const child of element.childNodes) {
- if (child.nodeType === Node.ELEMENT_NODE && hasVisibleContent(child, styleCache)) return true;
- if (visible && child.nodeType === Node.TEXT_NODE) {
- // A display:contents editor has no box, but its editable text still does.
- const range = document.createRange();
- range.selectNodeContents(child);
- if (range.getClientRects().length > 0) return true;
- }
- }
- return false;
- }
-
- function resolveSelectedLookupCandidate(selection = window.getSelection()) {
- if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
- const range = selection.getRangeAt(0);
- const styleCache = new Map();
- if (!isScannableElement(selectionBoundaryElement(range.startContainer), styleCache)
- || !isScannableElement(selectionBoundaryElement(range.endContainer), styleCache)) return null;
- const query = selection.toString();
- if (!query.trim()) return null;
- const anchor = selectionBoundaryElement(range.commonAncestorContainer);
- for (const control of anchor.querySelectorAll(EDITING_SELECTOR)) {
- if (isEditingElement(control) && range.intersectsNode(control)
- && hasVisibleContent(control, styleCache)) return null;
- }
- return {
- anchor,
- anchorRange: range.cloneRange(),
- exactSelection: true,
- matchOffset: rangeOffsetWithin(anchor, range.startContainer, range.startOffset),
- query,
- rawSelectionText: range.toString(),
- sentence: anchor.textContent || "",
- sourceDepth: -1,
- sourceElements: [anchor],
- vertical: computedStyleFor(anchor, styleCache).writingMode.startsWith("vertical"),
- };
- }
-
- // Yomitan's Scan text at selection: an ordinary scan from the selection's
- // first text. The live selection, not the scanned word, keeps it retained.
- function resolveSelectionScanCandidate(selection = window.getSelection()) {
- if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
- const range = selection.getRangeAt(0);
- let node = range.startContainer, offset = range.startOffset;
- if (node.nodeType !== Node.TEXT_NODE) {
- const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT);
- do node = walker.nextNode(); while (node && !range.intersectsNode(node));
- offset = 0;
- }
- const candidate = node ? resolveCandidateAt(node, offset) : null;
- return candidate && { ...candidate, selectionRange: range.cloneRange(), selectionText: selection.toString() };
- }
-
- function candidateStart(candidate) {
- if (candidate.linkAnchor) return { node: candidate.anchor, offset: 0 };
- return candidate.exactSelection === true
- ? { node: candidate.anchorRange.startContainer, offset: candidate.anchorRange.startOffset }
- : candidate.scanEntries[0];
- }
-
- function candidateSignature(candidate) {
- const first = candidateStart(candidate);
- return `${candidate.exactSelection === true}\u001f${first.offset}\u001f${candidate.matchOffset}\u001f${candidate.query}`;
- }
-
- function sameAnchorNode(candidate, other) {
- return Boolean(other) &&
- other.anchor === candidate.anchor &&
- candidateStart(other).node === candidateStart(candidate).node;
- }
-
- /** Returns the last scanned source character covered by the engine match. */
- function matchedScanEnd(candidate, matched) {
- const wanted = typeof matched === "string" ? matched.length : 0;
- if (wanted <= 0 || !Array.isArray(candidate.scanEntries)) return null;
- let consumed = 0;
- let last = null;
- for (const entry of candidate.scanEntries) {
- if (consumed >= wanted) {
- break;
- }
- consumed += entry.text.length;
- last = entry;
- }
- return last;
- }
-
- function expandCandidateAnchor(candidate, matched) {
- if (candidate.linkAnchor || candidate.exactSelection === true || !candidate.anchorRange) return;
- const first = candidate.scanEntries?.[0];
- const last = matchedScanEnd(candidate, matched);
- if (!first || !last) return;
- // A page can move scanned text while the lookup is pending.
- if (!withinSources(candidate.sourceElements, first.node) || !withinSources(candidate.sourceElements, last.node)) return;
- try {
- // Scanning starts with a one-glyph range. Once lookup identifies the
- // complete match, place the popup against that word like Yomitan/PR 549.
- const range = document.createRange();
- range.setStart(first.node, first.offset);
- range.setEnd(
- last.node,
- Math.min(
- (last.node.nodeValue || "").length,
- last.offset + last.sourceLength
- )
- );
- if (!range.collapsed) candidate.anchorRange = range;
- } catch {
- // Keep the original hovered-glyph range if the page changed meanwhile.
- }
- }
-
- /**
- * Translates a matched length in scan coordinates into the raw substring of
- * `candidate.sentence` that covers it. createSourceHighlighter measures the
- * highlight as `matchedText.length` from `candidate.matchOffset` inside
- * `sentence`, and `sentence` still carries the rt text and uncollapsed
- * whitespace the scan dropped -- so the engine's own `matched` string is the
- * wrong length whenever the word crosses ruby or a line wrap.
- */
- function rawMatchedText(candidate, matched) {
- if (candidate.linkAnchor) return candidate.sentence;
- if (candidate.exactSelection === true) return candidate.rawSelectionText;
- const last = matchedScanEnd(candidate, matched);
- if (!last) {
- return "";
- }
- try {
- const end = sourceOffset(
- candidate.sourceElements,
- last.node,
- Math.min(
- (last.node.nodeValue || "").length,
- last.offset + last.sourceLength
- )
- );
- if (end > candidate.matchOffset) {
- return candidate.sentence.slice(candidate.matchOffset, end);
- }
- } catch {
- // Fall through to the engine's own string.
- }
- return matched;
- }
-
- function releaseCapture(value) {
- if (!value) return;
- void Promise.resolve(value).then(pin => window.HDCapture?.release(pin)).catch(() => {});
- }
-
- function releaseProvisionalCapture(value, level) {
- // Child requests borrow the root pin. A root replay also borrows the pin
- // already adopted by the visible popup, even if that replay becomes stale.
- if (level !== rootLevel) return;
- void Promise.resolve(value).then(pin => {
- if (pin !== rootLevel.capturePin) releaseCapture(pin);
- }).catch(() => {});
- }
-
- function releaseRootCapture() {
- const capture = rootLevel.capturePinPromise ?? rootLevel.capturePin;
- rootLevel.capturePin = null;
- rootLevel.capturePinPromise = null;
- releaseCapture(capture);
- }
-
- function teardown(reason) {
- if (disposed) {
- return;
- }
- releaseRootCapture();
- audio?.dispose();
- mining?.retire();
- disposed = true;
- disconnectSubminer?.();
- selectionDragActive = false;
- dragSelection = null;
- cancelPopupLayout();
- clearDictionaryResources();
- window.clearTimeout(scanTimer);
- window.clearTimeout(hideTimer);
- clearTransferTimer();
- clearDescendantTimer();
- scanTimer = null;
- hideTimer = null;
- document.removeEventListener("mousemove", onMouseMove, true);
- document.removeEventListener("mousedown", onMouseDown, true);
- document.removeEventListener("mouseup", onMouseUp, true);
- document.removeEventListener("selectionchange", onSelectionChange);
- document.removeEventListener("focusin", onPageFocusIn, true);
- document.removeEventListener("keydown", onKeyDown, true);
- document.removeEventListener("keyup", onKeyUp, true);
- document.removeEventListener("mouseout", onMouseOut, true);
- window.removeEventListener("scroll", onScroll, true);
- window.removeEventListener("blur", onWindowBlur);
- window.removeEventListener("pagehide", onPageHide);
- window.removeEventListener("pageshow", onPageShow);
- window.removeEventListener("resize", refreshPageZoom);
- try {
- chrome.storage.onChanged.removeListener(onStorageChanged);
- chrome.runtime.onMessage?.removeListener(onReaderCommand);
- } catch {
- // The context is already gone; the listener died with it.
- }
- try {
- highlighter?.clearAll();
- for (const level of levels) level.view?.destroy();
- } catch {
- // Teardown is best effort.
- }
- appearance?.destroy();
- customStyle?.destroy();
- host?.remove();
- host = null;
- shadow = null;
- rootLevel.popup = null;
- rootLevel.view = null;
- highlighter = null;
- rootLevel.activeCandidate = null;
- rootLevel.activeTermRender = null;
- rootLevel.currentViewRequest = null;
- rootLevel.noteEditing = false;
- syncHostAttention();
- if (reason) {
- console.debug(`hachidori: content script stopped (${reason})`);
- }
- }
-
- function discardUi() {
- releaseRootCapture();
- audio?.retire();
- mining?.retire();
- cancelPopupLayout();
- clearDictionaryResources();
- try {
- highlighter?.clearAll();
- for (const level of levels) level.view?.destroy();
- } catch {
- // Best effort: the point is only to leave nothing half-built behind.
- }
- host?.remove();
- host = null;
- shadow = null;
- rootLevel.popup = null;
- rootLevel.view = null;
- highlighter = null;
- rootLevel.activeCandidate = null;
- rootLevel.activeSignature = null;
- rootLevel.activeTermRender = null;
- rootLevel.currentViewRequest = null;
- rootLevel.noteEditing = false;
- syncHostAttention();
- }
-
- function clearDictionaryResources() {
- mediaCache.clear();
- mediaCacheBytes = 0;
- mediaQueue = [];
- for (const job of [...pendingMedia.values()]) {
- finishMediaJob(job, new Error("obsolete media request"));
- }
- styleGeneration = -1;
- styleRequest = null;
- }
-
- function noteGeneration(generation, owner = rootLevel) {
- if (!Number.isFinite(generation) || generation === currentGeneration) {
- return;
- }
- currentGeneration = generation;
- audio?.retire();
- mining?.retire();
- clearDictionaryResources();
- // Generation is an engine incarnation, not a monotonic storage revision.
- // Invalidate other in-flight owners even when a restarted engine returns 1.
- for (const level of levels) {
- if (level !== owner) {
- level.lookupToken += 1;
- level.retainedView = Boolean(level.currentViewRequest && !level.popup.hidden);
- }
- }
- }
-
- function sendRequest(type, payload, target = TARGET) {
- return new Promise((resolve, reject) => {
- if (disposed || !extensionAlive()) {
- teardown("context-invalidated");
- reject(new Error("extension context invalidated"));
- return;
- }
- const requestId = payload?.requestId ?? `${type.replace(/^hd_/u, "")}-${nextRequestId += 1}`;
- const request = { ...payload, requestId, target, type };
- try {
- chrome.runtime.sendMessage(request, (reply) => {
- const lastError = chrome.runtime.lastError;
- if (lastError) {
- const message = lastError.message || "sendMessage failed";
- if (INVALIDATED_MESSAGE_PATTERN.test(message)) {
- teardown("context-invalidated");
- }
- reject(new Error(message));
- return;
- }
- if (
- !reply ||
- reply.type !== `${type}_result` ||
- reply.requestId !== requestId
- ) {
- reject(new Error(`unexpected reply for ${type}`));
- return;
- }
- if (reply.ok !== true) {
- const error = new Error(reply.error || `${type} failed`);
- error.responseReceived = true;
- if (typeof reply.errorCode === "string") error.code = reply.errorCode;
- reject(error);
- return;
- }
- resolve(reply);
- });
- } catch (error) {
- teardown("context-invalidated");
- reject(error);
- }
- });
- }
-
- function cacheMedia(key, url) {
- // The engine produces base64 data URLs. Count decoded bytes without
- // decoding or copying the payload merely to maintain the cache budget.
- const padding = url.endsWith("==") ? 2 : url.endsWith("=") ? 1 : 0;
- const byteLength = (url.length - url.indexOf(",") - 1) / 4 * 3 - padding;
- mediaCache.set(key, { url, byteLength });
- mediaCacheBytes += byteLength;
- while (mediaCache.size > MAX_MEDIA_CACHE_ENTRIES || mediaCacheBytes > MAX_MEDIA_CACHE_BYTES) {
- const oldestKey = mediaCache.keys().next().value;
- mediaCacheBytes -= mediaCache.get(oldestKey).byteLength;
- // These are data URLs, not revocable Blob URLs. Drop our reference;
- // an image already rendered from it retains its independent DOM owner.
- mediaCache.delete(oldestKey);
- }
- }
-
- function finishMediaJob(job, error, url) {
- if (job.settled) return;
- job.settled = true;
- if (job.timer !== null) window.clearTimeout(job.timer);
- if (pendingMedia.get(job.key) === job) pendingMedia.delete(job.key);
- if (job.active) {
- job.active = false;
- activeMediaRequests -= 1;
- }
- if (error) job.reject(error);
- else job.resolve(url);
- }
-
- function pruneMediaQueue() {
- mediaQueue = mediaQueue.filter((job) => {
- if (job.consumers.some((isCurrent) => isCurrent())) return true;
- finishMediaJob(job, new Error("obsolete media request"));
- return false;
- });
- }
-
- async function dispatchMedia(job) {
- try {
- const reply = await sendRequest("hd_media", job.payload);
- if (job.settled) return;
- if (pendingMedia.get(job.key) !== job || job.payload.generation !== currentGeneration
- || reply.generation !== job.payload.generation) {
- throw new Error("obsolete media reply");
- }
- if (typeof reply.dataUrl !== "string") throw new Error("dictionary image is unavailable");
- // Started resource fetches may finish while hidden; image callbacks
- // separately check their current view before touching DOM.
- cacheMedia(job.key, reply.dataUrl);
- finishMediaJob(job, null, reply.dataUrl);
- } catch (error) {
- finishMediaJob(job, error);
- } finally {
- pumpMediaQueue();
- }
- }
-
- function pumpMediaQueue() {
- while (mediaQueue.length > 0 && activeMediaRequests < MAX_MEDIA_CONCURRENT_REQUESTS) {
- const job = mediaQueue.shift();
- if (!job.consumers.some((isCurrent) => isCurrent())) {
- finishMediaJob(job, new Error("obsolete media request"));
- continue;
- }
- job.active = true;
- activeMediaRequests += 1;
- job.timer = window.setTimeout(() => {
- finishMediaJob(job, new Error("dictionary image request timed out"));
- pumpMediaQueue();
- }, MEDIA_REQUEST_TIMEOUT_MS);
- void dispatchMedia(job);
- }
- }
-
- function resolveMedia({ dictionary, generation, path, isCurrent }) {
- if (!isCurrent() || generation !== currentGeneration) {
- return Promise.reject(new Error("obsolete media request"));
- }
- const key = `${generation}\u0000${dictionary}\u0000${path}`;
- const cached = mediaCache.get(key);
- if (cached) {
- mediaCache.delete(key);
- mediaCache.set(key, cached);
- return Promise.resolve(cached.url);
- }
- const pending = pendingMedia.get(key);
- if (pending) {
- pending.consumers.push(isCurrent);
- return pending.promise;
- }
- if (pendingMedia.size >= MAX_MEDIA_PENDING_REQUESTS) pruneMediaQueue();
- if (pendingMedia.size >= MAX_MEDIA_PENDING_REQUESTS) {
- return Promise.reject(new Error("dictionary image queue is full"));
- }
- const job = { key, consumers: [isCurrent], payload: { dictionary, generation, path },
- active: false, settled: false, timer: null };
- job.promise = new Promise((resolveJob, rejectJob) => {
- job.resolve = resolveJob;
- job.reject = rejectJob;
- });
- pendingMedia.set(key, job);
- mediaQueue.push(job);
- pumpMediaQueue();
- return job.promise;
- }
-
- function imageSourceContext() {
- const next = window.HDReaderOptions.resolvePopupImageSources(options.popupImageSource, dictionaries, dictionaryGroups);
- // Keep the effective route's identity through alias/name-only changes.
- // In-flight consumers capture it, independently of broad storage revisions.
- if (next !== popupImageSources && !sameDictionaries(next, popupImageSources)) popupImageSources = next;
- return { popupImageSources, resolveMedia: resolvePopupMedia };
- }
-
- function resolvePopupMedia(request) {
- const sources = popupImageSources;
- const isCurrent = () => sources === popupImageSources && request.generation === currentGeneration && request.isCurrent();
- const ownedRequest = { ...request, isCurrent };
- if (sources !== null) return resolveRoutedMedia(ownedRequest, sources);
- // Automatic retains the direct cache/queue path without candidate scans.
- return resolveMedia(ownedRequest).then(url => {
- if (!isCurrent()) throw new Error("obsolete media reply");
- return url;
- });
- }
-
- async function resolveRoutedMedia(request, sources) {
- const { isCurrent } = request;
- for (const dictionary of sources) {
- if (!isCurrent()) throw new Error("obsolete media request");
- let url;
- try {
- url = await resolveMedia({ ...request, dictionary, isCurrent });
- } catch (error) {
- if (!isCurrent()) throw error;
- // Availability is per requested path, not one global group winner.
- continue;
- }
- if (!isCurrent()) throw new Error("obsolete media reply");
- request.onResolvedSource?.(dictionary);
- return url;
- }
- throw new Error("dictionary image is unavailable");
- }
-
- function ensureDictionaryStyles(generation) {
- if (!shadow || generation === styleGeneration) {
- return;
- }
- styleGeneration = generation;
- const request = {};
- styleRequest = request;
- sendRequest("hd_styles", {}).then((reply) => {
- if (disposed || !shadow || styleRequest !== request) {
- return;
- }
- if (reply.generation !== generation) throw new Error("obsolete dictionary styles");
- window.HDGlossary.applyDictionaryStyles(
- document,
- shadow,
- generation,
- Array.isArray(reply.styles) ? reply.styles : []
- );
- }).catch(() => {
- // Dictionary CSS is cosmetic; a failure must not block the lookup that
- // asked for it. Retry on the next render without resetting a newer job.
- if (styleRequest === request) {
- styleGeneration = -1;
- styleRequest = null;
- }
- });
- }
-
- // Browser zoom scales CSS pixels. The popup cancels it with CSS zoom to keep
- // one on-screen size, so its lengths are unzoomed pixels and page geometry is
- // converted into them before placement.
- function popupRect(rect) {
- return window.HDPopup.scaleRect(rect, window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent));
- }
-
- function popupViewport() {
- const factor = window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent);
- return { width: window.innerWidth * factor, height: window.innerHeight * factor };
- }
-
- function applyPageZoom() {
- host?.style.setProperty("--gsm-hoshidicts-page-zoom", String(1 / pageZoom));
- }
-
- function refreshPageZoom() {
- // Resizing a window keeps its device pixel ratio; a zoom change does not.
- if (disposed || window.devicePixelRatio === pageZoomRatio) return;
- pageZoomRatio = window.devicePixelRatio;
- const request = ++pageZoomRequest;
- sendRequest("hd_page_zoom", {}, PAGE_ZOOM_TARGET).then((reply) => {
- if (disposed || request !== pageZoomRequest || !(reply.zoomFactor > 0) || reply.zoomFactor === pageZoom) return;
- pageZoom = reply.zoomFactor;
- applyPageZoom();
- for (const level of levels) level.view?.hideImagePreview();
- positionPopup();
- }, (error) => console.debug("hachidori: page zoom unavailable", error));
- }
-
- function calculatePopupPosition(anchorRect, viewport, vertical) {
- return window.HDPopup.calculatePopupPosition(anchorRect, sessionPopupSize ?? {
- width: options.popupWidthPx, height: options.popupHeightPx,
- }, viewport, { gap: POPUP_GAP_PX, padding: POPUP_PADDING_PX, vertical });
- }
-
- function anchorRectFor(candidate) {
- if (candidate.anchorRange) {
- try {
- const first = candidate.scanEntries?.[0];
- if (first && !candidate.linkAnchor && candidate.exactSelection !== true) {
- const origin = document.createRange();
- origin.setStart(first.node, first.offset);
- origin.setEnd(first.node, first.offset + first.sourceLength);
- const glyph = origin.getBoundingClientRect();
- const x = (glyph.left + glyph.right) / 2;
- const y = (glyph.top + glyph.bottom) / 2;
- const fragment = [...candidate.anchorRange.getClientRects()].find(rect =>
- rect.left <= x && rect.right >= x && rect.top <= y && rect.bottom >= y);
- if (fragment) return fragment;
- }
- const rect = candidate.anchorRange.getBoundingClientRect();
- if (rect && Number.isFinite(rect.left) && (rect.width > 0 || rect.height > 0)) {
- return rect;
- }
- } catch {
- // The range's nodes moved; fall back to the container box.
- }
- }
- return candidate.anchor.getBoundingClientRect();
- }
-
- function anchorConnected(candidate) {
- return Boolean(candidate) &&
- candidate.anchor.isConnected &&
- candidateStart(candidate).node.isConnected &&
- (candidate.exactSelection !== true || (
- !candidate.anchorRange.collapsed
- && candidate.anchor.contains(candidate.anchorRange.startContainer)
- && candidate.anchor.contains(candidate.anchorRange.endContainer)
- ));
- }
-
- function requestCanRender(token, candidate, level = rootLevel) {
- if (disposed || level.retired || token !== level.lookupToken || !level.popup) return false;
- if (retireDetachedAncestor(level)) return false;
- // Initial selections still own the live page selection; Note/Back replays
- // intentionally use their stored descriptor even after focus collapses it.
- if (!anchorConnected(candidate) || (level === rootLevel && pendingCandidateLookup?.token === token
- && candidate.exactSelection === true && !selectionIsUnchanged(candidate))) {
- hide(level);
- return false;
- }
- return true;
- }
-
- function retireDetachedAncestor(level) {
- for (let depth = 0; depth < level.depth; depth += 1) {
- const ancestor = levels[depth];
- if (!anchorConnected(ancestor.activeCandidate)) {
- hide(ancestor);
- return true;
- }
- }
- return false;
- }
-
- function lookupFailureState(error, request = null) {
- const message = error instanceof Error ? error.message : String(error);
- if (error?.code === "dictionary-structured-content-limit") {
- return {
- kind: "render",
- title: typeof error.userTitle === "string"
- ? error.userTitle
- : "Dictionary content could not be rendered.",
- detail: typeof error.userDetail === "string" ? error.userDetail : message,
- };
- }
- if (error?.code === "engine-mutating" || message === "the dictionary engine is busy mutating") {
- return {
- kind: "updating",
- title: "Dictionary update in progress.",
- detail: "Try the lookup again when the update finishes.",
- };
- }
- if (error?.code === "sharing-disconnected" || message === "The linked Hachidori is not reachable.") {
- return {
- kind: "disconnected",
- title: "Shared Hachidori is disconnected.",
- detail: "Reconnect it in Settings → Sharing, then try again.",
- };
- }
- if (error?.code === "engine-starting" || message === "the dictionary engine is still starting") {
- return {
- kind: "starting",
- title: "Dictionary engine is starting.",
- detail: "Wait a moment, then try again.",
- };
- }
- if (error?.code === "engine-start-failed") {
- return {
- kind: "engine",
- title: "Dictionary engine could not start.",
- detail: "Open Settings to check the engine status, then try again.",
- };
- }
- if (request?.kind === "kanji") {
- return {
- kind: "kanji",
- title: "Kanji lookup failed.",
- detail: "The current definition is still available. Try again.",
- };
- }
- return null;
- }
-
- function retainFailedView(request, token, level, replayOptions) {
- if (!replayOptions?.preserveViewControls || disposed || level.retired
- || token !== level.lookupToken || level.currentViewRequest !== request
- || level.popup.hidden || !requestCanRender(token, request.candidate, level)) return false;
- level.retainedView = true;
- return true;
- }
-
- function handleRequestFailure(request, token, error, level, replayOptions) {
- const preserveView = retainProtectedReplay(request, token, level, replayOptions)
- || retainFailedView(request, token, level, replayOptions);
- return handleLookupFailure(token, error, level, request, preserveView);
- }
-
- function handleLookupFailure(token, error, level = rootLevel, request = null, preserveView = false) {
- if (disposed || level.retired || token !== level.lookupToken) return false;
- if (error?.code !== "dictionary-structured-content-limit") {
- console.debug("hachidori: lookup failed", error);
- }
- const state = lookupFailureState(error, request);
- if (state === null) {
- if (!preserveView) hide(level);
- return false;
- }
- if (!preserveView) {
- show(request?.candidate ?? level.activeCandidate, level);
- level.currentViewRequest = request;
- level.activeHighlightText = "";
- level.activeTermRender = null;
- clearDefinitionBlurTimer(level);
- pruneLevels(level.depth + 1);
- }
- level.view.renderLookupFailure({
- ...state,
- actionLabel: "Try again",
- onAction: () => executeViewRequest(
- request,
- level,
- preserveView ? { preserveViewControls: true } : null,
- ),
- }, { preserveView });
- positionPopup(level);
- return false;
- }
-
- function handleRenderFailure(token, error, request, level = rootLevel) {
- if (disposed || level.retired || token !== level.lookupToken) return false;
- if (error?.cause instanceof Error) {
- console.warn("hachidori: could not render results", error, "caused by", error.cause);
- } else {
- console.warn("hachidori: could not render results", error);
- }
- return handleLookupFailure(token, error, level, request);
- }
-
- function retainProtectedReplay(request, token, level, replayOptions) {
- if (!replayOptions?.preserveViewControls || disposed || level.retired
- || token !== level.lookupToken || level.currentViewRequest !== request
- || level.popup.hidden
- || (!level.noteEditing && level.pendingCustomAppends === 0)) return false;
- if (!requestCanRender(token, request.candidate, level)) return false;
- level.retainedView = true;
- return true;
- }
-
- function positionToolbar(level, placement, reset = false) {
- const desired = window.HDPopup.resolveToolbarPosition(options.popupToolbarPosition, placement,
- reset ? "top" : level.popup.dataset.toolbarPosition);
- if (level.popup.dataset.toolbarPosition !== desired) level.view.setToolbarPosition(desired);
- }
-
- function positionPopup(fromLevel = rootLevel, resetToolbar = false) {
- if (fromLevel.retired || fromLevel.popup?.inert || !rootLevel.popup || rootLevel.popup.hidden || !rootLevel.activeCandidate) {
- return;
- }
- if (retireDetachedAncestor(fromLevel)) return;
- if (!anchorConnected(rootLevel.activeCandidate)) {
- hide();
- return;
- }
- highlighter?.refresh();
- if (fromLevel === rootLevel) {
- const position = popupResize?.level === rootLevel ? popupResizePosition() : calculatePopupPosition(
- popupRect(anchorRectFor(rootLevel.activeCandidate)),
- popupViewport(),
- rootLevel.activeCandidate.vertical
- );
- positionToolbar(rootLevel, position.placement, resetToolbar);
- rootLevel.popup.style.left = `${position.left}px`;
- rootLevel.popup.style.top = `${position.top}px`;
- rootLevel.popup.style.width = `${position.width}px`;
- rootLevel.popup.style.height = `${position.height}px`;
- }
- if (levels.length === 1) return;
- const viewport = popupViewport();
- if (viewport.width <= POPUP_PADDING_PX * 2 || viewport.height <= POPUP_PADDING_PX * 2) {
- pruneLevels(1);
- // Finish this placement before a newly unprotected view can reproject.
- window.queueMicrotask(flushDictionaryPresentation);
- return;
- }
- const startDepth = Math.max(1, fromLevel.depth);
- let parentRect = popupRect(levels[startDepth - 1].popup.getBoundingClientRect());
- for (const level of levels.slice(startDepth)) {
- if (level.popup.hidden) break;
- if (!anchorConnected(level.activeCandidate)) {
- hide(level);
- break;
- }
- positionToolbar(level, "beside", resetToolbar);
- const anchorRect = popupRect(anchorRectFor(level.activeCandidate));
- const width = Math.min(sessionPopupSize?.width ?? options.popupWidthPx, viewport.width - POPUP_PADDING_PX * 2);
- const height = Math.min(sessionPopupSize?.height ?? options.popupHeightPx, viewport.height - POPUP_PADDING_PX * 2);
- const rightRoom = viewport.width - parentRect.right - POPUP_GAP_PX - POPUP_PADDING_PX;
- const leftRoom = parentRect.left - POPUP_GAP_PX - POPUP_PADDING_PX;
- const preferredLeft = rightRoom >= width || rightRoom >= leftRoom
- ? parentRect.right + POPUP_GAP_PX
- : parentRect.left - width - POPUP_GAP_PX;
- const left = Math.max(POPUP_PADDING_PX, Math.min(preferredLeft, viewport.width - width - POPUP_PADDING_PX));
- const top = Math.max(POPUP_PADDING_PX, Math.min(anchorRect.top, viewport.height - height - POPUP_PADDING_PX));
- level.popup.style.left = `${left}px`;
- level.popup.style.top = `${top}px`;
- level.popup.style.width = `${width}px`;
- level.popup.style.height = `${height}px`;
- if (popupResize?.level === level) {
- const position = popupResizePosition();
- level.popup.style.left = `${position.left}px`;
- level.popup.style.top = `${position.top}px`;
- level.popup.style.width = `${position.width}px`;
- level.popup.style.height = `${position.height}px`;
- }
- // Each parent box is read once, after its own placement, not once per
- // ancestor for every descendant. Narrow viewports may overlap panes.
- parentRect = popupRect(level.popup.getBoundingClientRect());
- }
- }
-
- function cancelPopupLayout() {
- if (popupLayoutFrame !== null) window.cancelAnimationFrame(popupLayoutFrame);
- popupLayoutFrame = null;
- popupLayouts.clear();
- }
-
- function cancelMasonry(level, layout) {
- if (popupLayouts.get(level) !== layout) return;
- popupLayouts.delete(level);
- if (popupLayouts.size === 0) cancelPopupLayout();
- }
-
- function popupResizePosition() {
- const viewport = popupViewport();
- const left = Math.min(popupResize.left, viewport.width - POPUP_PADDING_PX);
- const top = Math.min(popupResize.top, viewport.height - POPUP_PADDING_PX);
- return { left, top, placement: "beside",
- width: Math.min(sessionPopupSize.width, viewport.width - left - POPUP_PADDING_PX),
- height: Math.min(sessionPopupSize.height, viewport.height - top - POPUP_PADDING_PX) };
- }
-
- function startPopupResize(event, level) {
- if (event.button !== 0 || level.retired) return;
- event.preventDefault();
- const handle = event.currentTarget;
- const rect = popupRect(level.popup.getBoundingClientRect());
- const minimum = popupRect(handle.getBoundingClientRect());
- cancelCandidateScan();
- clearHideTimer();
- clearTransferTimer();
- clearDescendantTimer();
- sessionPopupSize = { width: rect.width, height: rect.height };
- popupResize = { level, handle, pointerId: event.pointerId, ...rect,
- x: event.clientX, y: event.clientY, minimum };
- handle.setPointerCapture(event.pointerId);
- }
-
- function movePopupResize(event) {
- if (!popupResize || event.pointerId !== popupResize.pointerId) return;
- if ((event.buttons & 1) === 0) { stopPopupResize(); return; }
- const drag = popupResize;
- const factor = window.HDPopup.popupCoordinateScale(pageZoom, options.popupScalePercent);
- sessionPopupSize = {
- width: Math.max(drag.minimum.width, drag.width + (event.clientX - drag.x) * factor),
- height: Math.max(drag.minimum.height, drag.height + (event.clientY - drag.y) * factor),
- };
- const position = popupResizePosition();
- sessionPopupSize = { width: position.width, height: position.height };
- positionPopup();
- }
-
- function stopPopupResize() {
- if (!popupResize) return;
- const { handle, pointerId } = popupResize;
- popupResize = null;
- if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
- }
-
- function queueMasonry(level, layout) {
- if (disposed || level.retired || level.popup.hidden || level.popup.inert) return;
- popupLayouts.set(level, layout);
- if (popupLayoutFrame !== null) return;
- // Lay out every dirty pane before placing the chain once in this frame.
- // A width change can queue another observer batch without losing its work.
- popupLayoutFrame = window.requestAnimationFrame(() => {
- const layouts = popupLayouts;
- popupLayouts = new Map();
- popupLayoutFrame = null;
- let owner = null;
- for (const [level, layout] of layouts) {
- if (level.retired || level.popup.hidden || level.popup.inert) continue;
- layout();
- if (!owner || level.depth < owner.depth) owner = level;
- }
- if (owner) positionPopup(owner);
- });
- }
-
- // A screenshot of the page must not contain anything Hachidori drew: the host
- // carries the popup, its image preview and the fallback highlight paint, and the
- // registered highlight is suspended beside it. Two frames give the change time
- // to paint before the capture. Concealment is counted, so one capture cannot
- // reveal the reader while another still owns it, and everything is restored
- // whatever the captures did.
- let concealing = 0;
- let restoreMatchHighlight = null;
- let hostOpacity = "";
- let hostOpacityPriority = "";
- async function concealReader(during) {
- if (host === null) return during();
- // The source-term highlight is painted by the document, not by the shadow
- // tree, so the highlighter stops publishing for as long as this lasts —
- // including for a lookup that settles while the picture is being taken.
- if (concealing === 0) {
- restoreMatchHighlight = highlighter?.suspend() ?? null;
- hostOpacity = host.style.getPropertyValue("opacity");
- hostOpacityPriority = host.style.getPropertyPriority("opacity");
- // Descendants can override inherited visibility, including masonry cards.
- // Opacity composites the whole host without changing its layout.
- host.style.setProperty("opacity", "0", "important");
- }
- concealing += 1;
- try {
- await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve)));
- return await during();
- } finally {
- concealing -= 1;
- if (concealing === 0) {
- host.style.setProperty("opacity", hostOpacity, hostOpacityPriority);
- restoreMatchHighlight?.();
- restoreMatchHighlight = null;
- }
- }
- }
-
- async function readerStyleSheet() {
- const response = await fetch(chrome.runtime.getURL(READER_STYLESHEET));
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- const iconResponse = await fetch(chrome.runtime.getURL("icons.css"));
- if (!iconResponse.ok) throw new Error(`HTTP ${iconResponse.status}`);
- const text = `${await response.text()}\n${await iconResponse.text()}`;
- try {
- const sheet = new CSSStyleSheet();
- sheet.replaceSync(text);
- return { sheet, text };
- } catch {
- // A constructed sheet is preferred (one parse shared by every frame), but
- // a plain '),
- ("missing-alias", "@@@LINK=nowhere"),
- ("ruby", '
漢
'),
- ("食べる", "`1`to eat`2` (ichidan)"),
- ("見出し", "見出し語"),
-]
-
-TEXT_ENTRIES = [
- ("alpha", "first definition"),
- ("beta", "second\ndefinition with newline"),
- ("gamma", "third"),
- ("日本語", "Japanese text \"quoted\""),
-]
-
-PNG = bytes.fromhex(
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
- "0000000d49444154789c63f8ffff3f0005fe02fea72d5a5e0000000049454e44ae426082"
-)
-
-MDD_ENTRIES = [
- ("\\a.spx", b"not really speex"),
- ("\\img\\pic.png", PNG),
- ("\\style.css", ".mdx-red { color: red; }\n".encode("utf-8")),
- ("\\..\\evil.png", b"traversal"),
- # UTF-16LE without a BOM and with non-ASCII text, as some MDD authors save it.
- ("\\utf16.css", ".u16::before { content: \"\u2192\"; }\n".encode("utf-16-le")),
-]
-
-
-def main():
- out = lambda name: os.path.join(HERE, name) # noqa: E731
- sizes = {}
- sizes["v2_utf8_zlib_text.mdx"] = write_mdict(
- out("v2_utf8_zlib_text.mdx"), TEXT_ENTRIES, version="2.0", encoding="UTF-8", compression=2,
- fmt="Text", title="Text Fixture", description="A text fixture & entities")
- sizes["v2_utf8_lzo_html.mdx"] = write_mdict(
- out("v2_utf8_lzo_html.mdx"), HTML_ENTRIES, version="2.0", encoding="UTF-8", compression=1,
- fmt="Html", title="HTML Fixture", stylesheet="1\n\n\n2\n\n\n",
- description="HTML fixture with links, duplicates and a stylesheet")
- sizes["v2_utf16_encrypted2.mdx"] = write_mdict(
- out("v2_utf16_encrypted2.mdx"), TEXT_ENTRIES, version="2.0", encoding="UTF-16", compression=2,
- encrypted=2, fmt="Text", title="UTF-16 Fixture")
- sizes["v1_utf8_stored.mdx"] = write_mdict(
- out("v1_utf8_stored.mdx"), TEXT_ENTRIES, version="1.2", encoding="UTF-8", compression=0,
- fmt="Text", title="V1 Fixture")
- sizes["v2_utf8_lzo_html.mdd"] = write_mdict(
- out("v2_utf8_lzo_html.mdd"), MDD_ENTRIES, version="2.0", compression=2, kind="mdd",
- title="HTML Fixture Media")
- # Malformed inputs. Each must fail with a specific message.
- sizes["bad_truncated.mdx"] = write_mdict(
- out("bad_truncated.mdx"), TEXT_ENTRIES, fmt="Text", corrupt="truncate")
- sizes["bad_adler.mdx"] = write_mdict(
- out("bad_adler.mdx"), TEXT_ENTRIES, fmt="Text", corrupt="record_adler")
- sizes["bad_huge_block.mdx"] = write_mdict(
- out("bad_huge_block.mdx"), TEXT_ENTRIES, fmt="Text", corrupt="huge_block")
- sizes["bad_encrypted1.mdx"] = write_mdict(
- out("bad_encrypted1.mdx"), TEXT_ENTRIES, fmt="Text", encrypted=1)
- sizes["bad_gbk.mdx"] = write_mdict(
- out("bad_gbk.mdx"), TEXT_ENTRIES, fmt="Text", encoding="GBK")
- sizes["bad_v3.mdx"] = write_mdict(
- out("bad_v3.mdx"), TEXT_ENTRIES, fmt="Text", version="3.0")
- for name, size in sizes.items():
- print("%-28s %6d bytes" % (name, size))
-
-
-if __name__ == "__main__":
- main()
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v1_utf8_stored.mdx b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v1_utf8_stored.mdx
deleted file mode 100644
index 07e9a238..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v1_utf8_stored.mdx and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf16_encrypted2.mdx b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf16_encrypted2.mdx
deleted file mode 100644
index 8e4f2011..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf16_encrypted2.mdx and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdd b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdd
deleted file mode 100644
index fb5320cf..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdd and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdx b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdx
deleted file mode 100644
index 61477461..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdx and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_zlib_text.mdx b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_zlib_text.mdx
deleted file mode 100644
index eee40aee..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_zlib_text.mdx and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/gen_fixture.py b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/gen_fixture.py
deleted file mode 100644
index cce6e891..00000000
--- a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/gen_fixture.py
+++ /dev/null
@@ -1,128 +0,0 @@
-#!/usr/bin/env python3
-"""Writes small_dict.zip, the Yomitan dictionary used by import_equivalence_test.
-
-The archive is deterministic (fixed timestamps, fixed entry order, fixed
-compression) so the golden hashes in golden.sha256 stay valid when it is
-regenerated. Run from any directory:
-
- python3 tests/fixtures/yomitan/gen_fixture.py
-
-Every bank kind the importer reads is present, plus a stylesheet and media so
-the equivalence test covers blobs.bin, hash.table, bloom.filter, media.bin,
-media.idx and dict.zstd.
-"""
-import json
-import os
-import zipfile
-import zlib
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-OUT = os.path.join(HERE, "small_dict.zip")
-STAMP = (2020, 1, 1, 0, 0, 0)
-
-# 1x1 PNG, opaque white.
-PNG = bytes.fromhex(
- "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
- "0000000d49444154789c63f8ffff3f0005fe02fea72d5a5e0000000049454e44ae426082"
-)
-
-
-def dumps(value):
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def structured(text, extra=None):
- content = [{"tag": "span", "style": {"fontWeight": "bold"}, "content": text}]
- if extra:
- content.append({"tag": "div", "data": {"kind": "note"}, "content": extra})
- return {"type": "structured-content", "content": {"tag": "div", "content": content}}
-
-
-def term_bank_1():
- terms = []
- # Enough long glossaries to let the zstd trainer produce a dictionary.
- for i in range(48):
- gloss = "語釈 {}: これはテスト用の見出し語の説明文です。".format(i) + "同じ語尾を繰り返します。" * 3
- terms.append(["見出し{}".format(i), "みだし{}".format(i), "n", "", 100 - i, [gloss], i + 1, "P"])
- terms.append(["食べる", "たべる", "v1", "v1", 50, ["to eat", structured("食べる", "Ichidan verb")], 1000, ""])
- terms.append(["食べる", "たべる", "v1", "v1", 40, ["to eat"], 1000, ""]) # duplicate glossary text
- terms.append(["猫", "ねこ", "n", "", 10, [{"type": "image", "path": "img/neko.png", "width": 1, "height": 1}], 1001, ""])
- terms.append(["日本", "にほん", None, "", 0, ["Japan"], 1002, "P"])
- terms.append(["日本", "にっぽん", "n", "", 0, ["Japan"], 1002, ""])
- terms.append(["同形", "", "n", "", 0, ["reading omitted"], 1003, ""])
- return terms
-
-
-def term_bank_2():
- return [
- ["走る", "はしる", "v5", "v5", 5, ["to run"], 2000, ""],
- ["走る", "はしる", "v5", "v5", 5, ["to run"], 2000, ""], # exact duplicate entry
- ["\"quoted\" [brackets] \\backslash", "quoted", "", "", 0, ["escapes \"\\ 【】"], 2001, ""],
- ]
-
-
-def term_meta_bank_1():
- return [
- ["食べる", "freq", 12],
- ["食べる", "freq", {"reading": "たべる", "frequency": {"value": 12, "displayValue": "12㋕"}}],
- ["猫", "freq", {"value": 3, "displayValue": "3"}],
- ["食べる", "pitch", {"reading": "たべる", "pitches": [{"position": 2}, {"position": 0, "nasal": [1], "devoice": []}]}],
- ["日本", "ipa", {"reading": "にほん", "transcriptions": [{"ipa": "ɲihoɰ̃"}]}],
- ]
-
-
-def kanji_bank_1():
- return [
- ["食", "ショク ジキ", "く.う た.べる", "jouyou", ["eat", "food"], {"grade": "2", "strokes": "9"}],
- ["猫", "ビョウ", "ねこ", "jouyou", ["cat"], {"grade": "8"}],
- ]
-
-
-def kanji_meta_bank_1():
- return [["食", "freq", 7], ["猫", "freq", {"value": 9, "displayValue": "9"}]]
-
-
-def tag_bank_1():
- return [["n", "partOfSpeech", -3, "noun", 0], ["v1", "partOfSpeech", -3, "Ichidan verb", 0], ["P", "popular", -10, "common", 10]]
-
-
-def main():
- entries = [
- ("index.json", dumps({
- "title": "Hoshidicts Fixture",
- "revision": "fixture-1",
- "format": 3,
- "sequenced": True,
- "author": "hoshidicts tests",
- "description": "Deterministic fixture for the import equivalence test.",
- "sourceLanguage": "ja",
- "targetLanguage": "en",
- "frequencyMode": "rank-based",
- }).encode()),
- ("styles.css", b".mdict-yomitan-content { color: #333; }\n"),
- ("term_bank_1.json", dumps(term_bank_1()).encode()),
- ("term_bank_2.json", dumps(term_bank_2()).encode()),
- ("term_meta_bank_1.json", dumps(term_meta_bank_1()).encode()),
- ("kanji_bank_1.json", dumps(kanji_bank_1()).encode()),
- ("kanji_meta_bank_1.json", dumps(kanji_meta_bank_1()).encode()),
- ("tag_bank_1.json", dumps(tag_bank_1()).encode()),
- ("img/", b""),
- ("img/neko.png", PNG),
- ("audio/neko.txt", b"stored, not deflated"),
- ]
- with zipfile.ZipFile(OUT, "w") as zf:
- for name, data in entries:
- info = zipfile.ZipInfo(name, date_time=STAMP)
- info.create_system = 3
- if name.endswith("/"):
- info.external_attr = 0o40755 << 16
- zf.writestr(info, b"")
- continue
- info.external_attr = 0o644 << 16
- info.compress_type = zipfile.ZIP_STORED if name.startswith("audio/") else zipfile.ZIP_DEFLATED
- zf.writestr(info, data, compresslevel=9)
- print(OUT, os.path.getsize(OUT), "bytes")
-
-
-if __name__ == "__main__":
- main()
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/golden.sha256 b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/golden.sha256
deleted file mode 100644
index 2c101cb8..00000000
--- a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/golden.sha256
+++ /dev/null
@@ -1,11 +0,0 @@
-# SHA-256 of every file the pre-DictionarySource importer (bee-san/hoshidicts main @ fa833f9) wrote for small_dict.zip.
-# index.json is hashed with "importDate": replaced by "importDate":0. Regenerate only when the output format changes on purpose.
-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 .hoshidicts_6
-552b6e672c3b8e64d12e7eb43182c23234eaaea506bbd1c19b7d447e523840ad blobs.bin
-4bb3bf0755e28ae676f98c575174a62054651cc0004173bb03bf0d8b5a875c5c bloom.filter
-91de707c8eb26bff94240513c03a41b7f9995c67f5392a621fb3151aae429ef3 dict.zstd
-517a21ea1d33fa05bdc9e4655c3c8d171ded15002f297843cb17f1e9db3f045a hash.table
-b27413b159ab02a83b2f820087db41ffaf81ec2f7fb97c20493b41a1fcba3f5f media.bin
-56879507ea4d0e870990d88b906e754eaa34f47b4e634842a6f4499290f3fd2c media.idx
-91b1a6c60e52df5da7db5a1ff10a2d7d2b7d25a282b2142206f45e3cc63bb376 index.json
-4f6e1b0bf6ed1f02d3eb24617e9693284ea0607f30e92cab693e0583675bb9ca scan.idx
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/small_dict.zip b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/small_dict.zip
deleted file mode 100644
index 6117eb9c..00000000
Binary files a/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/small_dict.zip and /dev/null differ
diff --git a/vendor/hachidori/third_party/hoshidicts/tests/html_to_structured_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/html_to_structured_test.cpp
deleted file mode 100644
index 2ead042d..00000000
--- a/vendor/hachidori/third_party/hoshidicts/tests/html_to_structured_test.cpp
+++ /dev/null
@@ -1,229 +0,0 @@
-// Unit tests for mdict::convert_html and its helpers. Expectations follow
-// manabitan's mdx-converter.js behaviour; the JSON text is compared exactly
-// because the emitter is deterministic, and each glossary is also parsed with
-// glaze to prove it is well-formed JSON.
-#include
-
-#include
-#include
-#include
-
-#include "mdict/html_to_structured.hpp"
-
-namespace {
-int failures = 0;
-
-void check(bool ok, const std::string& what) {
- if (!ok) {
- std::printf("FAIL %s\n", what.c_str());
- failures++;
- }
-}
-
-void check_eq(const std::string& actual, const std::string& expected, const std::string& what) {
- if (actual != expected) {
- std::printf("FAIL %s\n expected: %s\n actual: %s\n", what.c_str(), expected.c_str(), actual.c_str());
- failures++;
- }
-}
-
-const std::string root_open =
- R"({"type":"structured-content","content":{"tag":"div","data":{"tag":"div","class":"mdict-yomitan-content"},"content":[)";
-const std::string root_close = "]}}";
-
-// Converts and returns only the root content array body.
-std::string body(const std::string& html, const mdict::ConvertOptions& options = {}) {
- const mdict::ConvertResult result = mdict::convert_html(html, options);
- glz::generic parsed;
- if (auto error = glz::read_json(parsed, result.glossary_json)) {
- check(false, "glossary is not valid JSON: " + result.glossary_json);
- }
- check(result.glossary_json.starts_with(root_open) && result.glossary_json.ends_with(root_close),
- "root wrapper for " + html);
- return result.glossary_json.substr(root_open.size(),
- result.glossary_json.size() - root_open.size() - root_close.size());
-}
-
-void test_basic_formatting() {
- check_eq(body("bold plain it"),
- R"({"tag":"span","style":{"fontWeight":"bold"},"content":["bold"]}," plain ",)"
- R"({"tag":"span","style":{"fontStyle":"italic"},"content":["it"]})",
- "b/i become styled spans");
- check_eq(body("
Head
para
"),
- R"({"tag":"div","style":{"fontWeight":"bold","fontSize":"2em"},"content":["Head"]},)"
- R"({"tag":"div","content":["para"]})",
- "h1/p become divs");
- check_eq(body("abc"),
- R"("ab",{"tag":"span","style":{"textDecorationLine":"underline"},"content":["c"]})",
- "unsupported element unwrapped and adjacent text merged");
- check_eq(body("x y"), R"("x",{"tag":"br"},"y")", "br has no content");
- check_eq(body("& <b> "), "\"& \xc2\xa0\"", "entities decoded");
- check_eq(body(""), "", "empty definition");
- check_eq(body("t"), R"("t")", "script and comments dropped");
-}
-
-void test_data_and_attributes() {
- check_eq(body(R"(s)"),
- R"({"tag":"span","data":{"tag":"span","class":"a b","id":"x"},"lang":"ja","title":"T","content":["s"]})",
- "class collapsed, id trimmed, lang and title kept");
- check_eq(body(R"(
c
)"), R"("c")", "stray td is dropped, its text kept");
- check_eq(body(R"(