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 pink and lilac hummingbird logo -

- -

Hachidori

- -

The fastest, most feature rich Japanese dictionary app in the world

- -

- GPL-3.0-or-later license - Chrome 128 or newer - Dictionary engine runs locally - SonarQube Cloud quality gate - GitHub stars -

- -

- Install · - Benchmarks · - Architecture · - Extension · - Sharing · - Chrome Web Store guide · - Privacy · - Contributing -

- -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. - -

- Animated walkthrough of Hachidori's first-run setup, dictionary installation, Anki detection, and Japanese lookup -

- -# Blazing Fast - -Hachidori is 83 times faster than the worlds most popular Japanese dictionary app at importing dictionaries. - -ChatGPT Image Sep 9, 2026, 09_18_59 AM - -See [the measured results](docs/browser-performance.md). - - -# Media mining - -Screenshot 2026-09-09 at 11 26 31 - -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. - -

- Animated demonstration of adding and viewing a custom dictionary definition in Hachidori -

- -# 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. - -

- Animated demonstration of Hachidori blurring repeated lookups before revealing their definitions -

- -# Sharing - -Set up Hachidori once and use that setup from every other Hachidori, in GameSentenceMiner, another browser or another computer. Same dictionaries and settings used across multiple Hachidoris. - -Install [Hachidori Relay for Anki](https://github.com/bee-san/hachidori-anki) -from **Settings → Sharing → Download the Anki add-on**, then follow the -[sharing guide](docs/sharing.md) to link your other browsers. - -Screenshot 2026-09-14 at 14 19 41 - - -# Experimental features - -**Settings → Advanced → Experimental features** switches on work that is still -changing and may be removed: - -- **Media mining** — the screen and audio capture above. -- **Long dictionary entries** — find entries longer than the scan length - (proverbs, titles) without scanning further on every hover. -- **MDX dictionaries** — import MDict `.mdx` dictionaries with their `.mdd` - resource files from **Add dictionaries**, next to Yomitan ZIPs. Choose the - `.mdx` and its `.mdd` files together. - - -# Opinionated - -Hachidori is an opinionated program. If it does not benefit me, the creator, personally than I will not add that feature. - -I do this because I am a pretty average learner, and if I make this tool great for myself than I am making it great for the average Japanese learner. - -# AI Usage - -This program was created with the assistance of AI. I used GPT 5.6 Ultra, and then GPT 6.0 Astra Ultra exclusively. When Codex goes down, I use Fable 5.1 with ultrathink and ultracode. - -I have reviewed all plans, I set the direction of how this program works. Large parts of the program such as the actual dictionary core are hand-written. - -I also have personally been using this for months, and as I am the main user of this program I find bugs pretty often which I fix. - -The assets used are AI generated. If you are an artist and want to contribute to open source, please feel free to make a real logo or a visual novel style background. - -## Credits - -The logo pack and six visual novel backgrounds were supplied by bee-san. See the -[asset ownership and publishing record](docs/asset-rights.md) for the original -assets and their copyright declaration. - -Hachidori is powered by [hoshidicts](https://github.com/Manhhao/hoshidicts) by Manhhao. Its popup renderer, structured-content renderer, furigana segmentation, and CSS are ported from [GameSentenceMiner PR #549](https://github.com/bpwhelan/GameSentenceMiner/pull/549), which adapts [Hoshi Reader](https://github.com/Manhhao/Hoshi-Reader) and [Yomitan](https://github.com/yomidevs/yomitan). See the full [renderer attribution](extension/render/ATTRIBUTION.md). - -## License - -Hachidori is available under [GPL-3.0-or-later](LICENSE), matching hoshidicts and the ported GameSentenceMiner code. diff --git a/vendor/hachidori/docs/asset-rights.md b/vendor/hachidori/docs/asset-rights.md deleted file mode 100644 index 9ab88f65..00000000 --- a/vendor/hachidori/docs/asset-rights.md +++ /dev/null @@ -1,111 +0,0 @@ -# Asset ownership and publishing record - -This record identifies the artwork supplied by Hachidori's repository owner, -**bee-san**, for the extension, documentation and Chrome Web Store materials. -The source files below were supplied on **September 8, 2026**. - -## Visual novel backgrounds - -**Copyright © 2026 bee-san.** The owner explicitly stated “i own the copyright” -and directed Hachidori to use the image made at 6:29am, with that ownership -recorded for Chrome publishing. This is the owner's declaration and -authorization to include the artwork in Hachidori and its publishing materials. - -- Original filename: `ChatGPT Image Sep 8, 2026, 06_29_20 AM.png`. -- Selected image time: **September 8, 2026, 06:29:20am**, Europe/London. -- Repository file: [`extension/assets/preview-background.webp`](../extension/assets/preview-background.webp). -- Supplied format and dimensions: PNG, **1672 × 941**, **2,154,041 bytes**. -- Supplied SHA-256: `84c3fd0ff5803596a5991b52a8ce2f3d2da5083393197112425f5f340bdca361`. -- Usage: first-run practice and the Design preview, plus screenshots of those - surfaces in documentation and store materials. - -The supplied artwork bakes its own illustrated dialogue panel into the bottom of -the frame; Hachidori never shows that panel, and places selectable Japanese text -over the live scene above it. This replaces the earlier GSM preview background in -the packaged extension. - -The owner subsequently supplied the five additional scenes below and requested -their use alongside the original in first-run practice and the Design preview, -with a random starting scene and an arrow to cycle through them. This extends -the record to those owner-supplied files and their authorized use in Hachidori -and its publishing screenshots. Every supplied image is a **1672 × 941 PNG**. - -| Repository file | Original filename | Supplied bytes | -| --- | --- | ---: | -| [`preview-background-2.webp`](../extension/assets/preview-background-2.webp) | `ChatGPT Image Sep 8, 2026, 07_16_27 AM.png` | 2,306,454 | -| [`preview-background-3.webp`](../extension/assets/preview-background-3.webp) | `ChatGPT Image Sep 8, 2026, 07_16_31 AM.png` | 2,172,556 | -| [`preview-background-4.webp`](../extension/assets/preview-background-4.webp) | `ChatGPT Image Sep 8, 2026, 07_16_38 AM.png` | 2,508,746 | -| [`preview-background-5.webp`](../extension/assets/preview-background-5.webp) | `ChatGPT Image Sep 8, 2026, 07_16_43 AM.png` | 2,306,899 | -| [`preview-background-6.webp`](../extension/assets/preview-background-6.webp) | `ChatGPT Image Sep 8, 2026, 07_18_10 AM.png` | 2,314,792 | - -SHA-256 checksums of the supplied PNG files: - -```text -ac8c161853b335b7c28f5e3d68cf7bb11862072974dae805642af6258e6a608e preview-background-2.png -95f23c4cd334a93f3c560dccf40ef7b40c7ea3d1188af5eb97a371b951e3d97f preview-background-3.png -579182b5dd57fa306ab569238f8812ee5b7b214a1042fb1836596d9528c793ec preview-background-4.png -35b57369c49ec315ac1c9b0d4aa9c732e86ab928f40ac68a1097f6dd8af6f692 preview-background-5.png -6556de21bdd8fc3f8faced963b8936be85aaa443fdf296e746b259c3c233ea14 preview-background-6.png -``` - -## Packaged form of the backgrounds - -The extension only ever displays the top **1672 × 672** of each frame, so that -crop is what Hachidori packages, encoded as WebP at quality 82: 970,930 bytes for -all six, where the PNGs cost 13,413,193. - -All six supplied files stay retrievable from this repository's history at commit -`4abbcd3`, where their sizes and checksums are the ones recorded above. The PNGs -the WebP files replace were those same images after ImgBot's lossless -re-compression in `23f138c`, which is why the replaced bytes and checksums differ -from the supplied ones while every pixel still matched. - -| Packaged file | Bytes | -| --- | ---: | -| `preview-background.webp` | 147,244 | -| `preview-background-2.webp` | 153,232 | -| `preview-background-3.webp` | 145,796 | -| `preview-background-4.webp` | 191,904 | -| `preview-background-5.webp` | 178,482 | -| `preview-background-6.webp` | 154,272 | - -```text -976b9f69104c66cbde4bc11e554ba3a1c9d1688493593621bba121d64d0066ba preview-background.webp -d787d3b3a0025e025eeb31b5158b28787465293176cce41456adbdc5ab2f24f8 preview-background-2.webp -52c66542fca697f226607cc4419c6ea333cb959663a327a3c1d73643f08eb449 preview-background-3.webp -0f996a30dd3ae0657d14d4021e4a3f028cfd57875b37cc16b19baf97f2690527 preview-background-4.webp -894588bd6b898c983d4d60abcaa2b08862a53df081d8f4ba2c0f898a08556f49 preview-background-5.webp -2b715d36fb5ad2be6810ffaf322a8700083e8a8173702bffb9ba477c350caf17 preview-background-6.webp -``` - -## Hachidori logo pack - -The owner supplied `hachidori-logo-pack.zip` and requested its use for the README -and other Hachidori branding. Archive SHA-256: -`b34398f7eb27eb136247445b20a48d69606366c0f8e1f929280f670519228e21`. - -The following files are copied unchanged from that pack: - -| Project asset | Purpose | -| --- | --- | -| [`docs/assets/hachidori.png`](assets/hachidori.png) | Transparent 1024 × 1024 hummingbird mark in the README | -| [`docs/assets/hachidori-icon.svg`](assets/hachidori-icon.svg) | Original vector mark | -| [`docs/assets/hachidori-logo.svg`](assets/hachidori-logo.svg) | Stacked logo with outlined lettering | -| [`docs/assets/hachidori-wordmark.svg`](assets/hachidori-wordmark.svg) | Horizontal logo with outlined lettering | -| [`docs/assets/hachidori-app-icon.svg`](assets/hachidori-app-icon.svg) | App icon source | -| [`extension/icons/hachidori-16.png`](../extension/icons/hachidori-16.png), [`32`](../extension/icons/hachidori-32.png), [`48`](../extension/icons/hachidori-48.png), [`128`](../extension/icons/hachidori-128.png) | Supplied icon sizes used by Chrome and the Settings/startup headers | - -The SVG lettering is outlined and needs no external font. The pack's README -describes these variants; it contains no separate license terms. This record -documents the owner's instruction to use the supplied branding for Hachidori. - -## Publishing reference - -Use this record and the linked repository assets when identifying the artwork -included in Hachidori's Chrome Web Store package, screenshots and branding. -The packaged backgrounds also carry a short -[ownership notice](../extension/assets/ATTRIBUTION.md). - -Hachidori's software license remains [GPL-3.0-or-later](../LICENSE). Attribution -for the imported dictionary renderer and other upstream code is maintained -separately in [renderer attribution](../extension/render/ATTRIBUTION.md). diff --git a/vendor/hachidori/docs/source-build.md b/vendor/hachidori/docs/source-build.md deleted file mode 100644 index 1cd81176..00000000 --- a/vendor/hachidori/docs/source-build.md +++ /dev/null @@ -1,120 +0,0 @@ -# Building a distributed source archive - -Each release's `hachidori---source.zip` includes Hachidori's -tracked files, the complete recursive Hoshidicts submodules, and the pinned -libavif, libaom and unminified zip.js sources. It contains no Git metadata and -does not require access to a private repository. `SOURCE_REVISIONS.json` -records the repository commits and downloaded dependency checksums. - -The Chrome upload ZIP contains `LICENSE`, the dependency notices, -`SOURCE.txt` and `SOURCE.json`. The source reference names the accompanying -archive and its SHA-256. Publish that exact source archive at a public download -location and put the location in the store listing before distributing the -extension. A private GitHub repository link is not a substitute for that -download. - -## Load the JavaScript source - -Extract the archive. Open `chrome://extensions`, enable **Developer mode**, -choose **Load unpacked**, and select its `extension/` directory. Committed -Wasm files are included, so JavaScript development needs no compiler. - -## Rebuild the dictionary engine - -Install [Emscripten](https://emscripten.org/docs/getting_started/downloads.html) -with C++23 support, CMake 3.31 or newer, and a native build tool. Activate the -SDK environment so `emcmake`, `emcc`, and `em++` are on `PATH`. Run these commands -from the extracted archive's top-level directory: - -```sh -emcmake cmake -S wasm -B wasm/build -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=ON -DHACHIDORI_WASMFS=ON -cmake --build wasm/build --parallel -emcmake cmake -S wasm -B wasm/build-idbfs -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=ON -DHACHIDORI_WASMFS=OFF -cmake --build wasm/build-idbfs --parallel -emcmake cmake -S wasm -B wasm/build-fallback -DCMAKE_BUILD_TYPE=Release -DHACHIDORI_PTHREADS=OFF -cmake --build wasm/build-fallback --parallel -cp wasm/build/hoshidicts-threaded.mjs wasm/build/hoshidicts-threaded.wasm extension/vendor/ -cp wasm/build-idbfs/hoshidicts-threaded-idbfs.mjs wasm/build-idbfs/hoshidicts-threaded-idbfs.wasm extension/vendor/ -cp wasm/build-fallback/hoshidicts.mjs wasm/build-fallback/hoshidicts.wasm extension/vendor/ -``` - -The source archive already includes each CMake dependency under -`third_party/hoshidicts/external/`; no submodule checkout is needed. - -## Rebuild the AVIF encoder - -Use the source archive's bundled dependencies to avoid fetching them during -configuration: - -```sh -emcmake cmake -S wasm/avif -B wasm/avif/build -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DFETCHCONTENT_SOURCE_DIR_LIBAVIF="$PWD/third_party/store-sources/libavif" \ - -DFETCHCONTENT_SOURCE_DIR_LIBAOM="$PWD/third_party/store-sources/libaom" -cmake --build wasm/avif/build --parallel -cp wasm/avif/build/avif-encoder.mjs wasm/avif/build/avif-encoder.wasm extension/vendor/ -``` - -The sources are libavif 1.3.0 at -`1aadfad932c98c069a1204261b1856f81f3bc199` and libaom 3.12.1 at -`10aece4157eb79315da205f39e19bf6ab3ee30d0`. Both libavif's small libyuv subset -and libaom's internal dependencies are present in those archives. Other AVIF -codecs and external libyuv are disabled by `wasm/avif/CMakeLists.txt`. - -Packaging pins libaom's official release archive. Its source files and executable -modes match that commit, while the Gitiles archive endpoint rewrites timestamps -on each request and cannot provide a stable download checksum. - -## zip.js and validation - -`third_party/store-sources/zipjs/lib/` contains zip.js 2.11.2's editable source; -its upstream `README.md` and `package.json` describe the library. The release -uses its existing `dist/zip-core-external.min.js`, copied unchanged to -`extension/vendor/zip.js`. No npm install or JavaScript build is needed to -restore that shipped file: - -```sh -cp third_party/store-sources/zipjs/dist/zip-core-external.min.js extension/vendor/zip.js -node test/make-fixture.mjs -node test/node-smoke.mjs -node test/extension-smoke.mjs -``` - -See [the test guide](../test/README.md) for the browser suite and its external -test dependencies. The packaging command verifies archive checksums and file -integrity; it does not compile or run these runtime suites. - -These instructions preserve the source revisions and build settings. The -historical committed Wasm files do not record the exact compiler version, so -they do not establish byte-for-byte reproduction of those binaries. Record -`emcc --version`, `cmake --version`, build commands and test results whenever -publishing newly built Wasm files. - -## Produce a release pair from a Git checkout - -Commit the intended release changes and initialize recursive submodules. With -Python 3.9 or newer and Git installed: - -```sh -git submodule update --init --recursive -python3 scripts/package-store.py --output-dir /tmp/hachidori-store -``` - -The command refuses a dirty checkout, reads only committed Git objects, and -writes the upload ZIP, source ZIP and `SHA256SUMS.txt` outside the repository. -Its first run downloads only checksum-pinned dependency sources. Pass -`--cache-dir /path/to/cache` to reuse them across machines or offline runs. -ZIP paths, timestamps, order and permissions are normalized, giving identical -bytes when repeated with the same commit and Python/zlib version. ZIP integrity -is checked before checksums are written. Load unpacked from an extracted Chrome -ZIP to check the exact staged runtime before uploading it. - -CI runs this packaging command and verifies the checksums for every release -candidate. The **Release** workflow supports package-only manual runs by default. -Enabling **Publish** for a manual run requires an existing bare -`` release tag at the selected commit; the workflow uploads -its assets and submits the Chrome package. Pushing `` keeps -the automatic release path. Both paths publish only after the version, -minimum/current Chrome pins, archive integrity, and checksums pass. Chrome Web -Store automation requires the service-account secret and the publisher and -extension repository variables documented in -[the publishing guide](chrome-web-store.md#publish-step-by-step). diff --git a/vendor/hachidori/extension/README.md b/vendor/hachidori/extension/README.md deleted file mode 100644 index 02f30c72..00000000 --- a/vendor/hachidori/extension/README.md +++ /dev/null @@ -1,186 +0,0 @@ - - -# The Hachidori extension - -This folder is the shared extension source and the Manifest V3 package exactly -as Chrome 128 or newer loads it, with no build step. The JavaScript is plain ES -modules and classic scripts, the dictionary engine is committed WebAssembly -under `vendor/`, and everything runs inside the browser. To run it in Chrome -from a checkout, open `chrome://extensions`, turn on **Developer mode**, choose -**Load unpacked** and select this folder. `scripts/package-store.py` zips this -same folder, with the licence files, for the Chrome Web Store. - -`manifest.firefox.json` is the reviewed Firefox MV2 manifest. The same -packager writes the Firefox XPI from these sources minus the Chrome-only files -listed in `scripts/firefox-package.json`, with that manifest in place of -`manifest.json`; `scripts/prepare-firefox.mjs` stages the same layout in an -ignored directory for lint and the Firefox smoke test. See the -[Firefox guide](../docs/firefox.md) to build and temporarily install the -unsigned XPI. - -[The architecture guide](../docs/architecture.md) explains how the pieces -work together and lists every runtime message and stored key. This page says -where things are. - -## Entry points - -Drag a lookup popup's bottom-right corner to resize it. The size is shared by -subsequent and nested lookups in that page, including after closing and reopening -the popup. Reloading or navigating the page (or restarting the browser) starts a -new reading session with the saved Design dimensions. Dragging does not change -those saved settings or other tabs. - -The popup action row is one non-wrapping keyboard and visual group: a nested -Close or Back control first, then Anki, pronunciation, personal-dictionary -edit, and custom buttons in saved order. A custom button opens a URL template -or mines with a chosen Anki Template. Actions share a 36-pixel height and a -5-pixel gap. At narrow popup widths the whole action row scrolls horizontally -instead of wrapping, clipping, or overlapping controls. Browser mode opens link -buttons in a Chrome tab; overlay mode asks its embedding host to open the same -validated URL in the system browser. - -`manifest.json` names them. - -| File | Runs as | Role | -| --- | --- | --- | -| `background.js` | the service worker | Routes every runtime message and owns everything in `chrome.storage.local`: dictionary metadata, options, the personal dictionary, update schedules, lookup counts, automatic-backup metadata, first-run and sharing state. It also owns the alarms, the Anki gateway and the sharing host and client. It holds no engine state, so Chrome may stop it whenever it is idle. | -| `firefox-background.html`, `firefox-background.js` | Firefox’s persistent MV2 background page | Loads the shared background module and hosts `offscreen.html` in one authenticated hidden iframe so the engine remains warm. | -| `content.js`, with the classic scripts listed under `content_scripts` | every web page | Scans the Japanese text near the pointer, renders the popup in a closed shadow root through `render/popup.js` and `render/glossary.js`, and adds the popup's Anki and pronunciation controls (`anki-content.js`, `audio-content.js`). Chrome also injects `capture-content.js`; Firefox does not. `content.css` is the only style the page itself receives: the source highlight. | -| `offscreen.html`, `offscreen.js` | Chrome’s offscreen document or Firefox’s hidden background iframe | Owns the dictionary engine. `engine-worker.js` runs the pthread build with direct OPFS once `opfs-capability-worker.js` has proved the browser can, `engine-worker-idbfs.js` runs the pthread build on IDBFS when the browser has shared memory but no OPFS access handles (Electron), both through `engine-worker-runtime.js`; `engine-service.js` is also the single-thread IDBFS fallback. Pronunciation, Anki and the first-run installer load here on demand. Chrome also hosts media capture here. | -| `settings.html`, `settings.js` | the options page | Dictionaries, groups, updates, the personal dictionary, Reading, Design, pronunciation, Anki, keybinds, backup and sharing, with media capture where supported and global search. The larger sections have their own `*-settings.js` controller; `design-preview.html` is the live preview inside Design. | -| `startup.html`, `startup.js` | a tab opened once after install | First-run setup: recommended dictionaries, Anki detection, a practice lookup, and the offer to use a Hachidori that another browser on this computer already shares. Overlay mode skips it. | -| `toolbar.html`, `toolbar.js` | the toolbar button's popup | Turns lookups on and off, shows the sharing state and opens Settings. Chrome also exposes the recording action here. | -| `capture.html`, `capture.js` | a Chrome-only tab opened from the toolbar or Settings | Controls media capture. The recorder itself, `capture-host.js`, runs in the offscreen document and keeps going when this tab closes. Firefox does not expose this entry point. | - -`overlay-mode.js`, its `browser-api.js` dependency, `render/reader.css` and -`icons.css` are the only files web pages may fetch -(`web_accessible_resources`). The popup and its Anki controls load the two -stylesheets; overlay hosts use the shared mode contract. - -## Modules by feature - -Files share a prefix with the feature they belong to. A rule that more than -one context needs lives in a module with no Chrome dependency, so Settings, -the service worker and both engine runtimes run the same code. - -- **Dictionaries and stored state.** `reader-options.js` is the one stored - options view every context reads. `dictionary-group-state.js` and - `dictionary-groups.js` hold the group rules and their Settings controls, - `dictionary-name-drafts.js` the autosaved names, `dictionary-progress.js` - the import progress. `managed-dictionary-source.js` and - `recommended-dictionaries.js` define the trusted update sources and the - starter set; `custom-dictionary.js` the personal dictionary's source format - and archive; `setup-state.js` the first-run stages and initial selections; - `setup-installer.js` the offscreen recommended installer, observed from startup - and Settings by `recommended-install-client.js`. `json-value.js` and `response-limits.js` - are the comparison and size rules the transaction boundaries share. -- **Lookup statistics.** `lookup-stats-identity.js`, a classic script so the - content script can use it, and `lookup-stats.js`. -- **Anki.** `anki.js` is the AnkiConnect gateway and `anki-setup.js` - recognises an existing mining setup. `anki-templates.js`, `anki-values.js`, - `anki-glossary.js`, `anki-pitch.js`, `anki-resources.js` and `anki-audio.js` build the note - fields and media. Stored Anki Templates group each destination, note type, - field mapping and duplicate policy; the first powers the built-in action and - custom Anki buttons select the others by stable ID. Settings edits every - field mapping through an accessible marker combobox while retaining the - mapping string exactly. `anki-duplicates.js` and - `anki-enrichment.js` handle a - note that already exists; `anki-digest.js` hashes media. - `anki-client-media.js` validates final screenshot, capture and browser-speech - media crossing a linked-browser boundary. `anki-mining.js` and - `anki-worker.js` are the mining service in the - service worker. `anki-index.js` and `anki-index-cache.js` provide the shared - scoped duplicate and maturity index, including cache-only View readiness and - click-time live ID repair. `anki-offscreen.js` launches - `anki-index-worker.js` for complete refreshes without moving note fields - through the service worker. -- **Pronunciation.** `audio-sources.js`, `audio-repository.js`, - `audio-cache.js` and `audio-player.js` fetch, keep and play audio in the - offscreen document (`audio-offscreen.js`); `speech.js` wraps the browser's - text-to-speech. -- **Media capture.** `capture-host.js` is the offscreen recorder. - `capture-session.js`, `capture-buffer.js`, `capture-timeline.js` and - `capture-speech.js` are its bounded buffers, occurrence timeline and speech - detection. `capture-audio-worklet.js`, `capture-frame-client.js` with - `capture-frame-worker.js`, and `capture-encoder-client.js` with - `capture-encoder-worker.js` move audio sampling, frame grabbing and animated - AVIF encoding (`avif-sequence.js`) off the main thread. - `texthooker-protocol.js` parses the text a texthooker sends. -- **Backup.** `backup-archive.js` is the manual ZIP format, `backup-state.js` - the shared snapshot rules, `backup-automatic.js` the two-record daily - retention, cadence and age rules, `backup-downloads.js` the pending downloads, - and `backup-settings.js` the manual and automatic restore controls. -- **Sharing.** `sharing-protocol.js` is the wire contract both sides import; - `sharing-host.js` and `sharing-client.js` are the two roles in the service - worker; `sharing-settings.js` is the Settings section. `anki-addon.js` pins - and downloads the compatible `.ankiaddon` release from - [hachidori-anki](https://github.com/bee-san/hachidori-anki), which owns the - Python relay, its tests, and packaging. -- **Pages.** `settings-search.js` and `settings-dom.js` serve Settings; - `experimental-settings.js` renders the Advanced → Experimental features - switches from the registry in `reader-options.js`; - `keybind-settings.js`, `custom-button-settings.js` and `external-links.js` - the keybinds and custom buttons in the popup; `local-file-access.js` the - notice about Chrome's *Allow access to file URLs* permission; - `startup-practice.js` the practice step. `visual-novel.js` and - `visual-novel.css` draw the background scenes behind the startup page and - the Design preview from the images in `assets/` (see - `assets/ATTRIBUTION.md`); `design-preview.js` renders the preview from - `sample-meal.svg` and local sample data. -- **Renderer.** `render/` is the popup renderer ported from GameSentenceMiner, - which adapts Hoshi Reader and Yomitan; `render/ATTRIBUTION.md` records what - came from where. -- **Overlay mode.** `overlay-mode.js` is the one switch a host such as the - GameSentenceMiner overlay flips in its copy. It also defines the shared - host-capability policy used by Settings, the toolbar, the reader and the - service worker; see - [overlay mode](../docs/overlay-mode.md). -- **Vendored code.** `vendor/hoshidicts-threaded.{mjs,wasm}`, - `vendor/hoshidicts-threaded-idbfs.{mjs,wasm}` and - `vendor/hoshidicts.{mjs,wasm}` are the three builds of the hoshidicts engine - from `wasm/build.sh`, `vendor/avif-encoder.{mjs,wasm}` the AVIF encoder - from `wasm/avif/`, and `vendor/zip.js` the pinned zip.js runtime. They are - committed build output: update them with their source change and otherwise - leave them alone. -- `icons/` holds the extension's icons. - -## Conventions - -- Every script, stylesheet and page starts with an - `SPDX-License-Identifier: GPL-3.0-or-later` line; the files under `render/` - also keep their upstream copyright lines. -- A rule the content script needs as well as the module contexts lives in a - classic script that publishes one `globalThis.HD…` object - (`HDReaderOptions`, `HDLookupStats`, `HDDictionaryGroups`, …); modules - import such a file for its side effect. -- Runtime messages are objects with a `target` and an `hd_*` `type`, and - they carry explicit ids, revisions or generations so a stale reply fails - closed. Stored values are revisioned and written only by the service - worker; a page edits them by compare-and-set. The offscreen document never - touches `chrome.storage` itself. -- Nothing here is generated except `vendor/`. There is no bundler, - transpiler or minifier: what is committed is what ships. - -## Checking a change - -```sh -node test/make-fixture.mjs # writes the dictionary fixtures once -node test/extension-smoke.mjs # this folder's JavaScript against the real engine, in Node -node test/chrome-e2e.mjs # this folder loaded unpacked into a real Chrome -``` - -[The test guide](../test/README.md) says what each suite proves and how to -install the browser and jsdom they need; the validation list in -[AGENTS.md](../AGENTS.md) says which checks each kind of change requires. -Sharing changes have their own suites, listed in [sharing](../docs/sharing.md). - -## More - -- [Privacy](../docs/privacy.md): what leaves the browser, and when. -- [Sharing](../docs/sharing.md), [overlay mode](../docs/overlay-mode.md), - [media capture](../docs/media-capture.md), - [the backup format](../docs/backup-format.md), - [update schedules](../docs/update-schedules.md) and - [lookup statistics](../docs/lookup-statistics.md) describe those features. -- [Building a source archive](../docs/source-build.md) covers `wasm/` and - the `third_party/hoshidicts` submodule behind `vendor/`. diff --git a/vendor/hachidori/extension/anki-addon.js b/vendor/hachidori/extension/anki-addon.js deleted file mode 100644 index 5501325d..00000000 --- a/vendor/hachidori/extension/anki-addon.js +++ /dev/null @@ -1,13 +0,0 @@ -// Pin the add-on independently so older extensions and vendored copies keep -// downloading the relay they were tested with. -// SPDX-License-Identifier: GPL-3.0-or-later - -export const ANKI_ADDON_FILE_NAME = "hachidori-relay.ankiaddon"; -export const ANKI_ADDON_VERSION = "0.0.4"; -export const ANKI_ADDON_URL = `https://github.com/bee-san/hachidori-anki/releases/download/v${ANKI_ADDON_VERSION}/${ANKI_ADDON_FILE_NAME}`; - -export async function fetchAnkiAddon(request = globalThis.fetch) { - const response = await request(ANKI_ADDON_URL); - if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}. Try again.`); - return response.blob(); -} diff --git a/vendor/hachidori/extension/anki-audio.js b/vendor/hachidori/extension/anki-audio.js deleted file mode 100644 index 84b4db2a..00000000 --- a/vendor/hachidori/extension/anki-audio.js +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { selectedAudioPlan } from "./audio-repository.js"; -import { ankiMediaFilename } from "./anki-resources.js"; - -const MIME_EXTENSIONS = { "audio/aac": "aac", "audio/flac": "flac", "audio/mp4": "m4a", "audio/mpeg": "mp3", - "audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "webm", "audio/x-wav": "wav", "application/ogg": "ogg" }; - -async function base64(window, blob, signal) { - signal.throwIfAborted(); - const reader = new window.FileReader(); - let abort; - try { - return await new Promise((resolve, reject) => { - reader.onload = () => resolve(reader.result.slice(reader.result.indexOf(",") + 1)); - reader.onerror = () => reject(reader.error); - abort = () => { reader.abort(); reject(signal.reason); }; - signal.addEventListener("abort", abort, { once: true }); - reader.readAsDataURL(blob); - }); - } finally { - signal.removeEventListener("abort", abort); - reader.onload = reader.onerror = null; - } -} - -async function candidateFile(window, repository, candidate, signal) { - const lease = await repository.acquire(candidate, signal); - let audio, abort; - try { - signal.throwIfAborted(); - audio = new window.Audio(); - audio.preload = "auto"; - await new Promise((resolve, reject) => { - audio.onloadeddata = resolve; - audio.onerror = () => { lease.invalidate(); reject(new Error("The pronunciation could not be decoded.")); }; - abort = () => reject(signal.reason); - signal.addEventListener("abort", abort, { once: true }); - audio.src = lease.url; - audio.load(); - }); - signal.throwIfAborted(); - const suffix = new URL(candidate.url).pathname.split(".").at(-1).toLowerCase(); - const mime = lease.blob.type.split(";")[0].toLowerCase(); - const fallbackExtension = /^[a-z0-9]+$/u.test(suffix) ? suffix : "bin"; - const extension = Object.hasOwn(MIME_EXTENSIONS, mime) ? MIME_EXTENSIONS[mime] : fallbackExtension; - const bytes = await lease.blob.arrayBuffer(); - signal.throwIfAborted(); - const filename = await ankiMediaFilename(bytes, extension); - const data = await base64(window, lease.blob, signal); - signal.throwIfAborted(); - return { filename, data, candidate }; - } finally { - if (audio) { - signal.removeEventListener("abort", abort); - audio.onloadeddata = audio.onerror = null; - audio.pause(); - audio.removeAttribute("src"); - audio.load(); - } - lease.release(); - } -} - -async function speechFile(window, recording, signal) { - signal.throwIfAborted(); - if (!(recording?.data instanceof Uint8Array) || recording.data.length === 0) { - throw new Error("Browser text-to-speech produced no captured WAV data."); - } - const filename = await ankiMediaFilename(recording.data, "wav"); - if (recording.filename !== undefined && recording.filename !== filename) { - throw new Error("The linked browser-speech filename does not match its WAV data."); - } - const data = await base64(window, new Blob([recording.data], { type: "audio/wav" }), signal); - signal.throwIfAborted(); - return { filename, data, candidate: recording.candidate }; -} - -// Read-only discovery/decoding, separate from the playback owner. The returned -// exact bytes and digest stay paired through duplicate check and later upload. -export async function exportAnkiAudio(window, repository, { - sources, - term, - selection, - recordSpeech = true, -}, signal, { recordSpeechAudio } = {}) { - const plan = selection ? await selectedAudioPlan(repository, sources, term, selection, signal) : { sources }; - let failure; - for (const source of plan.sources) { - if (source.type.startsWith("text-to-speech")) { - try { - if (typeof recordSpeechAudio !== "function") { - throw new Error("Browser text-to-speech recording is unavailable."); - } - const recorded = await recordSpeechAudio(source, term, signal, { record: recordSpeech }); - if (recorded?.recordingRequired === true) return recorded; - return { ...await speechFile(window, recorded, signal), sourceId: source.id }; - } catch (error) { - signal.throwIfAborted(); - failure = error; - } - continue; - } - try { - const candidates = plan.candidate ? [plan.candidate] : await repository.candidates(source, term, signal); - for (const [index, candidate] of candidates.entries()) { - try { - return { ...await candidateFile(window, repository, { ...candidate, index: candidate.index ?? index }, signal), sourceId: source.id }; - } catch (error) { signal.throwIfAborted(); failure = error; } - } - } catch (error) { signal.throwIfAborted(); failure = error; } - } - throw failure ?? new Error("No downloadable pronunciation is available for this result."); -} diff --git a/vendor/hachidori/extension/anki-client-media.js b/vendor/hachidori/extension/anki-client-media.js deleted file mode 100644 index 8cce3b53..00000000 --- a/vendor/hachidori/extension/anki-client-media.js +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { - MAX_ANIMATED_AVIF_BYTES, - MAX_WAV_BYTES, -} from "./media-limits.js"; - -export const MAX_LINKED_SCREENSHOT_BYTES = 6 * 1024 * 1024; -export const MAX_LINKED_SPEECH_BYTES = MAX_WAV_BYTES; -export const CAPTURE_FILENAMES = Object.freeze({ - animation: /^hachidori-[a-z0-9]+\.avif$/u, - audio: /^hachidori-[a-z0-9]+\.wav$/u, -}); -export const CAPTURE_LIMITS = Object.freeze({ - animation: MAX_ANIMATED_AVIF_BYTES, - audio: MAX_WAV_BYTES, -}); -const SCREENSHOT_FILENAME = /^hachidori-screenshot-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.jpg$/u; -const SPEECH_FILENAME = /^hachidori_[0-9a-f]{64}\.wav$/u; -const CLIENT_MEDIA_FIELDS = new Set(["screenshot", "capture", "speech"]); -const SCREENSHOT_FIELDS = new Set(["token", "filename", "data"]); -const CAPTURE_FIELDS = new Set(["jobId", "warnings", "assets"]); -const ASSET_FIELDS = new Set(["filename", "byteLength", "data"]); -const SPEECH_PLAN_FIELDS = new Set(["sourceId", "sourceKey", "expression", "reading"]); -const SPEECH_FIELDS = new Set([...SPEECH_PLAN_FIELDS, "filename", "byteLength", "data"]); -const CAPTURE_KINDS = ["animation", "audio"]; - -function record(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function exactFields(value, allowed, label) { - if (!record(value) || Object.keys(value).some(key => !allowed.has(key))) { - throw new Error(`The linked ${label} payload is invalid.`); - } -} - -export function decodedBase64Length(value) { - if (typeof value !== "string" || value.length === 0 || value.length % 4 !== 0 - || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) return null; - if (value.endsWith("==")) return value.length / 4 * 3 - 2; - return value.length / 4 * 3 - Number(value.endsWith("=")); -} - -function unavailable(request, kind) { - return Array.isArray(request?.captureUnavailable) && request.captureUnavailable.includes(kind); -} - -function validateScreenshot(request, value) { - exactFields(value, SCREENSHOT_FIELDS, "screenshot"); - const expected = request?.screenshot; - const byteLength = decodedBase64Length(value.data); - if (!record(expected) || unavailable(request, "screenshot") - || typeof value.token !== "string" || value.token === "" || value.token.length > 256 - || value.token !== expected.token || value.filename !== expected.filename - || !SCREENSHOT_FILENAME.test(value.filename) - || byteLength === null || !value.data.startsWith("/9j/") - || byteLength > MAX_LINKED_SCREENSHOT_BYTES) { - throw new Error("The linked screenshot payload is invalid, stale, or exceeds its size limit."); - } - return { token: value.token, filename: value.filename, data: value.data }; -} - -function validateAsset(request, kind, value) { - exactFields(value, ASSET_FIELDS, `captured ${kind}`); - const expectedFilename = request?.capturePin?.[kind === "animation" ? "animationFilename" : "audioFilename"]; - const byteLength = decodedBase64Length(value.data); - if (typeof expectedFilename !== "string" || value.filename !== expectedFilename - || !CAPTURE_FILENAMES[kind].test(value.filename) - || !Number.isSafeInteger(value.byteLength) || value.byteLength < 1 - || value.byteLength > CAPTURE_LIMITS[kind] - || byteLength === null || byteLength !== value.byteLength) { - throw new Error(`The linked captured ${kind} payload is invalid, stale, or exceeds its size limit.`); - } - return { filename: value.filename, byteLength: value.byteLength, data: value.data }; -} - -function validateCapture(request, value) { - exactFields(value, CAPTURE_FIELDS, "captured-media"); - if (typeof request?.captureJobId !== "string" || request.captureJobId === "" - || request.captureJobId.length > 256 || value.jobId !== request.captureJobId - || !Array.isArray(value.warnings) || value.warnings.length > 64 - || value.warnings.some(warning => typeof warning !== "string" || warning.length > 500) - || !record(value.assets) || Object.keys(value.assets).some(kind => !CAPTURE_KINDS.includes(kind))) { - throw new Error("The linked captured-media payload is invalid or stale."); - } - const assets = {}; - for (const kind of CAPTURE_KINDS) { - if (value.assets[kind] !== undefined) assets[kind] = validateAsset(request, kind, value.assets[kind]); - } - return { jobId: value.jobId, warnings: [...value.warnings], assets }; -} - -function validateSpeechPlan(value, label = "browser-speech plan") { - exactFields(value, SPEECH_PLAN_FIELDS, label); - if (typeof value.sourceId !== "string" || value.sourceId === "" || value.sourceId.length > 256 - || typeof value.sourceKey !== "string" || value.sourceKey === "" || value.sourceKey.length > 16_384 - || typeof value.expression !== "string" || value.expression === "" || value.expression.length > 4096 - || typeof value.reading !== "string" || value.reading.length > 4096) { - throw new Error(`The linked ${label} is invalid.`); - } - return { - sourceId: value.sourceId, - sourceKey: value.sourceKey, - expression: value.expression, - reading: value.reading, - }; -} - -function validateSpeech(request, value) { - exactFields(value, SPEECH_FIELDS, "browser-speech payload"); - const expected = validateSpeechPlan(request?.clientSpeech); - const actual = validateSpeechPlan(Object.fromEntries( - [...SPEECH_PLAN_FIELDS].map(field => [field, value[field]]), - ), "browser-speech identity"); - const byteLength = decodedBase64Length(value.data); - if (Object.keys(expected).some(field => actual[field] !== expected[field]) - || !SPEECH_FILENAME.test(value.filename) - || !Number.isSafeInteger(value.byteLength) || value.byteLength < 1 - || value.byteLength > MAX_LINKED_SPEECH_BYTES - || byteLength === null || byteLength !== value.byteLength - || !value.data.startsWith("UklG")) { - throw new Error("The linked browser-speech payload is invalid, stale, or exceeds its size limit."); - } - return { ...actual, filename: value.filename, byteLength: value.byteLength, data: value.data }; -} - -// Returns a new allowlisted envelope so the host never retains caller-owned -// objects or unrecognised fields after validating the cross-browser boundary. -export function validateLinkedAnkiClientMedia(request, value) { - exactFields(value, CLIENT_MEDIA_FIELDS, "client-media envelope"); - const expectsScreenshot = record(request?.screenshot) && !unavailable(request, "screenshot"); - const expectsCapture = typeof request?.captureJobId === "string" && request.captureJobId !== ""; - const expectsSpeech = record(request?.clientSpeech); - if (expectsScreenshot !== (value.screenshot !== undefined) - || expectsCapture !== (value.capture !== undefined) - || expectsSpeech !== (value.speech !== undefined)) { - throw new Error("The linked client-media envelope is missing media for this mining request."); - } - return { - ...(value.screenshot === undefined ? {} : { screenshot: validateScreenshot(request, value.screenshot) }), - ...(value.capture === undefined ? {} : { capture: validateCapture(request, value.capture) }), - ...(value.speech === undefined ? {} : { speech: validateSpeech(request, value.speech) }), - }; -} diff --git a/vendor/hachidori/extension/anki-content.js b/vendor/hachidori/extension/anki-content.js deleted file mode 100644 index 7422a021..00000000 --- a/vendor/hachidori/extension/anki-content.js +++ /dev/null @@ -1,677 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -(function () { - "use strict"; - const text = (node, value) => { if (node.textContent !== value) node.textContent = value; }; - - const FEEDBACK_PRIORITY = { info: 0, success: 1, warning: 2, error: 3 }; - function syncFeedbackSurface(feedback) { - const visible = [...feedback.querySelectorAll(".gsm-hoshidicts-anki-control")] - .filter(control => !control.hidden); - feedback.hidden = visible.length === 0; - if (visible.length === 0) { - delete feedback.dataset.kind; - return; - } - feedback.dataset.kind = visible.reduce((kind, control) => { - const next = control.dataset.kind || "info"; - return FEEDBACK_PRIORITY[next] > FEEDBACK_PRIORITY[kind] ? next : kind; - }, "info"); - } - function syncFeedback(record) { - if (!record.control) return; - record.control.hidden = record.hidden || (record.badge.hidden && record.output.textContent === ""); - syncFeedbackSurface(record.feedback); - } - function setStatus(record, value, kind = "info") { - text(record.output, value); - record.control.dataset.kind = kind; - syncFeedback(record); - } - function setMiningButtonState(record, state, message = "") { - const button = record.add; - button.dataset.state = state; - const actionTitle = message || { - checking: "Checking Anki card status", - ready: "Mine to Anki", - "add-duplicate": "Add duplicate to Anki", - overwrite: "Overwrite note in Anki", - "view-existing": "View existing notes in Anki", - mining: "Adding note", - success: "Note added", - error: "Could not add note", - duplicate: "Note already exists", - unavailable: "Anki mining is unavailable", - }[state] || "Mine to Anki"; - const title = record.custom && !message ? `${record.label}: ${actionTitle}` : actionTitle; - button.title = title; - button.setAttribute("aria-label", title); - button.setAttribute("aria-busy", String(state === "checking" || state === "mining")); - button.dataset.action = views(record) ? "view" : "add"; - if (record.custom) { - let label = button.querySelector(".gsm-hoshidicts-text-action-label"); - if (!label) { - label = button.ownerDocument.createElement("span"); - label.className = "gsm-hoshidicts-text-action-label"; - button.replaceChildren(label); - } - text(label, record.label); - return; - } - const iconName = { - ready: "add", - "add-duplicate": "document-add", - overwrite: "document-edit", - "view-existing": "book-search", - checking: "arrow-clockwise", - mining: "arrow-sync", - success: "book-search", - error: "error-circle", - unavailable: "subtract", - }[state] || "subtract"; - const icon = button.ownerDocument.createElement("span"); - icon.className = "gsm-hoshidicts-mine-icon hd-icon"; - icon.setAttribute("aria-hidden", "true"); - icon.dataset.icon = iconName; - button.replaceChildren(icon); - } - // The one Anki button opens Anki instead of adding once there is a note to - // show: the note it wrote, the write it could not confirm, or the duplicates - // that block adding. - function views(record) { - return record.terminal || (record.decision?.state === "duplicate" && record.decision.canAdd === false); - } - function disabled(record) { - if (!record.add) return; - record.add.disabled = record.busy - || (!record.terminal && (record.needsCheck || (!record.decision?.canAdd && !views(record)))); - } - function payload(record) { - return { - ...record.group.getRequest(record.result), - configKey: record.configKey, - templateId: record.templateId, - }; - } - function decisionState(value) { - if (value.action === "overwrite" && value.canAdd) return "overwrite"; - if (value.state === "duplicate") return value.canAdd ? "add-duplicate" : "view-existing"; - if (value.state === "invalid" || value.state === "error") return "error"; - return "ready"; - } - function showControls(record, value) { - record.hidden = !value; - if (record.add) record.add.hidden = !value; - syncFeedback(record); - } - function removeControls(record) { - if (record?.add && !record.custom) record.add.remove(); - if (record?.add && record.custom) { - record.add.removeEventListener("mousedown", record.onMouseDown); - record.add.removeEventListener("click", record.onClick); - record.add.disabled = true; - delete record.add.dataset.state; - delete record.add.dataset.action; - record.add.removeAttribute("aria-busy"); - } - record?.control?.remove(); - if (record?.feedback) syncFeedbackSurface(record.feedback); - } - function reusableRecord(record, group, spec) { - return record?.group === group && record.result === spec.item.result - && record.custom === spec.custom && record.templateId === spec.templateId; - } - function createRecord(group, spec) { - const { item, binding, custom, templateId, label, add } = spec; - return { - ...item, - binding, - group, - custom, - templateId, - label, - ...(add ? { add } : {}), - configKey: null, - busy: false, - terminal: false, - decision: null, - viewChecked: false, - needsCheck: true, - captureJobId: null, - }; - } - function updateRecord(record, spec) { - Object.assign(record, { - actions: spec.item.actions, - feedback: spec.item.feedback, - result: spec.item.result, - label: spec.label, - }); - if (spec.custom) setMiningButtonState(record, record.add.dataset.state || "checking"); - } - function captureBadge(record, state = "") { - if (!record.badge) return; - const capture = record.decision?.capture; - record.badge.hidden = !capture; - if (!capture) { - text(record.badge, ""); - syncFeedback(record); - return; - } - const labels = [capture.sourceLabel, capture.partial ? "Partial" : "", state].filter(Boolean); - text(record.badge, labels.join(" · ")); - syncFeedback(record); - } - function decision(record, value) { - record.decision = value; - if (!record.terminal && !record.busy) { - const state = decisionState(value); - setMiningButtonState(record, state, state === "view-existing" ? "" : value.error || ""); - setStatus(record, value.error || "", value.error ? "error" : "info"); - } - captureBadge(record); - disabled(record); - } - function uncertain(record, error) { - record.terminal = true; - setMiningButtonState(record, "error", "Check Anki before trying again"); - setStatus(record, error, "error"); - } - function readyCaptureRequest(record, request, requirements, assets) { - captureBadge(record); - const unavailable = [...(request.captureUnavailable ?? [])]; - if (requirements.includeAnimation && !assets?.animation) unavailable.push("animation"); - if (requirements.includeAudio && !assets?.audio) unavailable.push("audio"); - return { ...request, captureJobId: record.captureJobId, captureUnavailable: unavailable }; - } - function captureProgress(record, status) { - if (status.state === "finishing") { - captureBadge(record, "Finishing clip"); - setStatus(record, "Finishing clip…"); - } else { - captureBadge(record); - const progress = status.total > 0 ? ` ${status.progress}/${status.total}` : ""; - setStatus(record, `Encoding captured media${progress}…`); - } - } - function restartChecks(records) { - for (const record of records) { - if (record.terminal) continue; - record.viewChecked = false; - record.needsCheck = true; - record.decision = null; - if (record.add) setMiningButtonState(record, "checking"); - } - } - function cachedViewRequest(record) { - return { request: { - term: { - expression: record.result.term.expression, - reading: record.result.term.reading, - }, - templateId: record.templateId, - } }; - } - function cachedView(value) { - return value?.cached === true && value.state === "duplicate" - && value.canAdd === false && Array.isArray(value.noteIds) && value.noteIds.length > 0; - } - function checkedConfigKey(configKey, result) { - if (typeof result?.configKey !== "string") return { configKey, changed: false }; - if (configKey !== null && result.configKey !== configKey) return { configKey, changed: true }; - return { configKey: result.configKey, changed: false }; - } - function createAnkiController({ - send, - capture = send, - onChange, - // The page's own overlays are hidden for a viewport screenshot and restored - // afterwards; without a host to hide, the screenshot is just taken. - conceal = during => during(), - wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), - }) { - const owners = new Map(), bound = new WeakMap(); - let enabled = false, settingsKey = "", checks = Promise.resolve(); - let primaryTemplateId = "default"; - let templateIds = new Set(["default"]); - let customButtonTemplates = new Map(); - const live = group => enabled && owners.get(group.owner) === group && !group.popup.hidden && group.isCurrent(); - const boundHere = record => bound.get(record.binding) === record && record.binding.isConnected; - const current = record => live(record.group) && boundHere(record); - const needsCheck = record => boundHere(record) && record.needsCheck && !record.busy && !record.terminal; - function available(records, value, error = "") { - for (const record of records) { - const show = value || record.custom || record.terminal || cachedView(record.decision); - if (show && record.actions.isConnected) controls(record); - if (record.control) showControls(record, show); - if (!value && !show) record.needsCheck = false; - if (!value && record.custom) { - record.needsCheck = false; - setMiningButtonState(record, "unavailable", error || "This Anki Template is unavailable."); - setStatus(record, error || "This Anki Template is unavailable.", "error"); - } - } - } - async function requestCachedView(record) { - record.viewChecked = true; - try { - return await send("hd_anki_view", cachedViewRequest(record)); - } catch { - return null; - } - } - function applyCachedView(group, record, result) { - if (!cachedView(result)) return; - record.needsCheck = false; - controls(record); - showControls(record, true); - decision(record, result); - onChange(group.owner); - } - async function checkCachedViews(group, records, owns) { - const configKeys = new Map(); - for (const record of records) { - if (!owns()) return { configKeys, changed: [] }; - if (!needsCheck(record) || record.viewChecked) continue; - const result = await requestCachedView(record); - if (result === null || !owns() || !boundHere(record)) continue; - const configKey = configKeys.get(record.templateId) ?? null; - const checked = checkedConfigKey(configKey, result); - if (checked.changed) { - return { - configKeys, - changed: records.filter(candidate => candidate.templateId === record.templateId), - }; - } - if (checked.configKey !== null) configKeys.set(record.templateId, checked.configKey); - record.configKey = checked.configKey; - applyCachedView(group, record, result); - } - return { configKeys, changed: [] }; - } - async function checkRecords(records, owns) { - for (const record of records) { - if (!owns()) return; - if (!needsCheck(record)) continue; - record.needsCheck = false; - try { - const result = await send("hd_anki_preflight", { request: payload(record) }); - if (owns() && boundHere(record)) decision(record, result); - } catch (error) { - if (owns() && boundHere(record)) decision(record, { state: "error", canAdd: false, error: error.message }); - } - } - } - function recordsByTemplate(records) { - const byTemplate = new Map(); - for (const record of records) { - if (!byTemplate.has(record.templateId)) byTemplate.set(record.templateId, []); - byTemplate.get(record.templateId).push(record); - } - return byTemplate; - } - async function checkTemplateRecords(group, templateId, records, configKeys, owns) { - const status = await send("hd_anki_status", { templateId }); - if (!owns()) return; - const cachedKey = configKeys.get(templateId) ?? null; - if (status.available && cachedKey !== null && status.configKey !== cachedKey) { - restartChecks(records); - return; - } - for (const record of records) record.configKey = status.configKey; - available(records, status.available, status.error); - if (!status.available) return; - onChange(group.owner); - await checkRecords(records, owns); - } - async function checkGroup(group) { - const epoch = group.epoch; - const owns = () => live(group) && epoch === group.epoch; - try { - if (!owns()) return; - const missing = group.records.filter(record => needsCheck(record) && !templateIds.has(record.templateId)); - available(missing, false, "The selected Anki Template is no longer available."); - const configured = group.records.filter(record => templateIds.has(record.templateId)); - const cached = await checkCachedViews(group, configured, owns); - if (!owns()) return; - if (cached.changed.length > 0) { - restartChecks(cached.changed); - return; - } - if (!group.records.some(needsCheck)) return; - const byTemplate = recordsByTemplate(group.records.filter(needsCheck)); - for (const [templateId, records] of byTemplate) { - await checkTemplateRecords(group, templateId, records, cached.configKeys, owns); - if (!owns()) return; - } - } catch (error) { - if (owns()) available(group.records.filter(needsCheck), false, error.message); - } finally { - group.queued = false; - if (live(group)) { - group.records.forEach(disabled); - onChange(group.owner); - refresh(group); - } - } - } - function refresh(group, all = false) { - if (all) for (const record of group.records) { - record.viewChecked = record.terminal; - record.needsCheck = !record.terminal; - } - group.records.forEach(record => { - if (needsCheck(record)) { - // Readiness belongs to this result, not to the whole popup's queue. - record.decision = null; - if (enabled) { - controls(record); - showControls(record, true); - setMiningButtonState(record, "checking"); - } - } - disabled(record); - }); - if (!live(group) || group.queued || !group.records.some(needsCheck)) return; - group.queued = true; - const operation = () => checkGroup(group); - checks = checks.then(operation, operation); - } - function refreshAll() { - for (const group of owners.values()) refresh(group, true); - } - async function discardScreenshot(record) { - if (!record.screenshot) return; - const { token } = record.screenshot; - record.screenshot = null; - try { await send("hd_anki_screenshot_discard", { request: { token } }); } catch { /* A restarted worker holds nothing. */ } - } - async function cancelCapture(record) { - if (!record.captureJobId) return; - try { await capture("hd_capture_cancel", { jobId: record.captureJobId }); } catch { /* Stop/expiry already cleaned it up. */ } - record.captureJobId = null; - } - async function handleSubmissionFailure(record, error, writeSent, owns) { - if (!writeSent) { - // Nothing was sent, so the picture this submission took is nobody's. - await discardScreenshot(record); - } else if (!error.responseReceived) { - uncertain(record, `The write could not be confirmed. Check Anki before trying again. ${error.message}`); - return; - } else { - // A worker reply confirms that no Anki mutation was sent. Release the - // request-owned export even after its popup owner has retired. - await cancelCapture(record); - await discardScreenshot(record); - } - if (!owns()) return; - setMiningButtonState(record, decisionState(record.decision)); - setStatus(record, `Could not add: ${error.message}`, "error"); - } - // One viewport screenshot for this submission, taken with Hachidori's own - // overlays hidden. A capture or upload that fails is a warning carried with - // the note's outcome: the field renders empty and the note still goes in. - async function prepareScreenshot(record, request, owns) { - record.screenshotWarning = ""; - record.screenshot = null; - if (record.decision?.screenshot !== true) return request; - if (owns()) setStatus(record, "Taking the screenshot…"); - try { - const taken = await conceal(() => send("hd_anki_screenshot", { templateId: record.templateId })); - if (typeof taken?.filename !== "string" || !taken.filename) throw new Error("no screenshot was taken"); - record.screenshot = { token: taken.token, filename: taken.filename }; - return { ...request, screenshot: record.screenshot }; - } catch (error) { - record.screenshotWarning = `Screenshot: ${error.message}`; - return { ...request, captureUnavailable: [...(request.captureUnavailable ?? []), "screenshot"] }; - } - } - function submitted(record, result) { - if (result.state === "uncertain") { uncertain(record, result.error); return true; } - if (result.state !== "added" && result.state !== "updated") return false; - // A configuration epoch can change while Anki commits. The submitted - // record still owns its outcome; never turn a known write into a retry. - record.terminal = true; - record.noteIds = [result.noteId]; - const label = result.state === "added" ? "Added" : "Updated"; - const warnings = [record.screenshotWarning, ...(result.warnings ?? [])].filter(Boolean); - setMiningButtonState(record, "success", `Find ${label.toLowerCase()} note in Anki`); - setStatus(record, `${label} note ${result.noteId}. ${warnings.join(" ")}`.trim(), - warnings.length > 0 ? "warning" : "success"); - refreshAll(); // Best-effort checks cannot turn a confirmed write into a retry. - return true; - } - async function prepareCapture(record, request, owns) { - const selected = record.decision?.capture; - if (!selected) return request; - if (!request.capturePin?.token) throw new Error("The capture pin expired. Look up the text again."); - if (!record.captureJobId) { - const started = await capture("hd_capture_export", { - token: request.capturePin.token, - requirements: selected.requirements, - }); - record.captureJobId = started.jobId; - } - for (;;) { - const status = await capture("hd_capture_job_status", { jobId: record.captureJobId }); - if (status.state === "ready") { - return readyCaptureRequest(record, request, selected.requirements, status.assets); - } - if (status.state === "error") { - const message = status.error || "Captured media could not be encoded."; - await cancelCapture(record); - throw new Error(message); - } - if (owns()) captureProgress(record, status); - await wait(100); - } - } - async function submit(record, fromPointer) { - if (!current(record) || record.add.disabled || record.busy || record.terminal) return; - const group = record.group, epoch = group.epoch; - const owns = () => current(record) && record.group === group && group.epoch === epoch; - const baseRequest = (fromPointer && record.pointerRequest) || payload(record); - const request = record.decision?.clientSpeech - ? { ...baseRequest, clientSpeech: record.decision.clientSpeech } - : baseRequest; - record.pointerRequest = null; - record.busy = true; - setMiningButtonState(record, "mining"); - disabled(record); - setStatus(record, "Saving to Anki…"); - let writeSent = false; - try { - const prepared = await prepareCapture(record, await prepareScreenshot(record, request, owns), owns); - if (owns()) setStatus(record, "Saving to Anki…"); - writeSent = true; - const result = await send("hd_anki_submit", { request: prepared }); - // These replies confirm that no note was written. Release the export - // even if its popup retired while Anki was checking the submission. - if (["duplicate", "invalid"].includes(result.state)) await cancelCapture(record); - if (!submitted(record, result) && owns()) { decision(record, { ...result, canAdd: false }); refreshAll(); } - } catch (error) { - await handleSubmissionFailure(record, error, writeSent, owns); - } finally { - record.busy = false; - if (current(record)) { disabled(record); onChange(record.group.owner); refresh(record.group); } - } - } - // An unconfirmed write has no note ID, so Anki searches for the expression. - async function browse(record) { - if (!current(record) || record.add.disabled) return; - record.add.disabled = true; - const noteIds = record.terminal ? record.noteIds : record.decision?.noteIds; - try { - const result = await send("hd_anki_browse", { request: { - noteIds: Array.isArray(noteIds) ? noteIds : [], - expression: record.result.term.expression, - configKey: record.configKey, - templateId: record.templateId, - } }); - if (current(record) && Array.isArray(result?.noteIds)) { - if (record.terminal) record.noteIds = result.noteIds; - else if (record.decision) record.decision = { ...record.decision, noteIds: result.noteIds }; - } - if (current(record) && result?.opened === false) { - record.terminal = false; - record.noteIds = []; - record.decision = null; - record.viewChecked = false; - record.needsCheck = true; - setMiningButtonState(record, "checking"); - setStatus(record, ""); - refresh(record.group); - } - } - catch (error) { if (current(record)) setStatus(record, `Could not open Anki: ${error.message}`, "error"); } - finally { - if (current(record)) { disabled(record); onChange(record.group.owner); } - } - } - function controls(record) { - if (record.control) return; - const document = record.actions.ownerDocument; - const feedback = record.feedback || document.createElement("div"); - if (!record.feedback) { - feedback.className = "gsm-hoshidicts-mining-feedback"; - feedback.setAttribute("role", "status"); - feedback.setAttribute("aria-live", "polite"); - feedback.hidden = true; - record.actions.after(feedback); - } - const control = document.createElement("div"); - control.className = "gsm-hoshidicts-anki-control"; - const add = record.add || document.createElement("button"); - if (!record.custom) { - add.type = "button"; - add.className = "gsm-hoshidicts-mine-button"; - } - const badge = document.createElement("span"); - badge.className = "gsm-hoshidicts-capture-badge"; - badge.hidden = true; - const output = document.createElement("output"); - output.className = "gsm-hoshidicts-anki-status"; - output.setAttribute("aria-live", "polite"); - control.append(badge, output); - control.hidden = true; - feedback.append(control); - if (!record.custom) { - const leadingAction = record.actions.firstElementChild; - if (leadingAction?.matches(".gsm-hoshidicts-popup-close, .gsm-hoshidicts-kanji-back")) { - leadingAction.after(add); - } else { - record.actions.prepend(add); - } - } - Object.assign(record, { feedback, control, add, badge, output, hidden: false }); - setMiningButtonState(record, "checking"); - record.onMouseDown = event => { - if (event.button === 0 && current(record) && !views(record)) record.pointerRequest = payload(record); - }; - record.onClick = event => { - if (views(record)) void browse(record); - else void submit(record, event.detail > 0); - }; - add.addEventListener("mousedown", record.onMouseDown); - add.addEventListener("click", record.onClick); - disabled(record); - } - function recordSpecs(group) { - const specs = []; - for (const item of group.items) { - specs.push({ - item, - binding: item.actions, - custom: false, - templateId: primaryTemplateId, - label: "Anki", - }); - for (const add of item.actions.querySelectorAll(".gsm-hoshidicts-custom-anki-button")) { - const descriptor = customButtonTemplates.get(add.dataset.customButtonId); - if (!descriptor) continue; - specs.push({ - item, - binding: add, - add, - custom: true, - templateId: descriptor.templateId, - label: descriptor.label, - }); - } - } - return specs; - } - function recordForSpec(group, spec) { - let record = bound.get(spec.binding); - if (reusableRecord(record, group, spec)) { - updateRecord(record, spec); - return record; - } - if (record) { - removeControls(record); - bound.delete(spec.binding); - } - record = createRecord(group, spec); - bound.set(spec.binding, record); - return record; - } - function reconcile(group) { - const next = recordSpecs(group).map(spec => recordForSpec(group, spec)); - const retained = new Set(next); - const retainedBindings = new Set(next.map(record => record.binding)); - for (const record of group.records) { - if (retained.has(record) || retainedBindings.has(record.binding)) continue; - removeControls(record); - if (bound.get(record.binding) === record) bound.delete(record.binding); - } - group.records = next; - } - function bind(items, context) { - let group = owners.get(context.owner); - if (group && group.request !== context.request) { retire(context.owner); group = null; } - if (!group) { - group = { ...context, items, records: [], epoch: 0, queued: false }; - owners.set(context.owner, group); - } - else Object.assign(group, context); - group.items = items; - reconcile(group); - group.records.forEach(disabled); - refresh(group); - } - function retire(owner) { - for (const [key, group] of owners) { - if (owner !== undefined && owner !== key) continue; - owners.delete(key); - for (const record of group.records) if (record.control) showControls(record, false); - } - } - return { bind, retire, - refresh(owner) { const group = owners.get(owner); if (group) refresh(group, true); }, - update(options, ready = true) { - const anki = globalThis.HDReaderOptions.normaliseAnki(options.anki); - const customButtons = globalThis.HDReaderOptions.normaliseCustomButtons(options.customButtons); - const key = JSON.stringify([ready, anki, customButtons, options.audioSources, options.mediaCapture]); - if (key === settingsKey) return; - settingsKey = key; - primaryTemplateId = anki.templates[0].id; - templateIds = new Set(anki.templates.map(template => template.id)); - customButtonTemplates = new Map(customButtons.filter(button => button.type === "anki") - .map(button => [button.id, { templateId: button.templateId, label: button.label }])); - enabled = ready && (anki.templates.some(template => Boolean(template.model)) - || customButtonTemplates.size > 0); - for (const group of owners.values()) { - group.epoch++; - reconcile(group); - for (const record of group.records) if (record.control) showControls(record, false); - refresh(group, true); - } - }, - }; - } - // Mining screenshots address this exact document before and after capture. - // This script is present in every browser; the capture content script is not. - globalThis.chrome?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { - if (message?.target === "hachidori-anki-content" && message.type === "hd_anki_document") sendResponse({ present: true }); - }); - globalThis.HDAnki = { createAnkiController }; -}()); diff --git a/vendor/hachidori/extension/anki-digest.js b/vendor/hachidori/extension/anki-digest.js deleted file mode 100644 index a9d659d5..00000000 --- a/vendor/hachidori/extension/anki-digest.js +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -export async function ankiDigest(bytes) { - const hash = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes)); - return [...hash].map(byte => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/vendor/hachidori/extension/anki-duplicates.js b/vendor/hachidori/extension/anki-duplicates.js deleted file mode 100644 index a68ded27..00000000 --- a/vendor/hachidori/extension/anki-duplicates.js +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { isAnkiAudioOnlyTemplate } from "./anki-templates.js"; - -// GSM PR #549 hoshidicts_anki.py and hoshidicts_markers.py. These policies -// receive the gateway's private invoker, never a page-selected API action. -const escapeQuery = value => value.replace(/[\\"*_:]/gu, String.raw`\$&`); -const positiveId = value => Number.isSafeInteger(value) && value > 0; -export const isAnkiDuplicateError = error => /cannot create note because it is a duplicate/iu.test(error || ""); - -export function ankiBrowseQuery(expression) { - return `"${escapeQuery(expression.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"))}"`; -} - -export function ankiNoteIdsQuery(noteIds) { - if (!Array.isArray(noteIds) || noteIds.length === 0 || !noteIds.every(positiveId)) { - throw new Error("Anki browse requires valid note IDs."); - } - return `nid:${[...new Set(noteIds)].join(",")}`; -} - -export function ankiNoteOptions(config) { - const deck = config.duplicateScope === "deck"; - return { - // The index applies the selected recognized-note-type scope. Native Anki - // remains a final race guard for the configured destination note type only: - // its all-model switch would also reject unrelated custom note types. - allowDuplicate: config.duplicateBehavior === "new", - duplicateScope: deck ? "deck" : "collection", - duplicateScopeOptions: { - deckName: deck ? config.deck : null, - checkChildren: deck, - checkAllModels: false, - }, - }; -} - -function overwriteValue(existing, incoming, mode) { - if (mode === "overwrite") return incoming; - if (mode === "skip") return existing; - if (mode === "append") return existing + incoming; - if (mode === "prepend") return incoming + existing; - if (mode === "coalesce-new") return incoming || existing; - return existing || incoming; -} - -export function canonicalAnkiFields(fields, templates, existing) { - const names = new Map(Object.keys(existing).map(name => [name.toLowerCase(), name])); - const canonicalTemplates = [], incoming = []; - for (const [field, template] of Object.entries(templates)) { - const name = Object.hasOwn(existing, field) ? field : names.get(field.toLowerCase()); - if (name === undefined) { - const existingNames = Object.keys(existing).map(field => `“${field}”`).join(", "); - throw new Error(`Anki's note has no field “${field}” to overwrite; its fields are ${existingNames || "unknown"}. ` - + "The note type's fields changed. Refresh fields in Anki Settings before overwriting this note."); - } - canonicalTemplates.push([name, template]); - incoming.push([name, fields[field]]); - } - return { templates: Object.fromEntries(canonicalTemplates), fields: Object.fromEntries(incoming) }; -} - -export function overwriteAnkiFields(incoming, existing, templates, { includeAudio = false } = {}) { - return Object.fromEntries(Object.entries(templates).filter(([, template]) => includeAudio || !isAnkiAudioOnlyTemplate(template.value)) - .map(([field, template]) => [field, overwriteValue(existing[field] ?? "", incoming[field] ?? "", template.overwriteMode)])); -} - -function checkResult(result, detailed) { - if (!Array.isArray(result) || result.length !== 1 - || (detailed ? typeof result[0]?.canAdd !== "boolean" : typeof result[0] !== "boolean")) { - throw new Error("AnkiConnect returned invalid duplicate check results."); - } - return result[0]; -} - -export async function checkAnkiDuplicate(invoke, note, config) { - // Anki also validates clozes in non-first fields. Keep all rendered fields, - // but omit media-upload objects: preflight must not write collection media. - const checkNote = allowDuplicate => ({ deckName: note.deckName, modelName: note.modelName, fields: note.fields, tags: note.tags, - options: { ...note.options, allowDuplicate } }); - let result; - try { - result = checkResult(await invoke("canAddNotesWithErrorDetail", { notes: [checkNote(false)] }), true); - } catch (error) { - if (!/unsupported action/iu.test(error.message)) throw error; - const allowed = checkResult(await invoke("canAddNotes", { notes: [checkNote(true)] }), false); - const prevented = checkResult(await invoke("canAddNotes", { notes: [checkNote(false)] }), false); - return { duplicate: allowed && !prevented, addable: allowed && prevented, error: null }; - } - const error = typeof result.error === "string" && result.error ? result.error : null; - return { duplicate: isAnkiDuplicateError(error), addable: result.canAdd && !error, error }; -} - -export async function validateAnkiNote(invoke, note) { - const checkNote = { - deckName: note.deckName, - modelName: note.modelName, - fields: note.fields, - tags: note.tags, - options: { ...note.options, allowDuplicate: true }, - }; - try { - const result = checkResult(await invoke("canAddNotesWithErrorDetail", { notes: [checkNote] }), true); - const error = typeof result.error === "string" && result.error ? result.error : null; - return { addable: result.canAdd && error === null, error }; - } catch (error) { - if (!/unsupported action/iu.test(error.message)) throw error; - const addable = checkResult(await invoke("canAddNotes", { notes: [checkNote] }), false); - return { addable, error: addable ? null : "Anki rejected this note." }; - } -} - -function duplicateQuery(note, firstField, modelId) { - // Native Anki dupe search uses the same case-sensitive, HTML-stripped - // comparison as duplicate validation. Ordinary field search does not. - // Unlike ordinary search, dupe text treats wildcard/colon/comma literally. - const text = (note.fields[firstField] ?? "").replace(/[\\"]/gu, String.raw`\$&`); - return `"dupe:${modelId},${text}"`; -} - -async function scopedNoteIds(invoke, infos, config) { - if (config.duplicateScope !== "deck") return null; - const ids = infos.flatMap(info => Array.isArray(info?.cards) ? info.cards.filter(positiveId) : []); - if (!ids.length) return new Set(); - const cards = await invoke("cardsInfo", { cards: ids }); - if (!Array.isArray(cards)) throw new Error("AnkiConnect returned invalid duplicate card details."); - const exact = config.deck.toLowerCase(); - return new Set(cards.filter(card => { - if (typeof card?.deckName !== "string" || !positiveId(card.note)) return false; - const deck = card.deckName.toLowerCase(); - return deck === exact || deck.startsWith(`${exact}::`); - }).map(card => card.note)); -} - -export async function findAnkiDuplicateNotes(invoke, note, firstField, config, { - allModels = false, -} = {}) { - const models = await invoke("modelNamesAndIds"); - const modelId = models?.[config.model]; - if (Array.isArray(models) || !positiveId(modelId)) throw new Error("AnkiConnect returned no valid ID for the selected note type."); - const modelEntries = [[config.model, modelId]]; - if (allModels) { - for (const [modelName, id] of Object.entries(models)) { - if (modelName === config.model) continue; - if (!positiveId(id)) throw new Error("AnkiConnect returned invalid note type IDs."); - modelEntries.push([modelName, id]); - } - } - const ids = []; - const modelByNote = new Map(); - for (const [modelName, id] of modelEntries) { - const found = await invoke("findNotes", { query: duplicateQuery(note, firstField, id) }); - if (!Array.isArray(found) || !found.every(positiveId)) throw new Error("AnkiConnect returned invalid duplicate note IDs."); - for (const noteId of found) { - if (modelByNote.has(noteId)) continue; - ids.push(noteId); - modelByNote.set(noteId, modelName); - } - } - if (!ids.length) return []; - const infos = await invoke("notesInfo", { notes: ids }); - if (!Array.isArray(infos)) throw new Error("AnkiConnect returned invalid duplicate note details."); - const scoped = await scopedNoteIds(invoke, infos, config); - const byId = new Map(infos.filter(info => positiveId(info?.noteId)).map(info => [info.noteId, info])); - const matches = []; - for (const id of ids) { - if (scoped && !scoped.has(id)) continue; - const info = byId.get(id); - const expectedModel = modelByNote.get(id); - if (typeof info?.modelName !== "string" || info.modelName.toLowerCase() !== expectedModel.toLowerCase()) continue; - let fields = null; - if (info.modelName.toLowerCase() === config.model.toLowerCase()) { - if (!info.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) continue; - const entries = Object.entries(info.fields).map(([field, value]) => - [field, typeof value === "string" ? value : value?.value]); - if (entries.some(([, value]) => typeof value !== "string")) { - throw new Error("AnkiConnect returned invalid duplicate note fields."); - } - fields = Object.fromEntries(entries); - } - matches.push({ noteId: id, modelName: info.modelName, fields }); - } - return matches; -} - -export async function findAnkiOverwriteTarget(invoke, note, firstField, config) { - const [target] = await findAnkiDuplicateNotes(invoke, note, firstField, config, { allModels: false }); - return target ? { noteId: target.noteId, fields: target.fields } : null; -} diff --git a/vendor/hachidori/extension/anki-enrichment.js b/vendor/hachidori/extension/anki-enrichment.js deleted file mode 100644 index 4d1ce51f..00000000 --- a/vendor/hachidori/extension/anki-enrichment.js +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiTemplateMarkerNames } from "./anki-templates.js"; -import { canonicalAnkiFields, overwriteAnkiFields } from "./anki-duplicates.js"; -import { readAnkiNoteFields, verifyAnkiFields } from "./anki-mining.js"; - -function pronunciationFields(incoming, current, appliedFields, existingFields, warnings) { - const fields = {}; - for (const [field, value] of Object.entries(incoming)) { - const baseline = appliedFields[field] ?? existingFields?.[field] ?? ""; - if (typeof current[field] !== "string" || current[field].normalize("NFC") !== baseline.normalize("NFC")) { - warnings.push(`Field “${field}” changed in Anki; its pronunciation update was skipped.`); - } else if (current[field] !== value) fields[field] = value; - } - return fields; -} - -export async function enrichAnkiNote(context, { audio, render, store }) { - const { request, invoke, noteId, appliedFields, existingFields, resolved, resources } = context; - if (resources.pronunciationWarning) return [resources.pronunciationWarning]; - const warnings = []; - const confirmed = new Set(Array.isArray(resources.confirmedMedia) ? resources.confirmedMedia : []); - async function ensure(file, kind) { - if (confirmed.has(file.filename)) return; - await store(file, kind); - confirmed.add(file.filename); - } - // Dictionary media and any first-field pronunciation have already been - // confirmed before the note mutation. Only deferred pronunciation remains. - const canonical = existingFields ? canonicalAnkiFields({}, resolved.templates, existingFields).templates : resolved.templates; - const templates = Object.fromEntries(Object.entries(canonical).filter(([field, template]) => { - if (!ankiTemplateMarkerNames(template.value).includes("audio")) return false; - return !existingFields || (template.overwriteMode !== "skip" - && !(template.overwriteMode === "coalesce" && existingFields[field])); - })); - // No enabled audio source means no pronunciation, like a screenshot turned off. - if (!Object.keys(templates).length || (!resources.audioPrepared && !context.config.audioSources.length)) return warnings; - try { - const file = resources.audioPrepared ? resources.audio : await audio(request, context.config); - await ensure(file, "pronunciation"); - const rendered = await render(request, templates, `[sound:${file.filename}]`, resources); - const incoming = existingFields ? overwriteAnkiFields(rendered.fields, existingFields, templates, { includeAudio: true }) : rendered.fields; - // Audio work may have taken seconds. Re-read just before updating; never - // clobber an external edit or append to our already-applied text a second - // time. AnkiConnect has no CAS, so the final inter-call race remains. - const current = await readAnkiNoteFields(invoke, noteId); - const fields = pronunciationFields(incoming, current, appliedFields, existingFields, warnings); - if (Object.keys(fields).length) { - const result = await invoke("updateNoteFields", { note: { id: noteId, fields } }, 10_000); - if (result !== null) throw new Error("Anki returned an invalid pronunciation-update acknowledgement. Inspect the saved note."); - await verifyAnkiFields(invoke, noteId, fields); - } - } catch (error) { warnings.push(`Pronunciation: ${error.message}`); } - return warnings; -} diff --git a/vendor/hachidori/extension/anki-glossary.js b/vendor/hachidori/extension/anki-glossary.js deleted file mode 100644 index 4342121a..00000000 --- a/vendor/hachidori/extension/anki-glossary.js +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import "./external-links.js"; -import "./render/glossary.js"; - -const BLOCKS = new Set(["BR", "DIV", "LI", "OL", "P", "TABLE", "TBODY", "TD", "TFOOT", "TH", "THEAD", "TR", "UL"]); -function plainText(node) { - if (node.nodeType === 3) return node.textContent; - if (node.getAttribute?.("aria-hidden") === "true") return ""; - return [...node.childNodes].map(plainText).join("") + (BLOCKS.has(node.nodeName) ? "\n" : ""); -} - -function imageSize(image, value) { - const units = value.sizeUnits === "em" ? "em" : "px"; - for (const dimension of ["width", "height"]) { - if (Number.isFinite(value[dimension]) && value[dimension] > 0) image.setAttribute(dimension, String(value[dimension])); - const preferred = value[dimension === "width" ? "preferredWidth" : "preferredHeight"]; - if (Number.isFinite(preferred) && preferred > 0) image.style[dimension] = `${preferred}${units}`; - } - if (image.style.width && !image.style.height) image.style.height = "auto"; - else if (image.style.height && !image.style.width) image.style.width = "auto"; -} - -export function createAnkiDefinitionRenderer(document, request, filenameFor) { - const inert = document.implementation.createHTMLDocument(""); - const groups = new Map(); - for (const glossary of request.term.glossaries) { - if (!groups.has(glossary.dictionary)) groups.set(glossary.dictionary, []); - groups.get(glossary.dictionary).push(glossary); - } - const media = new Map(request.dictionaryMedia.map(item => [JSON.stringify([item.dictionary, item.path]), item.filename])); - const dictionaryAlias = dictionary => Object.hasOwn(request.dictionaryAliases, dictionary) - ? request.dictionaryAliases[dictionary] : dictionary; - const escape = text => { const span = inert.createElement("span"); span.textContent = text; return span.innerHTML; }; - - function appendImage(doc, parent, value, { dictionary, path }, pending) { - const image = doc.createElement("img"); - image.className = "gloss-sc-img"; - pending.push(Promise.resolve(filenameFor ? filenameFor(dictionary, path) : media.get(JSON.stringify([dictionary, path]))) - .then(filename => { if (filename) image.setAttribute("src", filename); })); - image.alt = typeof value.title === "string" ? value.title : "Dictionary image"; - image.style.maxWidth = "100%"; - image.style.objectFit = "contain"; - imageSize(image, value); - parent.append(image); - } - - function content(glossary, pending) { - const body = inert.createElement("div"); - body.className = "gsm-hoshidicts-glossary-content"; - body.dataset.hoshidictsDictionary = glossary.dictionary; - globalThis.HDGlossary.appendTextOnlyGlossary(inert, body, glossary.glossary, { - dictionary: glossary.dictionary, appendImage: pending ? (...args) => appendImage(...args, pending) : () => {}, - }); - return body; - } - - function plainDefinition(selected, noDictionary) { - const lines = []; - for (const [dictionary, glossaries] of selected) { - if (!noDictionary) lines.push(`(${escape(dictionaryAlias(dictionary))})`); - for (const glossary of glossaries) { - lines.push(...plainText(content(glossary)).split(/\r?\n|\r/u).map(line => line.trim()).filter(Boolean).map(escape)); - } - } - return lines.join("
"); - } - - function entry(glossary, brief, noDictionary, pending) { - const wrapper = inert.createElement("div"); - const labels = brief ? [] : [glossary.definitionTags, glossary.termTags, - noDictionary ? "" : dictionaryAlias(glossary.dictionary)].filter(Boolean); - if (labels.length) { - const meta = inert.createElement("i"); - meta.className = "yomitan-glossary-meta"; - meta.textContent = `(${labels.join(", ")})`; - wrapper.append(meta, " "); - } - wrapper.append(content(glossary, pending)); - return wrapper; - } - - function appendStyles(root, selected) { - const names = new Set(selected.map(([name]) => name)); - const styles = request.dictionaryStyles.filter(style => names.has(style.dictionary)); - if (!styles.length) return; - const applied = globalThis.HDGlossary.applyDictionaryStyles(document, root, request.generation, styles); - // Escape a serialized closing style tag's slash without corrupting CSS - // strings or pre-existing selector escapes. - for (const style of applied) style.textContent = style.textContent.replace(/<\/style/giu, value => String.raw`<\/${value.slice(2)}`); - } - - function appendDetails(root) { - const details = []; - if (request.term.rules) details.push(`Rules: ${escape(request.term.rules)}`); - if (request.trace.length) details.push(`Deinflection: ${request.trace.map(step => escape(step.name)).join(" > ")}`); - if (!details.length) return; - const small = inert.createElement("small"); - small.className = "yomitan-glossary-details"; - small.innerHTML = details.join("
"); - root.append(small); - } - - return async ({ dictionary, firstOnly = false, brief = false, noDictionary = false, plain = false }) => { - let selected = [...groups].filter(([name]) => dictionary === undefined || name === dictionary); - if (firstOnly) selected = selected.slice(0, 1); - if (!selected.length) return ""; - if (plain) return plainDefinition(selected, noDictionary); - const pending = []; - const root = inert.createElement("div"); - root.className = "yomitan-glossary"; - root.style.cssText = "text-align: left; contain: layout paint style; isolation: isolate;"; - const list = inert.createElement("ol"); - for (const [name, glossaries] of selected) { - const page = inert.createElement("li"); - page.dataset.dictionary = name; - if (glossaries.length === 1) page.append(entry(glossaries[0], brief, noDictionary, pending)); - else { - const senses = inert.createElement("ul"); - for (const glossary of glossaries) { const sense = inert.createElement("li"); sense.append(entry(glossary, brief, noDictionary, pending)); senses.append(sense); } - page.append(senses); - } - list.append(page); - } - root.append(list); - appendStyles(root, selected); - if (!brief) appendDetails(root); - await Promise.all(pending); - return root.outerHTML; - }; -} diff --git a/vendor/hachidori/extension/anki-index-cache.js b/vendor/hachidori/extension/anki-index-cache.js deleted file mode 100644 index f0c7d496..00000000 --- a/vendor/hachidori/extension/anki-index-cache.js +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiIndexSource, ankiWordKey } from "./anki-index.js"; - -export const ANKI_INDEX_KEY = "ankiDuplicateIndex"; -export const ANKI_INDEX_ALARM = "hachidori-anki-index"; -export const ANKI_INDEX_REFRESH_MS = 30 * 60 * 1000; - -const positiveId = value => Number.isSafeInteger(value) && value > 0; -const compare = (left, right) => Number(left > right) - Number(left < right); - -function normalizedRow(value) { - if (!Array.isArray(value) || value.length !== 3 || typeof value[0] !== "string" || !value[0] - || typeof value[1] !== "boolean" || !Array.isArray(value[2]) || !value[2].every(positiveId)) return null; - const noteIds = [...new Set(value[2])].sort((left, right) => left - right); - if (!noteIds.length) return null; - return [value[0], value[1], noteIds]; -} - -function normalizedRows(value) { - if (!Array.isArray(value)) return null; - const rows = value.map(normalizedRow); - if (rows.some(row => row === null)) return null; - rows.sort(([left], [right]) => compare(left, right)); - if (rows.some((row, index) => index > 0 && rows[index - 1][0] === row[0])) return null; - return rows; -} - -function cacheState(value) { - const state = value?.version === 1 ? value : {}; - const snapshot = state.snapshot; - const rows = normalizedRows(snapshot?.rows); - const attempt = state.attempt; - return { - version: 1, - configurationRevision: Number.isInteger(state.configurationRevision) ? state.configurationRevision : 0, - rowRevision: Number.isInteger(state.rowRevision) ? state.rowRevision : 0, - snapshot: typeof snapshot?.sourceKey === "string" && Number.isFinite(snapshot.refreshedAt) && rows !== null - ? { sourceKey: snapshot.sourceKey, refreshedAt: snapshot.refreshedAt, rows } : null, - attempt: typeof attempt?.sourceKey === "string" && Number.isFinite(attempt.startedAt) - ? { - sourceKey: attempt.sourceKey, - startedAt: attempt.startedAt, - ...(Number.isFinite(attempt.finishedAt) ? { finishedAt: attempt.finishedAt } : {}), - } : null, - }; -} - -async function sourceFor(options) { - return ankiIndexSource(options.anki); -} - -export async function ankiIndexConfigurationChange(previous, next, value) { - const [before, after] = await Promise.all([sourceFor(previous), sourceFor(next)]); - if (before?.key === after?.key) return undefined; - const state = cacheState(value); - return { ...state, configurationRevision: state.configurationRevision + 1, attempt: null }; -} - -function rowMap(snapshot) { - return new Map((snapshot?.rows ?? []).map(([word, mature, noteIds]) => - [word, { mature, noteIds: [...noteIds] }])); -} - -function rowsFromMap(rows) { - return [...rows].sort(([left], [right]) => compare(left, right)) - .map(([word, row]) => [word, row.mature, [...row.noteIds]]); -} - -function sameRow(left, right) { - return left?.mature === right?.mature - && JSON.stringify(left?.noteIds ?? null) === JSON.stringify(right?.noteIds ?? null); -} - -function normalizedLookup(value, wordKey) { - if (!value || value.wordKey !== wordKey || typeof value.mature !== "boolean" - || !Array.isArray(value.noteIds) || !value.noteIds.every(positiveId)) { - throw new Error("Anki returned an invalid duplicate lookup result."); - } - return { - wordKey, - mature: value.mature, - noteIds: [...new Set(value.noteIds)].sort((left, right) => left - right), - }; -} - -function ownsAttempt(current, source, token) { - return current.attempt?.sourceKey === source.key && current.attempt.startedAt === token.startedAt; -} - -export function createAnkiDuplicateIndex({ - fetchRows, - lookupLive, - readOptions, - readState, - updateState, - alarms, - now = Date.now, - reportError = error => console.warn("hachidori: Anki index refresh failed:", error), -}) { - let snapshot = null; - let rows = new Map(); - let active = null; - // The reservation this worker made most recently. A stored attempt that has - // no recorded outcome and was not reserved here belongs to a worker that - // stopped mid-pull (host torn down, worker restarted): it is due now rather - // than at its 30-minute mark, which no alarm may be armed to reach. - let ownStartedAt = null; - let controlTail = Promise.resolve(); - let suspended = false; - const liveLookups = new Map(); - const hydrate = readState().then(value => { - snapshot = cacheState(value).snapshot; - rows = rowMap(snapshot); - }).catch(reportError); - - function install(value) { - snapshot = cacheState(value).snapshot; - rows = rowMap(snapshot); - } - - function control(job) { - const run = controlTail.then(job); - controlTail = run.catch(() => {}); - return run; - } - - async function schedule(when) { - const existing = await alarms.get(ANKI_INDEX_ALARM); - if (when === null) { - if (existing) await alarms.clear(ANKI_INDEX_ALARM); - } else if (!existing || existing.scheduledTime !== when || existing.periodInMinutes !== undefined) { - await alarms.create(ANKI_INDEX_ALARM, { when }); - } - } - - async function updateRow(source, wordKey, change) { - await hydrate; - return control(async () => { - let changed = false; - const saved = await updateState(async ({ options, state: value }) => { - const current = cacheState(value); - const currentSource = await sourceFor(options); - if (currentSource?.key !== source.key) return; - const currentRows = current.snapshot?.sourceKey === source.key ? rowMap(current.snapshot) : new Map(); - const previous = currentRows.get(wordKey) ?? null; - const next = change(previous); - if (next === null) currentRows.delete(wordKey); - else currentRows.set(wordKey, { - mature: next.mature, - noteIds: [...new Set(next.noteIds)].sort((left, right) => left - right), - }); - if (sameRow(previous, next)) return; - changed = true; - return { - ...current, - rowRevision: current.rowRevision + 1, - snapshot: { - sourceKey: source.key, - refreshedAt: current.snapshot?.sourceKey === source.key ? current.snapshot.refreshedAt : now(), - rows: rowsFromMap(currentRows), - }, - }; - }); - if (changed) install(saved); - }); - } - - async function pull(source, token) { - try { - const nextRows = normalizedRows(await fetchRows(source)); - if (nextRows === null) throw new Error("Anki returned an invalid index snapshot."); - await control(async () => { - let committed = false; - const saved = await updateState(async ({ options, state: value }) => { - const current = cacheState(value); - const currentSource = await sourceFor(options); - if (current.configurationRevision !== token.configurationRevision - || currentSource?.key !== source.key - || !ownsAttempt(current, source, token)) return; - // A repaired miss or confirmed write after this pull began must not - // be erased by a response that took its snapshot before that change. - if (current.rowRevision !== token.rowRevision) { - return { ...current, attempt: null }; - } - committed = true; - return { - ...current, - rowRevision: current.rowRevision + 1, - snapshot: { sourceKey: source.key, refreshedAt: now(), rows: nextRows }, - attempt: { ...current.attempt, finishedAt: now() }, - }; - }); - if (committed) install(saved); - }); - } catch (error) { - reportError(error); - // A pull that ran to failure keeps its 30-minute backoff; only a pull - // that never records an outcome is retried by the next worker start. - await updateState(async ({ state: value }) => { - const current = cacheState(value); - if (!ownsAttempt(current, source, token) || current.attempt.finishedAt !== undefined) return; - return { ...current, attempt: { ...current.attempt, finishedAt: now() } }; - }).catch(reportError); - } finally { - await control(() => { if (active?.token === token) active = null; }); - // A configuration or row change during the pull may require an immediate - // replacement. A successful or failed current pull remains due in 30 min. - void reconcile(); - } - } - - function refreshDue(attempt, source) { - if (attempt?.sourceKey !== source.key) return 0; - if (attempt.finishedAt === undefined && attempt.startedAt !== ownStartedAt) return 0; - return attempt.startedAt + ANKI_INDEX_REFRESH_MS; - } - - async function startDueRefresh() { - await hydrate; - if (suspended) { - await schedule(null); - return null; - } - const source = await sourceFor(await readOptions()); - if (!source) { - await schedule(null); - return null; - } - if (active) return { promise: active.promise }; - const state = cacheState(await readState()); - const due = refreshDue(state.attempt, source); - if (due > now()) { - await schedule(due); - return null; - } - - const token = { startedAt: now() }; - let reserved = false; - await updateState(async ({ options, state: value }) => { - const current = cacheState(value); - const currentSource = await sourceFor(options); - if (currentSource?.key !== source.key) return; - if (refreshDue(current.attempt, source) > now()) return; - reserved = true; - token.configurationRevision = current.configurationRevision; - token.rowRevision = current.rowRevision; - return { ...current, attempt: { sourceKey: source.key, startedAt: token.startedAt } }; - }); - if (!reserved) return null; - ownStartedAt = token.startedAt; - await schedule(token.startedAt + ANKI_INDEX_REFRESH_MS); - const promise = pull(source, token); - active = { token, promise }; - return { promise }; - } - - function reconcile() { - return control(startDueRefresh).then(job => job?.promise).catch(reportError); - } - - async function suspend() { - suspended = true; - await hydrate; - // Let a refresh which already reserved its pull publish `active`, then - // wait outside the control queue so its completion can use that queue. - await control(async () => {}); - await active?.promise; - await schedule(null); - } - - function resume() { - suspended = false; - return reconcile(); - } - - async function local(config, expression) { - const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]); - if (source === null || wordKey === null) { - return { source, wordKey, mature: false, noteIds: [], cached: false }; - } - await hydrate; - const cached = snapshot?.sourceKey === source.key ? rows.get(wordKey) : null; - return cached - ? { source, wordKey, mature: cached.mature, noteIds: [...cached.noteIds], cached: true } - : { source, wordKey, mature: false, noteIds: [], cached: false }; - } - - async function find(config, expression, invoke, force) { - const cached = await local(config, expression); - const { source, wordKey } = cached; - if (source === null || wordKey === null) { - return { wordKey, mature: false, noteIds: [], cached: false }; - } - if (!force && cached.cached) { - return { wordKey, mature: cached.mature, noteIds: cached.noteIds, cached: true }; - } - const liveKey = `${source.key}\n${wordKey}\n${force ? "repair" : "lookup"}`; - let operation = liveLookups.get(liveKey); - if (!operation) { - operation = Promise.resolve(lookupLive(source, expression, invoke)) - .then(value => normalizedLookup(value, wordKey)) - .then(async value => { - if (value.noteIds.length) { - await updateRow(source, wordKey, () => ({ mature: value.mature, noteIds: value.noteIds })); - } else if (force) { - await updateRow(source, wordKey, () => null); - } - return value; - }) - .finally(() => liveLookups.delete(liveKey)); - liveLookups.set(liveKey, operation); - } - return { ...await operation, cached: false }; - } - - return { - reconcile, - suspend, - resume, - source: ankiIndexSource, - async peek(config, expression) { - const result = await local(config, expression); - return { wordKey: result.wordKey, mature: result.mature, noteIds: result.noteIds, cached: result.cached }; - }, - lookup: (config, expression, invoke) => find(config, expression, invoke, false), - repair: (config, expression, invoke) => find(config, expression, invoke, true), - async recordWrite(config, expression, noteId, { mature = false } = {}) { - if (!positiveId(noteId)) throw new Error("Anki returned an invalid written note ID."); - const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]); - if (source === null || wordKey === null) return; - await updateRow(source, wordKey, previous => ({ - mature: previous?.mature ?? mature, - noteIds: [...(previous?.noteIds ?? []), noteId], - })); - }, - async has(config, expression) { - const [source, wordKey] = await Promise.all([ankiIndexSource(config), Promise.resolve(ankiWordKey(expression))]); - if (source === null || wordKey === null) return false; - await hydrate; - return snapshot?.sourceKey === source.key && rows.get(wordKey)?.mature === true; - }, - }; -} diff --git a/vendor/hachidori/extension/anki-index-worker.js b/vendor/hachidori/extension/anki-index-worker.js deleted file mode 100644 index 784b2dbe..00000000 --- a/vendor/hachidori/extension/anki-index-worker.js +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { createAnkiGateway } from "./anki.js"; -import { fetchAnkiIndex } from "./anki-index.js"; - -self.onmessage = async ({ data: source }) => { - try { - const gateway = createAnkiGateway(); - const invoke = (action, params, timeoutMs) => - gateway.invoke(action, params, source.apiKey, timeoutMs, source.url); - self.postMessage({ rows: await fetchAnkiIndex(invoke, source) }); - } catch (error) { - self.postMessage({ error: error.message || String(error) }); - } -}; diff --git a/vendor/hachidori/extension/anki-index.js b/vendor/hachidori/extension/anki-index.js deleted file mode 100644 index 08aa8927..00000000 --- a/vendor/hachidori/extension/anki-index.js +++ /dev/null @@ -1,265 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiMultiResults } from "./anki.js"; -import { ankiDigest } from "./anki-digest.js"; -import { ankiSetupFamily, ankiSetupTemplates } from "./anki-setup.js"; -import { escapeAnkiHtml, resolveAnkiTemplates } from "./anki-templates.js"; -import "./reader-options.js"; - -// Anki parses these field names as operators before considering a field search. -// They cannot identify a direct expression value reliably. -const SEARCH_OPERATORS = new Set(["deck", "note", "tag", "card", "flag", "resched", "prop", "added", "edited", - "introduced", "rated", "is", "did", "mid", "nid", "cid", "re", "nc", "sc", "w", "dupe", "has-cd", "preset"]); -const positiveId = value => Number.isSafeInteger(value) && value > 0; -const foldAscii = value => value.replace(/[A-Z]/gu, character => character.toLowerCase()); -const nameKey = value => value.normalize("NFC").toLowerCase(); -const compare = (left, right) => Number(left > right) - Number(left < right); -const escapeQuery = value => value.replace(/[\\"*_:]/gu, String.raw`\$&`); -const searchToken = (operator, value) => `"${escapeQuery(operator)}:${escapeQuery(value)}"`; - -function directExpressionFields(config, names = config.fieldTemplates === null - ? Object.values(config.fields).filter(Boolean) : Object.keys(config.fieldTemplates)) { - const { templates } = resolveAnkiTemplates(config, names); - return [...new Set(Object.entries(templates) - .filter(([field, template]) => /^\{expression\}$/iu.test(template.value) - && !SEARCH_OPERATORS.has(field.toLowerCase())) - .map(([field]) => nameKey(field)))].sort(compare); -} - -export function ankiWordKey(expression) { - if (typeof expression !== "string" || !expression) return null; - // Ordinary Anki field search escapes the rendered HTML, folds ASCII only, - // and normalizes query text to NFC. Stored field values remain unnormalized. - return foldAscii(escapeAnkiHtml(expression).normalize("NFC")); -} - -function storedWordKey(value) { - return typeof value === "string" && value ? foldAscii(value) : null; -} - -export async function ankiIndexSource(config) { - if (!config.model) return null; - const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl( - config.url === undefined ? globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki.url : config.url - ); - if (!url) return null; - const fields = directExpressionFields(config); - if (!fields.length) return null; - const source = { - url, - apiKey: config.apiKey, - scope: config.duplicateScope, - model: config.model, - fields, - ...(config.duplicateScope === "deck" ? { deck: config.deck } : {}), - }; - return { key: await ankiDigest(new TextEncoder().encode(JSON.stringify(source))), ...source }; -} - -function modelMap(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("AnkiConnect returned an invalid note type list."); - } - return value; -} - -function fieldList(value) { - if (!Array.isArray(value) || value.some(field => typeof field !== "string" || !field)) { - throw new Error("AnkiConnect returned an invalid field list."); - } - return value; -} - -async function recognizedModels(invoke, source) { - if (source.scope === "model") return [{ name: source.model, fields: source.fields }]; - const available = modelMap(await invoke("modelNamesAndIds", {})); - const models = []; - if (positiveId(available[source.model])) { - models.push({ name: source.model, fields: source.fields }); - } - const base = globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki; - for (const [model, id] of Object.entries(available)) { - if (model === source.model) continue; - const family = ankiSetupFamily(model); - if (family === null || !positiveId(id)) continue; - const fields = fieldList(await invoke("modelFieldNames", { modelName: model })); - const templates = ankiSetupTemplates(family, model, source.deck ?? "Default", fields, base); - if (templates === null) continue; - const expressionFields = directExpressionFields({ ...base, model, fieldTemplates: templates }, fields); - if (expressionFields.length) models.push({ name: model, fields: expressionFields }); - } - return models; -} - -function modelQuery(models) { - const clauses = models.map(model => searchToken("note", model.name)); - if (clauses.length === 0) return null; - return clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`; -} - -function scopedQuery(source, query) { - return source.scope === "deck" ? `${query} ${searchToken("deck", source.deck)}` : query; -} - -function completeQuery(source, models) { - const query = modelQuery(models); - return query === null ? null : scopedQuery(source, query); -} - -function lookupQuery(source, models, expression) { - const value = escapeAnkiHtml(expression).normalize("NFC"); - const clauses = models.flatMap(model => model.fields.map(field => - `(${searchToken("note", model.name)} ${searchToken(field, value)})`)); - if (clauses.length === 0) return null; - const query = clauses.length === 1 ? clauses[0] : `(${clauses.join(" or ")})`; - return scopedQuery(source, query); -} - -function noteFields(info) { - if (!info?.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) { - throw new Error("AnkiConnect returned invalid note details."); - } - const entries = Object.entries(info.fields).map(([field, value]) => - [field, typeof value === "string" ? value : value?.value]); - if (entries.some(([, value]) => typeof value !== "string")) { - throw new Error("AnkiConnect returned invalid note details."); - } - return Object.fromEntries(entries); -} - -function indexedNotes(value, models, expectedIds = null) { - if (!Array.isArray(value)) throw new Error("AnkiConnect returned invalid note details."); - const byModel = new Map(models.map(model => [nameKey(model.name), model])); - const expected = expectedIds === null ? null : new Set(expectedIds); - const seen = new Set(); - const notes = value.map(info => { - if (!positiveId(info?.noteId) || typeof info.modelName !== "string") { - throw new Error("AnkiConnect returned invalid note details."); - } - if ((expected && !expected.has(info.noteId)) || seen.has(info.noteId)) { - throw new Error("AnkiConnect returned invalid note details."); - } - seen.add(info.noteId); - const model = byModel.get(nameKey(info.modelName)); - if (!model) throw new Error("AnkiConnect returned notes outside the requested note types."); - const fields = noteFields(info); - const names = new Map(Object.keys(fields).map(field => [nameKey(field), field])); - if (model.fields.some(field => !names.has(field))) { - throw new Error("AnkiConnect returned invalid note details."); - } - return { noteId: info.noteId, model, fields, names }; - }); - if (expected && seen.size !== expected.size) throw new Error("AnkiConnect returned invalid note details."); - return notes; -} - -function returnedNoteIds(value, message = "AnkiConnect returned invalid note IDs.") { - if (!Array.isArray(value) || !value.every(positiveId)) { - throw new Error(message); - } - return [...new Set(value)].sort((left, right) => left - right); -} - -const matureQuery = query => `${query} is:review -is:learn prop:ivl>=21`; - -function matureNoteIds(value, candidates) { - const mature = new Set(returnedNoteIds(value, "AnkiConnect returned invalid mature note IDs.")); - if ([...mature].some(noteId => !candidates.has(noteId))) { - throw new Error("AnkiConnect returned invalid mature note IDs."); - } - return mature; -} - -function compactRows(notes, mature) { - const rows = new Map(); - for (const note of notes) { - for (const field of note.model.fields) { - const key = storedWordKey(note.fields[note.names.get(field)]); - if (key === null) continue; - const row = rows.get(key) ?? { mature: false, noteIds: new Set() }; - row.noteIds.add(note.noteId); - row.mature ||= mature.has(note.noteId); - rows.set(key, row); - } - } - return [...rows].sort(([left], [right]) => compare(left, right)) - .map(([word, row]) => [word, row.mature, [...row.noteIds].sort((left, right) => left - right)]); -} - -export async function fetchAnkiIndex(invoke, source) { - const models = await recognizedModels(invoke, source); - const query = completeQuery(source, models); - if (query === null) return []; - const [candidateResult, matureResult] = await Promise.all([ - invoke("findNotes", { query }, 25_000), - invoke("findNotes", { query: matureQuery(query) }, 25_000), - ]); - const candidateIds = returnedNoteIds(candidateResult); - const mature = matureNoteIds(matureResult, new Set(candidateIds)); - if (!candidateIds.length) return []; - const infos = await invoke("notesInfo", { notes: candidateIds }, 25_000); - return compactRows(indexedNotes(infos, models, candidateIds), mature); -} - -// A popup cache miss waits on this, and each AnkiConnect request costs one -// poll interval, so the candidate and maturity searches share one `multi` -// round trip and `notesInfo` is the only other stage. Maturity is the scoped -// query's mature subset intersected with the exactly matching notes: Anki -// searches cards, so in deck scope a note counts as mature only through a -// mature card inside the configured deck, exactly as the complete index does. -export async function lookupAnkiIndex(invoke, source, expression) { - const wordKey = ankiWordKey(expression); - if (wordKey === null) return { wordKey, mature: false, noteIds: [] }; - const models = await recognizedModels(invoke, source); - const query = lookupQuery(source, models, expression); - if (query === null) return { wordKey, mature: false, noteIds: [] }; - const [candidateResult, matureResult] = ankiMultiResults(await invoke("multi", { actions: [ - { action: "findNotes", params: { query } }, - { action: "findNotes", params: { query: matureQuery(query) } }, - ] })); - const candidateIds = returnedNoteIds(candidateResult); - const mature = matureNoteIds(matureResult, new Set(candidateIds)); - if (!candidateIds.length) return { wordKey, mature: false, noteIds: [] }; - const candidates = indexedNotes(await invoke("notesInfo", { notes: candidateIds }), models, candidateIds); - const noteIds = []; - for (const note of candidates) { - if (note.model.fields.some(field => storedWordKey(note.fields[note.names.get(field)]) === wordKey)) { - noteIds.push(note.noteId); - } - } - noteIds.sort((left, right) => left - right); - const unique = [...new Set(noteIds)]; - return { wordKey, mature: unique.some(noteId => mature.has(noteId)), noteIds: unique }; -} - -export async function inspectAnkiNoteIds(invoke, source, expression, noteIds) { - if (!Array.isArray(noteIds) || !noteIds.every(positiveId)) { - throw new Error("Anki duplicate inspection requires valid note IDs."); - } - const ids = [...new Set(noteIds)].sort((left, right) => left - right); - const infos = await invoke("notesInfo", { notes: ids }); - if (!Array.isArray(infos)) throw new Error("AnkiConnect returned invalid duplicate note details."); - const requested = new Set(ids); - const byId = new Map(); - for (const info of infos) { - if (!positiveId(info?.noteId) || !requested.has(info.noteId) || byId.has(info.noteId) - || typeof info.modelName !== "string") { - throw new Error("AnkiConnect returned invalid duplicate note details."); - } - byId.set(info.noteId, info); - } - const wordKey = ankiWordKey(expression); - let stale = byId.size !== ids.length; - let target = null; - for (const noteId of ids) { - const info = byId.get(noteId); - if (!info || nameKey(info.modelName) !== nameKey(source.model)) continue; - const fields = noteFields(info); - const names = new Map(Object.keys(fields).map(field => [nameKey(field), field])); - if (wordKey === null || !source.fields.some(field => storedWordKey(fields[names.get(field)]) === wordKey)) { - stale = true; - continue; - } - target ??= { noteId, fields }; - } - return { stale, target }; -} diff --git a/vendor/hachidori/extension/anki-media.js b/vendor/hachidori/extension/anki-media.js deleted file mode 100644 index 81c74698..00000000 --- a/vendor/hachidori/extension/anki-media.js +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { decodedBase64Length } from "./anki-client-media.js"; - -const GENERATED_MEDIA_FILENAME = /^hachidori_[0-9a-f]{64}\.[a-z0-9]+$/u; - -function validatePayload(data, kind) { - const bytes = decodedBase64Length(data); - if (bytes === null || bytes < 1) throw new Error(`The ${kind} payload is not valid base64.`); - return bytes; -} - -async function exactMediaExists(invoke, filename) { - const names = await invoke("getMediaFilesNames", { pattern: filename }, 10_000); - if (!Array.isArray(names) || names.some(name => typeof name !== "string")) { - throw new Error("Anki returned an invalid media inventory."); - } - return names.includes(filename); -} - -function referenced(fields, filename) { - return Object.values(fields).some(value => typeof value === "string" && value.includes(filename)); -} - -function requiredDictionaryMedia(resources, fields) { - if (!Array.isArray(resources.media)) throw new Error("The planned dictionary media is invalid."); - const required = new Map(); - for (const item of resources.media) { - if (!item || typeof item !== "object" || typeof item.filename !== "string" - || typeof item.dictionary !== "string" || typeof item.path !== "string") { - throw new Error("The planned dictionary media entry is invalid."); - } - if (!referenced(fields, item.filename)) continue; - const previous = required.get(item.filename); - if (previous && (previous.dictionary !== item.dictionary || previous.path !== item.path)) { - throw new Error(`Two dictionary resources planned the same Anki filename: ${item.filename}`); - } - required.set(item.filename, item); - } - return [...required.values()]; -} - -async function ensure({ - invoke, - filename, - kind, - data, - load, - validate, -}) { - if (!GENERATED_MEDIA_FILENAME.test(filename)) { - throw new Error("The generated Anki media filename is invalid."); - } - await validate?.(); - const exists = await exactMediaExists(invoke, filename); - await validate?.(); - if (exists) { - return { filename, status: "existing", bytes: 0 }; - } - const loaded = data === undefined ? await load() : { data }; - const payload = typeof loaded === "string" ? loaded : loaded?.data; - const bytes = validatePayload(payload, kind); - await validate?.(); - let stored; - try { - stored = await invoke("storeMediaFile", { filename, data: payload, deleteExisting: false }, 30_000); - } catch (error) { - // A timed-out acknowledgement may follow a completed write. Confirm the - // exact deterministic name before deciding that the note cannot proceed. - if (await exactMediaExists(invoke, filename).catch(() => false)) { - return { filename, status: "confirmed-after-error", bytes }; - } - throw error; - } - if (stored !== filename) { - const exists = await exactMediaExists(invoke, filename); - if (!exists) throw new Error(`Anki stored ${kind} under a different filename.`); - return { filename, status: "existing-race", bytes }; - } - if (!await exactMediaExists(invoke, filename)) { - throw new Error(`Anki acknowledged ${kind} without confirming the requested media filename.`); - } - return { filename, status: "stored", bytes }; -} - -export function createAnkiMediaStore() { - async function prepare({ - request, - invoke, - appliedFields, - resources, - media, - validate, - }) { - const files = []; - try { - for (const item of requiredDictionaryMedia(resources, appliedFields)) { - files.push(await ensure({ - invoke, - filename: item.filename, - kind: "dictionary image", - validate, - load: () => media(item, request.generation), - })); - } - if (resources.audioPrepared && resources.audio && referenced(appliedFields, resources.audio.filename)) { - files.push(await ensure({ - invoke, - filename: resources.audio.filename, - data: resources.audio.data, - kind: "pronunciation", - validate, - })); - } - } catch (error) { - const retained = files.length; - if (retained > 0) { - throw new Error(`${error.message} ${retained} confirmed media ${retained === 1 ? "file was" : "files were"} retained for a safe retry.`, { cause: error }); - } - throw error; - } - resources.confirmedMedia = files.map(file => file.filename); - return { - files, - required: files.length, - existing: files.filter(file => file.status !== "stored").length, - stored: files.filter(file => file.status === "stored").length, - uploadedBytes: files.reduce((sum, file) => sum + (file.status === "stored" ? file.bytes : 0), 0), - }; - } - - return { ensure, prepare }; -} diff --git a/vendor/hachidori/extension/anki-mining.js b/vendor/hachidori/extension/anki-mining.js deleted file mode 100644 index 66c0c446..00000000 --- a/vendor/hachidori/extension/anki-mining.js +++ /dev/null @@ -1,475 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiAvailability, isUndispatchedAnkiTransportError } from "./anki.js"; -import { ankiCaptureRequirements, resolveAnkiTemplates } from "./anki-templates.js"; -import { ankiDigest } from "./anki-digest.js"; -import { ankiSetupFamily } from "./anki-setup.js"; -import { inspectAnkiNoteIds } from "./anki-index.js"; -import { ankiBrowseQuery, ankiNoteIdsQuery, ankiNoteOptions, canonicalAnkiFields, checkAnkiDuplicate, findAnkiDuplicateNotes, - isAnkiDuplicateError, overwriteAnkiFields, validateAnkiNote } from "./anki-duplicates.js"; - -const CONFIG_CHANGED = "Anki configuration changed. Refresh this result before adding a note."; -const AUTOMATIC_CAPTURE_FIELDS = { - kiku: { picture: "Picture", audio: "SentenceAudio" }, - lapis: { picture: "Picture", audio: "SentenceAudio" }, - senren: { picture: "picture", audio: "sentenceAudio" }, -}; - -function requestConfiguration(current, request) { - const fields = AUTOMATIC_CAPTURE_FIELDS[ankiSetupFamily(current.config.model)]; - if (!fields) return current; - const templates = Object.fromEntries(Object.entries(current.resolved.templates) - .map(([field, template]) => [field, { ...template }])); - const routed = { ...current, resolved: { ...current.resolved, templates } }; - const capture = current.config.mediaCapture; - if (!request.capturePin || capture?.enabled !== true) return routed; - if (capture.includeAnimation === true && templates[fields.picture]) { - templates[fields.picture].value = templates[fields.picture].value - .replaceAll("{screenshot}", "{capture-animation}"); - } - if (capture.includeCapturedAudio === true && templates[fields.audio]?.value.trim() === "") { - templates[fields.audio].value = "{capture-audio}"; - } - return routed; -} - -export async function readAnkiNoteFields(invoke, noteId) { - const infos = await invoke("notesInfo", { notes: [noteId] }); - const info = Array.isArray(infos) ? infos.find(value => value.noteId === noteId) : null; - if (!info?.fields || typeof info.fields !== "object" || Array.isArray(info.fields)) throw new Error("Anki did not return the saved note fields."); - return Object.fromEntries(Object.entries(info.fields).map(([field, value]) => [field, value?.value])); -} - -export async function verifyAnkiFields(invoke, noteId, expected) { - const fields = await readAnkiNoteFields(invoke, noteId); - const missing = [], changed = []; - for (const [field, value] of Object.entries(expected)) { - if (typeof fields[field] !== "string") missing.push(field); - else if (fields[field].normalize("NFC") !== value.normalize("NFC")) changed.push(field); - } - if (missing.length === 0 && changed.length === 0) return; - const list = names => names.map(name => `“${name}”`).join(", "); - const parts = []; - if (missing.length) parts.push(`${missing.length === 1 ? "field" : "fields"} ${list(missing)} ${missing.length === 1 ? "is" : "are"} missing from note ${noteId}`); - if (changed.length) parts.push(`${changed.length === 1 ? "field" : "fields"} ${list(changed)} ${changed.length === 1 ? "was" : "were"} saved with different content`); - throw new Error(`Anki's saved note differs from the submitted values: ${parts.join("; ")}. Inspect note ${noteId} in Anki.`); -} - -async function addableDecision(prepared) { - const check = await validateAnkiNote(prepared.invoke, prepared.note); - return { state: check.addable ? "addable" : "invalid", canAdd: check.addable, error: check.error }; -} - -async function unindexedDecision(prepared) { - const { invoke, note, config, firstField } = prepared; - const checked = await checkAnkiDuplicate(invoke, note, config); - if (!checked.duplicate) { - return { state: checked.addable ? "addable" : "invalid", canAdd: checked.addable, error: checked.error }; - } - // A non-direct destination field cannot be keyed by the word index. Keep - // Anki's exact first-field identity as a compatibility path, restricted to - // the configured destination type so unrelated custom models never block. - const matches = await findAnkiDuplicateNotes(invoke, note, firstField, config); - const noteIds = matches.map(match => match.noteId); - if (config.duplicateBehavior === "overwrite") { - const target = matches.find(match => match.fields !== null) ?? null; - return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds, mature: false, - error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." }; - } - if (config.duplicateBehavior === "new") { - const addable = await addableDecision(prepared); - if (!addable.canAdd) return addable; - } - return { - state: "duplicate", - canAdd: config.duplicateBehavior === "new", - error: null, - noteIds, - mature: false, - }; -} - -async function decision(prepared, request, duplicateIndex) { - const { invoke, config } = prepared; - const expression = request.term?.expression ?? request.expression; - const source = await duplicateIndex.source(config); - if (source === null) return unindexedDecision(prepared); - let duplicate = await duplicateIndex.lookup(config, expression, invoke); - if (!duplicate.noteIds.length) return addableDecision(prepared); - if (config.duplicateBehavior === "overwrite") { - let inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds); - if (inspected.stale) { - duplicate = await duplicateIndex.repair(config, expression, invoke); - if (!duplicate.noteIds.length) return addableDecision(prepared); - inspected = await inspectAnkiNoteIds(invoke, source, expression, duplicate.noteIds); - } - const target = inspected.target; - return { state: "duplicate", canAdd: target !== null, action: "overwrite", target, noteIds: duplicate.noteIds, - mature: duplicate.mature, - error: target ? null : "A duplicate exists, but no matching configured note type is inside the selected scope." }; - } - if (config.duplicateBehavior === "new") { - const addable = await addableDecision(prepared); - if (!addable.canAdd) return addable; - } - return { state: "duplicate", canAdd: config.duplicateBehavior === "new", error: null, - noteIds: duplicate.noteIds, mature: duplicate.mature }; -} - -function omitUnchangedFields(fields, existing) { - if (existing) for (const [field, value] of Object.entries(fields)) { - if (value === existing[field]) delete fields[field]; - } - return fields; -} - -function fieldsForDecision(prepared, checked) { - const target = checked.target; - if (!target) { - return { - fields: prepared.note.fields, - target: null, - templates: prepared.resolved.templates, - }; - } - const canonical = canonicalAnkiFields(prepared.note.fields, prepared.resolved.templates, target.fields); - // Only the initial write omits unchanged values. Pronunciation enrichment - // compares its complete desired value with the text-only write it replaces. - const fields = omitUnchangedFields(overwriteAnkiFields(canonical.fields, target.fields, canonical.templates), target.fields); - return { - fields, - target, - templates: Object.fromEntries(Object.entries(canonical.templates).filter(([field]) => Object.hasOwn(fields, field))), - }; -} - -function captureForApplication(request, templates) { - const requirements = ankiCaptureRequirements(templates); - const unavailable = new Set(Array.isArray(request.captureUnavailable) ? request.captureUnavailable : []); - requirements.includeAnimation &&= !unavailable.has("animation"); - requirements.includeAudio &&= !unavailable.has("audio"); - if (!requirements.includeAnimation && !requirements.includeAudio) return null; - const pin = request.capturePin; - return { - requirements, - sourceLabel: pin?.sourceLabel, - partial: pin?.partial === true, - readyAtMs: pin?.readyAtMs, - }; -} - -// Names the looked-up word in an error, so a reader mining several results -// can tell which one Anki refused. -function describeRequestTerm(request) { - const expression = request?.term?.expression ?? request?.expression; - return typeof expression === "string" && expression.trim() ? `“${expression}”` : "this result"; -} - -async function writeAnkiNote(invoke, note, target, fields, duplicateNoteIds) { - let noteId; - if (target) { - const reply = await invoke("updateNoteFields", { note: { id: target.noteId, fields }, subminerEnrich: true }, 10_000); - if (reply !== null) throw new Error("Anki returned an invalid field-update acknowledgement."); - noteId = target.noteId; - } else { - try { - noteId = await invoke("addNote", { note, subminerDuplicateNoteIds: duplicateNoteIds }, 10_000); - } catch (error) { - throw addNoteContext(error, note); - } - } - if (!Number.isSafeInteger(noteId) || noteId <= 0) { - throw new Error(`Anki did not return a valid note ID for the ${target ? "updated" : "added"} note in deck “${note.deckName}”. Check the note in Anki.`); - } - return noteId; -} - -// AnkiConnect's "empty" refusal names neither the note nor the field; Anki -// strips HTML before judging, so a first field this side considered filled -// can still be refused. A duplicate refusal is described by the caller. -function addNoteContext(error, note) { - const message = error?.message ?? String(error); - const [firstField] = Object.keys(note.fields ?? {}); - const firstValue = firstField === undefined ? "" : String(note.fields[firstField] ?? ""); - const context = `deck “${note.deckName}”, note type “${note.modelName}”`; - if (!/cannot create note because it is empty/iu.test(message)) return error; - const detail = `Anki refused the note for ${context} because its first field “${firstField}” is empty` - + `${firstValue.trim() ? " once Anki stripped its formatting" : ""}.`; - const raw = (/\(AnkiConnect: (.+)\)$/u.exec(message)?.[1] ?? message).replace(/^AnkiConnect: /u, ""); - return new Error(`${detail} (AnkiConnect: ${raw})`, { cause: error }); -} - -export function createAnkiMiningService({ - gateway, - readConfig, - buildFields, - beforeWrite, - beforeMutation = async () => {}, - afterConfirmed = async () => {}, - afterRejected = async () => {}, - preflightExtra = async () => ({}), - validateCapture = async () => {}, - enrich, - duplicateIndex, - now = Date.now, -}) { - const cached = new Map(); - let mutations = Promise.resolve(); - const invokeFor = config => (action, params, timeoutMs) => gateway.invoke(action, params, config.apiKey, timeoutMs, config.url); - - async function identity(templateId) { - const config = await readConfig(templateId); - if (!config) throw new Error("The selected Anki Template is no longer available."); - const configJson = JSON.stringify(config); - const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({ - templateId: templateId ?? null, - config, - }))); - return { config, configJson, configKey, templateId: templateId ?? null }; - } - - async function configuration(templateId, fresh = false) { - const current = await identity(templateId); - const { config, configJson } = current; - const cacheKey = templateId ?? ""; - const previous = cached.get(cacheKey); - if (!fresh && previous?.key === configJson && now() < previous.expires) return previous.promise; - const promise = (async () => { - // Correlate reader requests without returning the saved API key/source - // credentials in a serialized configuration string to each content script. - const { configKey } = current; - if (!config.model) return { config, configKey, configJson, errors: ["Choose an Anki note type in Settings."] }; - const discovery = await gateway.discover(config); - const resolved = resolveAnkiTemplates(config, discovery.fields); - return { config, configKey, configJson, discovery, resolved, errors: ankiAvailability(config, discovery, resolved) }; - })(); - // GSM's two-second status cache, sharing concurrent callers as well. Only - // read-only preparation may use it; each submission refreshes discovery. - cached.set(cacheKey, { key: configJson, expires: now() + 2000, promise }); - return promise; - } - - async function status(templateId) { - const current = await configuration(templateId); - return { available: current.errors.length === 0, configKey: current.configKey, error: current.errors.join("\n") }; - } - - async function view(request) { - const current = await identity(request?.templateId); - const expression = request?.term?.expression ?? request?.expression; - const unknown = { - state: "unknown", - canAdd: false, - noteIds: [], - configKey: current.configKey, - cached: false, - }; - if (current.config.duplicateBehavior !== "prevent") return unknown; - const duplicate = await duplicateIndex.peek(current.config, expression); - if (!duplicate.noteIds.length) return unknown; - return { - state: "duplicate", - canAdd: false, - noteIds: duplicate.noteIds, - mature: duplicate.mature, - configKey: current.configKey, - cached: true, - }; - } - - async function prepare(request, fresh) { - const configured = await configuration(request?.templateId, fresh); - const current = requestConfiguration(configured, request); - if (request.configKey !== current.configKey) throw new Error(CONFIG_CHANGED); - if (current.errors.length) throw new Error(current.errors.join("\n")); - const resources = await buildFields(request, current, { preflight: !fresh }); - const { fields } = resources; - const firstField = current.discovery.fields[0]; - if (!fields[firstField]?.trim()) { - const template = current.resolved.templates[firstField]?.value ?? ""; - throw new Error(`The first field of note type “${current.config.model}”, “${firstField}”, is empty for this result` - + `${template ? `: its template ${template} produced nothing for ${describeRequestTerm(request)}` : ""}. Anki requires it.`); - } - const note = { deckName: current.config.deck, modelName: current.config.model, fields, - options: ankiNoteOptions(current.config), tags: [...new Set(current.config.tags)] }; - return { ...current, note, resources, firstField, invoke: invokeFor(current.config) }; - } - - async function preflight(request) { - const prepared = await prepare(request, false); - if (prepared.resources.deferDuplicateCheck === true) { - const capture = captureForApplication(request, prepared.resolved.templates); - if (capture) await validateCapture({ request, prepared, capture }); - const extra = await preflightExtra({ request, prepared, applied: null, deferred: true }); - return { - state: "addable", - canAdd: true, - error: null, - deferred: true, - capture, - screenshot: prepared.config.captureScreenshot === true - && ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot, - ...(extra ?? {}), - }; - } - const result = await decision(prepared, request, duplicateIndex); - const applied = result.canAdd ? fieldsForDecision(prepared, result) : null; - const capture = applied ? captureForApplication(request, applied.templates) : null; - if (capture) await validateCapture({ request, prepared, capture }); - const extra = await preflightExtra({ request, prepared, applied, deferred: false }); - return { - state: result.state, - canAdd: result.canAdd, - error: result.error, - action: result.action, - noteIds: result.noteIds, - capture, - // A mapped {screenshot} that the user has left switched on: the reader - // takes the viewport picture itself, when it submits. The whole - // request-specific mapping decides, not the subset this preflight would - // apply, because the authoritative decision is made again inside the - // write and may then apply a field this one would have kept. - screenshot: prepared.config.captureScreenshot === true - && ankiCaptureRequirements(prepared.resolved.templates).includeScreenshot, - ...(extra ?? {}), - }; - } - - async function write(request) { - const prepared = await prepare(request, true); - const checked = await decision(prepared, request, duplicateIndex); - if (!checked.canAdd) return { state: checked.state, error: checked.error, - action: checked.action, noteIds: checked.noteIds }; - const { config, configJson, firstField, note, invoke } = prepared; - const { fields, target, templates } = fieldsForDecision(prepared, checked); - const capture = captureForApplication(request, templates); - if (capture) await validateCapture({ request, prepared, capture }); - if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) throw new Error(CONFIG_CHANGED); - const writeResources = await beforeWrite({ - request, - ...prepared, - target, - appliedFields: fields, - capture, - }); - // Failed media can restore a field's original value after preparation. - // Leave it untouched instead of overwriting an intervening Anki edit. - omitUnchangedFields(fields, target?.fields); - // A definitive no-write releases whatever only this note would have used. - // An uncertain write keeps it: the note may exist in Anki after all. - const releaseRejected = () => afterRejected({ request, ...prepared, writeResources }) - .catch(() => undefined); - if (JSON.stringify(await readConfig(request?.templateId)) !== configJson) { - await releaseRejected(); - throw new Error(CONFIG_CHANGED); - } - // Uploads and configuration reads can outlive Stop. Validate the remaining - // write ownership last, with no unrelated await before sending the mutation. - try { - await beforeMutation({ request, capture, writeResources }); - } catch (error) { - await releaseRejected(); - throw error; - } - let noteId; - try { - noteId = await writeAnkiNote(invoke, note, target, fields, checked.noteIds ?? []); - } catch (error) { - if (isAnkiDuplicateError(error.message)) { - await releaseRejected(); - let noteIds = []; - try { - const expression = request.term?.expression ?? request.expression; - noteIds = await duplicateIndex.source(config) === null - ? (await findAnkiDuplicateNotes(invoke, note, firstField, config)).map(match => match.noteId) - : (await duplicateIndex.repair(config, expression, invoke)).noteIds; - } catch { /* The duplicate result is definitive even if browse discovery fails. */ } - const firstValue = String(fields[firstField] ?? note.fields[firstField] ?? "").trim(); - return { state: "duplicate", noteIds, - error: `Anki already has a note in deck “${note.deckName}” (note type “${note.modelName}”) whose first field “${firstField}” is ` - + `${firstValue ? `“${firstValue}”` : "empty"}.` }; - } - if (isUndispatchedAnkiTransportError(error)) { - // A failed endpoint generation rejected this queued mutation before it - // entered fetch. Its note cannot exist, so release request-owned media - // and return a definitive retryable failure instead of uncertainty. - await releaseRejected(); - throw error; - } - // A lost acknowledgement may follow a completed write. Neither this - // worker nor the reader retries it automatically, including append modes. - return { state: "uncertain", error: `The write could not be confirmed. Check Anki before trying again. ${error.message}` }; - } - const warnings = [...(Array.isArray(writeResources?.warnings) ? writeResources.warnings : [])]; - try { - await duplicateIndex.recordWrite(config, request.term?.expression ?? request.expression, noteId, - { mature: checked.mature === true }); - } catch (error) { - warnings.push(`Duplicate index: ${error.message}`); - } - let verified = false; - try { - await verifyAnkiFields(invoke, noteId, fields); - verified = true; - } catch (error) { - warnings.push(error.message); - } - try { - await afterConfirmed({ - request, - ...prepared, - noteId, - existingFields: target?.fields, - appliedFields: fields, - capture, - writeResources, - verified, - }); - } catch (error) { - warnings.push(`Captured media cleanup: ${error.message}`); - } - if (verified) { - try { - warnings.push(...await enrich({ request, ...prepared, noteId, existingFields: target?.fields, appliedFields: fields })); - } catch (error) { - warnings.push(error.message); - } - } - cached.delete(request?.templateId ?? ""); - return { state: target ? "updated" : "added", noteId, warnings }; - } - - function submit(request) { - const operation = mutations.then(() => write(request)); - mutations = operation.catch(() => {}); - return operation; - } - - async function browse(request) { - const value = typeof request === "string" ? { expression: request } : request; - const config = await readConfig(value?.templateId); - if (!config) throw new Error("The selected Anki Template is no longer available."); - if (typeof value?.configKey === "string") { - const configKey = await ankiDigest(new TextEncoder().encode(JSON.stringify({ - templateId: value?.templateId ?? null, - config, - }))); - if (value.configKey !== configKey) throw new Error(CONFIG_CHANGED); - } - const invoke = invokeFor(config); - const supplied = Array.isArray(value?.noteIds) && value.noteIds.length; - let noteIds = supplied ? [...value.noteIds] : []; - let repaired = false; - if (supplied && typeof value?.expression === "string" && value.expression - && await duplicateIndex.source(config) !== null) { - const refreshed = await duplicateIndex.repair(config, value.expression, invoke); - noteIds = refreshed.noteIds; - if (!noteIds.length) return { opened: false, noteIds: [], repaired: true }; - repaired = true; - } - const query = noteIds.length ? ankiNoteIdsQuery(noteIds) : ankiBrowseQuery(value?.expression ?? ""); - await invoke("guiBrowse", { query }, 30_000); - return { opened: true, noteIds, repaired }; - } - - return { status, view, preflight, submit, browse }; -} diff --git a/vendor/hachidori/extension/anki-offscreen.js b/vendor/hachidori/extension/anki-offscreen.js deleted file mode 100644 index a2aa9323..00000000 --- a/vendor/hachidori/extension/anki-offscreen.js +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { extensionApi } from "./browser-api.js"; -import { decodeBase64 } from "./base64.js"; -import { buildAnkiResourceFields } from "./anki-resources.js"; -import { exportAnkiAudio } from "./anki-audio.js"; -import { MINING_CAPABILITIES } from "./overlay-mode.js"; - -// Resolve and parse the complete scoped note set away from the background and -// engine request threads; only compact index rows cross back to the commit. -async function refreshAnkiIndex(window, source) { - const worker = new window.Worker(new URL("./anki-index-worker.js", import.meta.url), { type: "module" }); - try { - return await new Promise((resolve, reject) => { - worker.onmessage = ({ data }) => data.error ? reject(new Error(data.error)) : resolve({ rows: data.rows }); - worker.onerror = event => reject(new Error(event.message || "Anki index refresh worker failed.")); - worker.postMessage(source); - }); - } finally { - worker.terminate(); - } -} - -async function recordSpeechAudio(...args) { - if (!MINING_CAPABILITIES.browserSpeech) { - throw new Error("Browser text-to-speech recording is unavailable in Firefox."); - } - const capture = await import("./capture-host.js"); - return capture.recordSpeechAudio(...args); -} - -function clientSpeechPlan(source, term) { - return { - sourceId: source.id, - sourceKey: JSON.stringify(source), - expression: term.expression, - reading: term.reading, - }; -} - -function sameClientSpeech(plan, source, term) { - const expected = clientSpeechPlan(source, term); - return plan && Object.entries(expected).every(([key, value]) => plan[key] === value); -} - -function decodeClientSpeech(window, data) { - return decodeBase64(data, { atob: window.atob.bind(window) }); -} - -function linkedSpeechRecorder(window, message) { - if (message.clientSpeechProbe !== true && !message.clientSpeech) return recordSpeechAudio; - return async (source, term, signal, { record = true } = {}) => { - signal.throwIfAborted(); - const plan = clientSpeechPlan(source, term); - if (!record || message.clientSpeechProbe === true) { - return { recordingRequired: true, clientSpeech: plan }; - } - const supplied = message.clientSpeech; - if (!sameClientSpeech(supplied, source, term)) { - throw new Error("The linked browser did not supply the requested browser speech."); - } - return { - data: decodeClientSpeech(window, supplied.data), - filename: supplied.filename, - candidate: { name: "Linked browser speech", text: term.reading || term.expression, voice: "", index: 0 }, - }; - }; -} - -export function createAnkiOffscreenService(window, getAudioRepository, captureSpeech = recordSpeechAudio) { - return async message => { - if (message.type === "hd_anki_index_refresh") return refreshAnkiIndex(window, message.source); - if (message.type === "hd_anki_audio") { - return exportAnkiAudio(window, await getAudioRepository(), message, window.AbortSignal.timeout(30_000), { - recordSpeechAudio: message.clientSpeechProbe === true || message.clientSpeech - ? linkedSpeechRecorder(window, message) - : captureSpeech, - }); - } - if (message.type !== "hd_anki_fields") throw new Error("Unknown Anki rendering request."); - return buildAnkiResourceFields(message.request, message.templates, { - document: window.document, dictionaryPaths: message.dictionaryPaths, audio: message.audio, - styles: async () => { - const reply = await extensionApi.runtime.sendMessage({ target: "hoshidicts-offscreen", type: "hd_styles", requestId: message.requestId }); - if (!reply.ok || reply.generation !== message.request.generation) throw new Error(reply.error || "Dictionary styles changed during Anki preparation."); - return reply.styles; - }, - }); - }; -} diff --git a/vendor/hachidori/extension/anki-pitch.js b/vendor/hachidori/extension/anki-pitch.js deleted file mode 100644 index ab70e651..00000000 --- a/vendor/hachidori/extension/anki-pitch.js +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2023-2026 Yomitan Authors -// Copyright (C) 2021-2022 Yomichan Authors -// Graph notation follows Yomitan's pronunciation-generator.js (GPL-3.0-or-later), -// including its Jidoujisho-style kana graph: https://github.com/yomidevs/yomitan -import "./render/glossary.js"; -import { escapeAnkiHtml as escape } from "./anki-templates.js"; - -function pitchContour(reading, pitch) { - const { splitPitchAccentMorae, buildPitchAccentMorae } = globalThis.HDGlossary; - if (pitch.pattern) { - const morae = splitPitchAccentMorae(reading); - // The engine stores string positions in pattern, with a placeholder numeric - // position of zero. Never interpret an unsupported pattern as heiban. - if (!morae.length || !/^[HL]+$/u.test(pitch.pattern) - || pitch.pattern.length < morae.length || pitch.pattern.length > morae.length + 1) return null; - return { morae, levels: [...pitch.pattern.padEnd(morae.length + 1, pitch.pattern.at(-1))] }; - } - const morae = buildPitchAccentMorae(reading, pitch.position); - if (!morae) return null; - return { morae: morae.map(mora => mora.text), - levels: [...morae.map(mora => mora.level === "high" ? "H" : "L"), pitch.position === 0 ? "H" : "L"] }; -} - -function graphLine(from, to, radius) { - // Stop at the dots' edges so hollow downstep/particle marks stay transparent - // on both light and dark cards, without masks or document-wide SVG IDs. - const dx = to.x - from.x, dy = to.y - from.y; - const scale = radius / Math.hypot(dx, dy); - return `M${from.x + dx * scale} ${from.y + dy * scale}L${to.x - dx * scale} ${to.y - dy * scale}`; -} - -function pitchGraph(reading, pitch, kana) { - const contour = pitchContour(reading, pitch); - if (!contour) return ""; - const { morae, levels } = contour; - const step = kana ? 35 : 50, height = kana ? 80 : 100, radius = kana ? 5 : 15; - const highY = kana ? 10 : 25; - const lowY = kana ? 35 : 75; - const points = levels.map((level, index) => ({ x: step * (index + 0.5), - y: level === "H" ? highY : lowY })); - const width = step * points.length; - const label = `${reading}: pitch accent ${pitch.pattern || pitch.position}`; - const lines = points.slice(1).map((point, index) => { - const tail = index === morae.length - 1; - return ``; - }); - const dots = points.slice(0, -1).map(({ x, y }, index) => { - const downstep = !kana && levels[index] === "H" && levels[index + 1] === "L"; - return `` - + (downstep ? `` : ""); - }); - const tail = points.at(-1); - const tailAttributes = `class="pronunciation-graph-tail" data-pitch="${levels.at(-1) === "H" ? "high" : "low"}" fill="none" stroke="currentColor"`; - // Match Yomitan's distinct particle radius so card CSS for 5-unit mora dots - // does not fill the hollow JJ particle. - const particle = kana - ? `` - : ``; - const labels = kana ? morae.map((mora, index) => - ` 1 ? ' textLength="30" lengthAdjust="spacingAndGlyphs"' : ""} style="font: 20px sans-serif; fill: currentColor;">${escape(mora)}`).join("") : ""; - return `` - + `${escape(label)}${lines.join("")}${dots.join("")}${particle}${labels}`; -} - -export function ankiPitchGraphs(term, kana = false) { - const reading = term.reading || term.expression; - return term.pitches.map(group => { - const graphs = group.pitches.map(pitch => pitchGraph(reading, pitch, kana)).filter(Boolean); - return graphs.length ? `${escape(group.dictionary)}: ${graphs.join(" ")}` : ""; - }).filter(Boolean).join("
"); -} diff --git a/vendor/hachidori/extension/anki-resources.js b/vendor/hachidori/extension/anki-resources.js deleted file mode 100644 index 89a37beb..00000000 --- a/vendor/hachidori/extension/anki-resources.js +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { buildAnkiFields } from "./anki-values.js"; -import { createAnkiDefinitionRenderer } from "./anki-glossary.js"; -import { ankiDigest } from "./anki-digest.js"; - -const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "bmp", "ico", "tiff"]); - -export async function ankiMediaFilename(bytes, extension) { - return `hachidori_${await ankiDigest(bytes)}.${extension}`; -} - -// Planning reads no dictionary bytes and uploads nothing. Stable generation -// paths, unlike an engine's restart counter, keep first-field image identities -// identical between preflight and the authoritative write. -export async function buildAnkiResourceFields(request, templates, { document, dictionaryPaths, styles, audio = "" }) { - const media = new Map(); - const source = { ...request, dictionaryMedia: [], dictionaryStyles: [] }; - let plainRenderer, richRenderer; - function filenameFor(dictionary, path) { - const generationPath = Object.hasOwn(dictionaryPaths, dictionary) ? dictionaryPaths[dictionary] : null; - if (!generationPath) throw new Error(`The dictionary generation is no longer available: ${dictionary}`); - const key = JSON.stringify([dictionary, path]); - if (!media.has(key)) { - const suffix = path.split(".").at(-1).toLowerCase(); - const bytes = new TextEncoder().encode(JSON.stringify([generationPath, path])); - media.set(key, ankiMediaFilename(bytes, IMAGE_EXTENSIONS.has(suffix) ? suffix : "bin") - .then(filename => ({ dictionary, path, filename }))); - } - return media.get(key).then(item => item.filename); - } - const fields = await buildAnkiFields(request, templates, { audio, definition: async options => { - if (options.plain) { - plainRenderer ??= createAnkiDefinitionRenderer(document, source); - return plainRenderer(options); - } - richRenderer ??= Promise.resolve(styles()).then(dictionaryStyles => - createAnkiDefinitionRenderer(document, { ...source, dictionaryStyles }, filenameFor)); - return (await richRenderer)(options); - } }); - return { fields, media: await Promise.all(media.values()) }; -} diff --git a/vendor/hachidori/extension/anki-settings.js b/vendor/hachidori/extension/anki-settings.js deleted file mode 100644 index 8d0fae06..00000000 --- a/vendor/hachidori/extension/anki-settings.js +++ /dev/null @@ -1,1023 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiAvailability } from "./anki.js"; -import { ankiSetupFamily } from "./anki-setup.js"; -import { ANKI_TEMPLATE_MARKER_OPTIONS, ankiFieldNames, ankiTemplateErrors, applyAnkiPreset, resolveAnkiTemplates } from "./anki-templates.js"; -import { reorderSettingsRows, setStatusOutput } from "./settings-dom.js"; - -function setAttributeIfChanged(element, name, value) { - if (element.getAttribute(name) !== value) element.setAttribute(name, value); -} - -function intersectingMarkerSelection(value, selectionStart, selectionEnd) { - const collapsed = selectionStart === selectionEnd; - for (const match of value.matchAll(/\{([^{}]*)\}/gu)) { - const start = match.index; - const end = start + match[0].length; - const intersects = collapsed - ? selectionStart > start && selectionStart < end - : selectionStart < end && selectionEnd > start; - if (!intersects) continue; - return { - start: collapsed ? start : Math.min(selectionStart, start), - end: collapsed ? end : Math.max(selectionEnd, end), - query: match[1], - }; - } - return null; -} - -function partialMarkerSelection(value, selectionStart, selectionEnd) { - const before = value.slice(0, selectionStart); - const open = before.lastIndexOf("{"); - if (open <= before.lastIndexOf("}")) return null; - const close = value.indexOf("}", selectionEnd); - return { - start: open, - end: close < 0 ? selectionEnd : close + 1, - query: value.slice(open + 1, selectionStart), - }; -} - -function markerLikeTokenQuery(token) { - let start = token.startsWith("{") ? 1 : 0; - let end = token.endsWith("}") ? token.length - 1 : token.length; - if (end < start) end = start; - const query = token.slice(start, end); - for (const character of query) { - if (character === "{" || character === "}" || character.trim() === "") return null; - } - return query; -} - -function singleTokenSelection(value, selectionStart, selectionEnd) { - const trimmedStart = value.length - value.trimStart().length; - const trimmedEnd = value.trimEnd().length; - if (trimmedStart >= trimmedEnd || trimmedStart > selectionStart || selectionEnd > trimmedEnd) return null; - const token = value.slice(trimmedStart, trimmedEnd); - const collapsed = selectionStart === selectionEnd; - const completeMarkerBoundary = collapsed && token.startsWith("{") && token.endsWith("}") - && (selectionStart === trimmedStart || selectionStart === trimmedEnd); - if (completeMarkerBoundary) return null; - const query = markerLikeTokenQuery(token); - return query === null ? null : { start: trimmedStart, end: trimmedEnd, query }; -} - -function markerSelection(value, selectionStart, selectionEnd) { - const selection = intersectingMarkerSelection(value, selectionStart, selectionEnd) - ?? partialMarkerSelection(value, selectionStart, selectionEnd) - ?? singleTokenSelection(value, selectionStart, selectionEnd); - if (selection) return selection; - return { start: selectionStart, end: selectionEnd, query: "" }; -} - -function createMarkerCombobox(document, id, labelText, onValue) { - const root = document.createElement("div"); - root.className = "anki-marker-combobox"; - root.innerHTML = `
- - - -
- - `; - const editor = root.querySelector("textarea"); - const toggle = root.querySelector("button"); - const listbox = root.querySelector('[role="listbox"]'); - const status = root.querySelector('[role="status"]'); - const error = root.querySelector("output"); - editor.id = id; - listbox.id = `${id}-listbox`; - status.id = `${id}-status`; - error.id = `${id}-error`; - editor.setAttribute("aria-controls", listbox.id); - editor.setAttribute("aria-describedby", `${status.id} ${error.id}`); - toggle.setAttribute("aria-controls", listbox.id); - - const empty = document.createElement("div"); - empty.className = "anki-marker-empty"; - empty.setAttribute("role", "presentation"); - empty.setAttribute("aria-hidden", "true"); - empty.textContent = "No markers match. Keep typing to use this text as-is."; - empty.hidden = true; - const options = ANKI_TEMPLATE_MARKER_OPTIONS.map((marker, index) => { - const option = document.createElement("div"); - option.id = `${id}-option-${index + 1}`; - option.className = "anki-marker-option"; - option.dataset.marker = marker.value; - option.setAttribute("role", "option"); - option.setAttribute("aria-selected", "false"); - option.setAttribute("aria-label", `${marker.value}: ${marker.description}`); - const value = document.createElement("code"); - value.textContent = marker.value; - const description = document.createElement("span"); - description.textContent = marker.description; - option.append(value, description); - option.addEventListener("pointerenter", () => { - if (!option.hidden) setActive(index); - }); - option.addEventListener("pointerdown", event => event.preventDefault()); - option.addEventListener("click", () => select(index)); - listbox.append(option); - return { - ...marker, - element: option, - search: `${marker.marker} ${marker.value}`.toLocaleLowerCase(), - }; - }); - listbox.append(empty); - - let open = false; - let showAll = false; - let active = -1; - let composing = false; - let committedValue = ""; - - function updateLabel(value) { - setAttributeIfChanged(toggle, "aria-label", `${open ? "Hide" : "Show"} marker suggestions for ${value}`); - setAttributeIfChanged(listbox, "aria-label", `Marker suggestions for ${value}`); - } - - function setActive(index) { - active = index; - for (const [optionIndex, option] of options.entries()) { - option.element.setAttribute("aria-selected", `${open && optionIndex === active}`); - } - if (open && active >= 0) { - editor.setAttribute("aria-activedescendant", options[active].element.id); - options[active].element.scrollIntoView?.({ block: "nearest" }); - } else { - editor.removeAttribute("aria-activedescendant"); - } - } - - function visibleOptions() { - return options.map((option, index) => option.element.hidden ? -1 : index).filter(index => index >= 0); - } - - function refreshOptions() { - const selection = markerSelection(editor.value, editor.selectionStart, editor.selectionEnd); - const query = showAll ? "" : selection.query.toLocaleLowerCase(); - for (const option of options) option.element.hidden = query !== "" && !option.search.includes(query); - const visible = visibleOptions(); - empty.hidden = visible.length !== 0; - if (!visible.includes(active)) { - const exact = visible.find(index => options[index].value === editor.value); - setActive(exact ?? visible[0] ?? -1); - } else { - setActive(active); - } - if (visible.length === 0) { - status.textContent = query - ? `No marker suggestions match ${selection.query}. Keep typing to use this text as-is.` - : "No marker suggestions are available."; - } else { - status.textContent = `${visible.length} marker suggestion${visible.length === 1 ? "" : "s"} available. Use Arrow keys and Enter to select.`; - } - } - - function setOpen(value, all = false) { - open = value; - showAll = value && all; - editor.setAttribute("aria-expanded", `${open}`); - toggle.setAttribute("aria-expanded", `${open}`); - listbox.hidden = !open; - updateLabel(labelText); - if (open) refreshOptions(); - else { - status.textContent = ""; - setActive(-1); - } - } - - function commit() { - if (editor.value === committedValue) return; - onValue(editor.value); - } - - function select(index) { - if (index < 0 || options[index].element.hidden) return; - const selection = markerSelection(editor.value, editor.selectionStart, editor.selectionEnd); - const value = options[index].value; - editor.value = `${editor.value.slice(0, selection.start)}${value}${editor.value.slice(selection.end)}`; - const caret = selection.start + value.length; - editor.setSelectionRange(caret, caret); - commit(); - setOpen(false); - editor.focus({ preventScroll: true }); - } - - function moveActive(offset) { - const visible = visibleOptions(); - if (visible.length === 0) return; - const position = visible.indexOf(active); - let next = 0; - if (position >= 0) next = (position + offset + visible.length) % visible.length; - else if (offset < 0) next = visible.length - 1; - setActive(visible[next]); - } - - function moveActiveToEdge(last) { - const visible = visibleOptions(); - setActive(last ? visible.at(-1) ?? -1 : visible[0] ?? -1); - } - - function handleOpenKey(event) { - if (!open) return false; - if (event.key === "Tab") { - setOpen(false); - return false; - } - switch (event.key) { - case "Home": - moveActiveToEdge(false); - return true; - case "End": - moveActiveToEdge(true); - return true; - case "Enter": - if (event.shiftKey || active < 0) return false; - select(active); - return true; - case "Escape": - setOpen(false); - return true; - default: - return false; - } - } - - function handleKeydown(event) { - if (composing || event.isComposing) return; - let handled = false; - if (event.altKey && event.key === "ArrowUp" && open) { - setOpen(false); - handled = true; - } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { - if (!open) setOpen(true, true); - else moveActive(event.key === "ArrowDown" ? 1 : -1); - handled = true; - } else { - handled = handleOpenKey(event); - } - if (handled) event.preventDefault(); - } - - editor.addEventListener("input", event => { - showAll = false; - if (!composing && !event.isComposing) commit(); - if (!open) setOpen(true); - else refreshOptions(); - }); - editor.addEventListener("compositionstart", () => { composing = true; }); - editor.addEventListener("compositionend", () => { - composing = false; - commit(); - if (!open) setOpen(true); - else refreshOptions(); - }); - editor.addEventListener("keydown", handleKeydown); - toggle.addEventListener("pointerdown", event => event.preventDefault()); - toggle.addEventListener("click", () => { - const wasOpen = open; - editor.focus({ preventScroll: true }); - setOpen(!wasOpen, true); - }); - root.addEventListener("focusout", () => { - document.defaultView.queueMicrotask(() => { - if (root.contains(document.activeElement)) return; - if (composing) { - composing = false; - commit(); - } - setOpen(false); - }); - }); - - updateLabel(labelText); - return { - root, - editor, - error, - close: () => setOpen(false), - update({ value, label, errors }) { - labelText = label; - committedValue = value; - updateLabel(label); - if (editor !== document.activeElement && editor.value !== value) editor.value = value; - const message = errors.join("\n"); - if (error.textContent !== message) error.textContent = message; - setAttributeIfChanged(editor, "aria-invalid", `${errors.length > 0}`); - if (open) refreshOptions(); - }, - }; -} - -export function createAnkiSettingsController({ - document, - readConfig, - editConfig, - send, - capabilities = { screenshot: true }, - readOwnerKey = () => "", -}) { - const { ANKI_FIELDS, ANKI_OVERWRITE_MODES, normaliseAnkiConnectUrl } = document.defaultView.HDReaderOptions; - const element = id => document.getElementById(id); - const selects = new WeakMap(); - let discovery = null; - let discoveryKey = null; - let pendingPreset = null; - let presetModel = null; - let requestSequence = 0; - let requestedKey = null; - let loading = false; - let apiKeyPanelInitialized = false; - let findingSetup = false; - let setupRequestSequence = 0; - let setupSnapshot = null; - let ownerKey = String(readOwnerKey() ?? ""); - const templateRows = new Map(); - let nextTemplateId = 0; - const connectionKey = config => JSON.stringify([config.model, config.apiKey, config.url]); - - function setApiKeyExpanded(expanded) { - setAttributeIfChanged(element("anki-api-key-toggle"), "aria-expanded", `${expanded}`); - if (element("anki-api-key-panel").hidden !== !expanded) element("anki-api-key-panel").hidden = !expanded; - } - - if (!capabilities.screenshot) { - element("opt-anki-screenshot").disabled = true; - element("anki-screenshot-help").textContent = "Page screenshots are unavailable in this overlay. Screenshot fields stay empty."; - } - - function change(patch) { - if (Object.hasOwn(patch, "fields") || Object.hasOwn(patch, "fieldTemplates")) pendingPreset = null; - editConfig({ ...readConfig(), ...patch }); - render(); - } - - function currentFields() { - const config = readConfig(); - return discovery?.model === config.model && discoveryKey === connectionKey(config) ? discovery.fields : []; - } - - function templatePresentation(config = readConfig()) { - const fields = currentFields(); - const resolved = resolveAnkiTemplates(config, fields); - if (config.fieldTemplates !== null) { - return { - resolved, - entries: [...Object.entries(resolved.templates), - ...resolved.staleFields.map(field => [field, config.fieldTemplates[field]])], - unavailable: new Set(resolved.staleFields), - }; - } - const names = [...fields]; - const folded = new Set(names.map(field => field.toLowerCase())); - for (const semantic of ANKI_FIELDS) { - const field = config.fields[semantic]; - if (field && !folded.has(field.toLowerCase())) { - names.push(field); - folded.add(field.toLowerCase()); - } - } - const display = resolveAnkiTemplates(config, names); - const available = ankiFieldNames(fields); - return { - resolved, - entries: Object.entries(display.templates), - unavailable: new Set(names.filter(field => !available.has(field.toLowerCase()))), - }; - } - - function materializeTemplates() { - return Object.fromEntries(templatePresentation().entries.map(([field, template]) => [field, { ...template }])); - } - - function editTemplate(field, patch) { - const templates = materializeTemplates(); - templates[field] = { ...templates[field], ...patch }; - change({ fieldTemplates: templates }); - } - - function createTemplateRow(field) { - const row = document.createElement("div"); - row.className = "anki-template-row"; - row.innerHTML = `
- `; - const label = row.querySelector(".field-label"), mode = row.querySelector("select"); - const indexBadge = row.querySelector(".anki-field-index"); - const id = `opt-anki-template-${++nextTemplateId}`; - let record; - const combobox = createMarkerCombobox(document, id, field, value => editTemplate(record.field, { value })); - const editor = combobox.editor; - row.querySelector(".anki-template-mode").before(combobox.root); - label.htmlFor = editor.id; - label.id = `${editor.id}-label`; - label.textContent = field; - mode.id = `${editor.id}-mode`; - mode.setAttribute("aria-label", `On overwrite: ${field}`); - const names = { coalesce: "Keep existing, fill empty", "coalesce-new": "Use new, keep if empty", skip: "Keep existing", - append: "Append", prepend: "Prepend", overwrite: "Replace" }; - for (const value of ANKI_OVERWRITE_MODES) mode.add(new document.defaultView.Option(names[value], value)); - const remove = row.querySelector("button"); - remove.setAttribute("aria-label", `Remove unavailable field: ${field}`); - record = { field, row, label, editor, combobox, mode, remove, indexBadge, modeLabel: mode.parentElement }; - mode.addEventListener("change", () => editTemplate(record.field, { overwriteMode: mode.value })); - remove.addEventListener("click", () => { - const templates = materializeTemplates(); - delete templates[record.field]; - change({ fieldTemplates: templates }); - element("anki-apply-preset").focus(); - }); - return record; - } - - function updateTemplateRow(row, template, showMode, unavailable, index) { - const errors = ankiTemplateErrors(template.value); - row.combobox.update({ value: template.value, label: row.field, errors }); - if (row.mode !== document.activeElement && row.mode.value !== template.overwriteMode) row.mode.value = template.overwriteMode; - if (row.modeLabel.hidden === showMode) row.modeLabel.hidden = !showMode; - if (row.remove.hidden === unavailable) row.remove.hidden = !unavailable; - if (row.row.dataset.ankiField !== row.field) row.row.dataset.ankiField = row.field; - const displayIndex = String(index + 1).padStart(2, "0"); - if (row.indexBadge.textContent !== displayIndex) row.indexBadge.textContent = displayIndex; - const unmapped = template.value.trim() === ""; - if (row.row.classList.contains("is-unmapped") !== unmapped) row.row.classList.toggle("is-unmapped", unmapped); - } - - function renderTemplates(config, presentation) { - const templates = presentation.entries; - const retained = new Set(templates.map(([field]) => field)); - const showMode = config.duplicateBehavior === "overwrite"; - const renamedRows = new Map([...templateRows].filter(([field]) => !retained.has(field)) - .map(([field, row]) => [field.toLowerCase(), row])); - const container = element("anki-templates"); - for (const [index, [field, template]] of templates.entries()) { - if (!templateRows.has(field)) { - const previous = renamedRows.get(field.toLowerCase()); - if (previous) adoptRenamedRow(previous, field, renamedRows); - templateRows.set(field, previous || createTemplateRow(field)); - } - updateTemplateRow(templateRows.get(field), template, showMode, presentation.unavailable.has(field), index); - } - for (const [field, row] of templateRows) { - if (!retained.has(field)) { row.row.remove(); templateRows.delete(field); } - } - reorderSettingsRows(container, templates.map(([field]) => templateRows.get(field).row)); - filterTemplateRows(templates.map(([field]) => field)); - const canApply = !loading && currentFields().length > 0; - if (element("anki-apply-preset").disabled === canApply) element("anki-apply-preset").disabled = !canApply; - } - - function adoptRenamedRow(previous, field, renamedRows) { - templateRows.delete(previous.field); - renamedRows.delete(field.toLowerCase()); - previous.field = field; - previous.label.textContent = field; - previous.mode.setAttribute("aria-label", `On overwrite: ${field}`); - previous.remove.setAttribute("aria-label", `Remove unavailable field: ${field}`); - } - - // Rows are hidden, never dropped, so the mapping keeps Anki's field order. - function filterTemplateRows(fields) { - const query = element("anki-field-filter").value.trim().toLocaleLowerCase(); - let visible = 0; - for (const field of fields) { - const row = templateRows.get(field).row; - const hidden = query !== "" && !field.toLocaleLowerCase().includes(query); - if (row.hidden !== hidden) row.hidden = hidden; - if (!hidden) visible += 1; - } - const count = query === "" ? `Showing ${fields.length} fields` : `Showing ${visible} of ${fields.length} fields`; - if (element("anki-field-count").textContent !== count) element("anki-field-count").textContent = count; - } - - function optionGroup(label, choices, optionLabel) { - const group = document.createElement("optgroup"); - group.label = label; - group.append(...choices.map(name => new document.defaultView.Option(optionLabel(name), name))); - return group; - } - - // A saved value that discovery no longer lists stays selectable as "(unavailable)". - function selectChoices(id, names, value, placeholder, { suggested = "", allLabel, labels = {} }) { - const select = element(id); - if (select === document.activeElement) return; - const key = JSON.stringify([names, value, suggested, allLabel, labels]); - if (selects.get(select) === key) return; - const optionLabel = name => { - if (Object.hasOwn(labels, name)) return labels[name]; - return names.includes(name) ? name : `${name} (unavailable)`; - }; - const groups = []; - if (suggested) groups.push(optionGroup("Suggested", [suggested], name => `Suggested: ${optionLabel(name)}`)); - const rest = names.filter(name => name !== suggested); - if (value && !names.includes(value) && value !== suggested) rest.push(value); - groups.push(optionGroup(allLabel, rest, optionLabel)); - select.replaceChildren(new document.defaultView.Option(placeholder, ""), ...groups); - select.value = value; - selects.set(select, key); - } - - function renderDuplicateScope(config) { - const select = element("opt-anki-duplicate-scope"); - if (select === document.activeElement) return; - const choices = [ - ["model", `Note type: ${config.model || "Choose a note type"}`], - ["deck", `Deck: ${config.deck || "Choose a deck"}`], - ["all", "All of Anki"], - ]; - const key = JSON.stringify(choices); - if (selects.get(select) !== key) { - select.replaceChildren(...choices.map(([value, label]) => new document.defaultView.Option(label, value))); - selects.set(select, key); - } - select.value = config.duplicateScope; - } - - function renderStatus(config, resolved) { - const status = element("anki-status"); - // A URL/API-key/model edit retires the old discovery immediately. Its - // fields no longer match `resolved`, and must not be rendered while the - // replacement request (or a linked host-side save) is still pending. - const currentDiscovery = discoveryKey === connectionKey(config) ? discovery : null; - const errors = ankiAvailability(config, currentDiscovery, resolved); - const connected = currentDiscovery?.connected === true; - let state = "Not connected"; - if (connected) state = errors.length ? "Connected · configuration needs attention" : "Connected · configuration ready"; - const message = loading ? "Checking AnkiConnect…" : [state, ...errors].join("\n"); - let tone; - let connection = "offline"; - if (loading) { - tone = "working"; - connection = "checking"; - } else if (connected) { - tone = errors.length ? "error" : "ready"; - connection = "connected"; - } else if (errors.length) { - tone = "error"; - } - setAttributeIfChanged(status, "data-state", connection); - setStatusOutput(status, message, tone); - if (element("anki-refresh").disabled !== loading) element("anki-refresh").disabled = loading; - } - - function setupStatus(message, tone) { - const status = element("anki-setup-status"); - status.hidden = message === ""; - setStatusOutput(status, message, tone); - } - - function syncOwner() { - const next = String(readOwnerKey() ?? ""); - if (next === ownerKey) return; - ownerKey = next; - pendingPreset = null; - setupSnapshot = null; - setupRequestSequence += 1; - findingSetup = false; - for (const row of templateRows.values()) row.combobox.close(); - element("anki-find-setup").disabled = false; - setupStatus(""); - } - - async function findSetup() { - if (findingSetup || commitConnectionUrl() === null) return; - syncOwner(); - const config = readConfig(); - const snapshot = JSON.stringify(config); - const requestOwner = ownerKey; - const sequence = ++setupRequestSequence; - findingSetup = true; - element("anki-find-setup").disabled = true; - setupStatus("Finding your Anki setup…", "working"); - try { - const reply = await send("hd_anki_setup", { - anki: config, - ...(ownerKey === "" ? {} : { templateId: ownerKey }), - }); - if (sequence !== setupRequestSequence || requestOwner !== String(readOwnerKey() ?? "")) return; - if (snapshot !== JSON.stringify(readConfig())) { - throw new Error("Anki settings changed while checking. Your changes were kept; retry to check them."); - } - if (!reply.ok) throw new Error(reply.error || "Anki setup discovery did not reply."); - const { proposal, outcome } = reply; - if (proposal?.status === "configured") { - change({ model: proposal.model, deck: proposal.deck, fieldTemplates: proposal.fieldTemplates }); - setupStatus(`Found ${outcome.model} in deck ‘${outcome.deck}’. Changes save automatically.`, "ready"); - } else if (outcome.status === "already-configured") { - setupStatus(`Your saved ${outcome.model} setup for deck ‘${outcome.deck}’ is ready.`, "ready"); - } else { - setupStatus(outcome.detail, "error"); - } - } catch (error) { - if (sequence !== setupRequestSequence || requestOwner !== String(readOwnerKey() ?? "")) return; - setupStatus(error.message, "error"); - } finally { - if (sequence === setupRequestSequence && requestOwner === String(readOwnerKey() ?? "")) { - findingSetup = false; - setupSnapshot = JSON.stringify(readConfig()); - element("anki-find-setup").disabled = false; - } - } - } - - async function refresh() { - const config = readConfig(); - const key = connectionKey(config); - requestedKey = key; - const sequence = ++requestSequence; - loading = true; - renderStatus(config); - element("anki-apply-preset").disabled = true; - try { - const reply = await send("hd_anki_discover", { model: config.model, apiKey: config.apiKey, url: config.url }); - if (sequence !== requestSequence || key !== connectionKey(readConfig())) return; - if (!reply.ok) throw new Error(reply.error); - discovery = reply; - discoveryKey = key; - if (pendingPreset?.key === key && pendingPreset.owner === ownerKey && reply.connected && reply.fields.length > 0) { - const preset = pendingPreset; - pendingPreset = null; - const current = readConfig(); - if (current.fieldTemplates === null && JSON.stringify(current.fields) === preset.fields) { - editConfig(applyAnkiPreset(current, reply.fields, preset.family)); - } - } - } catch (error) { - if (sequence !== requestSequence || key !== connectionKey(readConfig())) return; - discovery = { connected: false, model: config.model, decks: [], models: [], fields: [], errors: [error.message] }; - discoveryKey = key; - } finally { - if (sequence === requestSequence && key === connectionKey(readConfig())) { - loading = false; - render(); - } - } - } - - const controls = [ - ["tags", "opt-anki-tags"], ["apiKey", "opt-anki-api-key"], - ["duplicateScope", "opt-anki-duplicate-scope"], ["duplicateBehavior", "opt-anki-duplicate-behavior"], - ["captureScreenshot", "opt-anki-screenshot"], - ]; - - function renderFormControls(config) { - const values = { ...config, captureScreenshot: capabilities.screenshot && config.captureScreenshot }; - for (const [key, id] of controls) { - const control = element(id); - if (control === document.activeElement) continue; - if (control.type === "checkbox") control.checked = values[key]; - else control.value = key === "tags" ? values.tags.join(" ") : values[key]; - } - } - - function render() { - syncOwner(); - const config = readConfig(); - if (!apiKeyPanelInitialized) { - apiKeyPanelInitialized = true; - setApiKeyExpanded(config.apiKey !== ""); - } - if (!findingSetup && setupSnapshot !== null && setupSnapshot !== JSON.stringify(config)) { - setupSnapshot = null; - setupStatus(""); - } - if (pendingPreset && (pendingPreset.key !== connectionKey(config) || pendingPreset.owner !== ownerKey)) pendingPreset = null; - if (presetModel !== config.model) { - presetModel = config.model; - element("anki-preset").value = ankiSetupFamily(config.model) || "automatic"; - } - const models = discovery?.models || []; - const suggestedModel = ankiSetupFamily(config.model) - ? config.model - : models.find(model => ankiSetupFamily(model)) || ""; - const modelLabels = {}; - if (discoveryKey === connectionKey(config) && discovery?.connected && discovery.model === config.model - && models.includes(config.model)) { - modelLabels[config.model] = `${config.model} (${discovery.fields.length} fields)`; - } - selectChoices("opt-anki-deck", discovery?.decks || [], config.deck, "Choose a deck", { - suggested: config.deck, - allLabel: "All decks", - }); - selectChoices("opt-anki-model", models, config.model, "Choose a note type", { - suggested: suggestedModel, - allLabel: "All note types", - labels: modelLabels, - }); - renderDuplicateScope(config); - const url = element("opt-anki-url"); - if (url !== document.activeElement && !url.validity.customError && url.value !== config.url) url.value = config.url; - renderFormControls(config); - const presentation = templatePresentation(config); - renderStatus(config, presentation.resolved); - renderTemplates(config, presentation); - if (connectionKey(config) !== requestedKey) void refresh(); - } - - element("opt-anki-deck").addEventListener("change", event => change({ deck: event.target.value })); - element("opt-anki-model").addEventListener("change", event => { - if (event.target.value === readConfig().model) return; - const next = { ...readConfig(), model: event.target.value, - fields: Object.fromEntries(ANKI_FIELDS.map(key => [key, ""])), fieldTemplates: null }; - const family = ankiSetupFamily(next.model); - pendingPreset = family - ? { family, key: connectionKey(next), fields: JSON.stringify(next.fields), owner: ownerKey } - : null; - editConfig(next); - render(); - }); - for (const [key, id] of controls) { - element(id).addEventListener("change", event => { - const value = event.target.type === "checkbox" ? event.target.checked : event.target.value; - change({ [key]: key === "tags" ? value.split(/\s+/u).filter(Boolean) : value }); - }); - } - element("anki").addEventListener("focusout", () => queueMicrotask(render)); - function commitConnectionUrl() { - const input = element("opt-anki-url"); - const url = normaliseAnkiConnectUrl(input.value); - const error = url ? "" : "Enter a valid HTTP or HTTPS AnkiConnect URL without a username or password."; - input.setCustomValidity(error); - element("anki-url-error").textContent = error; - if (!url) return null; - input.value = url; - if (url === readConfig().url) return false; - change({ url }); - return true; - } - element("opt-anki-url").addEventListener("change", commitConnectionUrl); - element("anki-api-key-toggle").addEventListener("click", event => { - setApiKeyExpanded(event.currentTarget.getAttribute("aria-expanded") !== "true"); - }); - element("anki-refresh").addEventListener("click", () => { - // A changed URL starts discovery through render; don't start it twice. - if (commitConnectionUrl() === false) void refresh(); - }); - element("anki-find-setup").addEventListener("click", () => { void findSetup(); }); - element("anki-preset").addEventListener("change", () => { pendingPreset = null; }); - element("anki-field-filter").addEventListener("input", () => { - const config = readConfig(); - renderTemplates(config, templatePresentation(config)); - }); - element("anki-apply-preset").addEventListener("click", () => { - pendingPreset = null; - editConfig(applyAnkiPreset(readConfig(), currentFields(), element("anki-preset").value)); - render(); - }); - return { render, refresh }; -} - -export function createAnkiTemplateSettingsController({ - document, - readAnki, - editAnki, - readButtons = () => [], - send, - capabilities = { screenshot: true }, - createId = () => document.defaultView.crypto.randomUUID(), -}) { - const { - ANKI_TEMPLATE_CONFIG_KEYS, - DEFAULT_ANKI_TEMPLATE, - ankiTemplateConfig, - normaliseAnki, - } = document.defaultView.HDReaderOptions; - const element = id => document.getElementById(id); - let selectedId = null; - let renderedChoices = null; - - function currentAnki() { - return normaliseAnki(readAnki()); - } - - function selected(anki = currentAnki()) { - let index = anki.templates.findIndex(template => template.id === selectedId); - if (index < 0) { - index = 0; - selectedId = anki.templates[0].id; - } - return { anki, index, template: anki.templates[index] }; - } - - function saveTemplates(anki, templates) { - editAnki(normaliseAnki({ url: anki.url, apiKey: anki.apiKey, templates })); - } - - function editSelectedConfig(config) { - const { anki, template } = selected(); - const templates = anki.templates.map(value => value.id === template.id - ? { - id: value.id, - name: value.name, - ...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, config[key]])), - } - : value); - editAnki(normaliseAnki({ url: config.url, apiKey: config.apiKey, templates })); - } - - const editor = createAnkiSettingsController({ - document, - readConfig: () => { - const { anki, template } = selected(); - return ankiTemplateConfig(anki, template.id); - }, - editConfig: editSelectedConfig, - send, - capabilities, - readOwnerKey: () => selected().template.id, - }); - - function uniqueId() { - const ids = new Set(currentAnki().templates.map(template => template.id)); - let id; - do id = createId(); - while (typeof id !== "string" || id === "" || ids.has(id)); - return id; - } - - function uniqueName(base = "Template") { - const names = new Set(currentAnki().templates.map(template => template.name)); - if (!names.has(base)) return base; - for (let suffix = 2; ; suffix++) { - const name = `${base} ${suffix}`; - if (!names.has(name)) return name; - } - } - - function setSelected(id, focus = false) { - selectedId = id; - element("anki-template-status").textContent = ""; - render(); - if (focus) element("anki-template-select").focus(); - } - - function reorder(anki, index, destination) { - if (destination < 0 || destination >= anki.templates.length || destination === index) return false; - const templates = anki.templates.slice(); - const [template] = templates.splice(index, 1); - templates.splice(destination, 0, template); - saveTemplates(anki, templates); - render(); - return true; - } - - function move(offset) { - const { anki, index } = selected(); - if (!reorder(anki, index, index + offset)) return; - const preferred = element(offset < 0 ? "anki-template-up" : "anki-template-down"); - let focusTarget = preferred; - if (preferred.disabled) { - focusTarget = element(offset < 0 ? "anki-template-down" : "anki-template-up"); - } - focusTarget.focus(); - } - - function setBuiltin() { - const { anki, index } = selected(); - reorder(anki, index, 0); - } - - function add() { - const anki = currentAnki(); - const template = { - ...DEFAULT_ANKI_TEMPLATE, - id: uniqueId(), - name: uniqueName(), - tags: [...DEFAULT_ANKI_TEMPLATE.tags], - fields: { ...DEFAULT_ANKI_TEMPLATE.fields }, - fieldTemplates: null, - }; - selectedId = template.id; - saveTemplates(anki, [...anki.templates, template]); - render(); - element("opt-anki-template-name").focus(); - element("opt-anki-template-name").select(); - } - - function duplicate() { - const { anki, index, template } = selected(); - const copy = normaliseAnki({ - url: anki.url, - apiKey: anki.apiKey, - templates: [{ ...template, id: uniqueId(), name: uniqueName(`${template.name} copy`) }], - }).templates[0]; - selectedId = copy.id; - saveTemplates(anki, [...anki.templates.slice(0, index + 1), copy, ...anki.templates.slice(index + 1)]); - render(); - element("opt-anki-template-name").focus(); - element("opt-anki-template-name").select(); - } - - function remove() { - const { anki, index, template } = selected(); - if (anki.templates.length === 1) return; - const references = readButtons().filter(button => button.type === "anki" && button.templateId === template.id); - if (references.length > 0) { - let usage = `${references.length} custom buttons`; - if (references.length === 1) usage = `the “${references[0].label}” custom button`; - element("anki-template-status").textContent = - `“${template.name}” is used by ${usage}. Choose another Template for those buttons before deleting it.`; - return; - } - const templates = anki.templates.filter(value => value.id !== template.id); - selectedId = templates[Math.min(index, templates.length - 1)].id; - saveTemplates(anki, templates); - render(); - element("anki-template-select").focus(); - } - - function handleTemplateSelection(event) { - setSelected(event.currentTarget.value); - } - - function renderChoices(anki) { - const select = element("anki-template-select"); - const pills = element("anki-template-pills"); - const key = JSON.stringify(anki.templates.map(({ id, name }) => [id, name])); - if (renderedChoices !== key) { - select.replaceChildren(...anki.templates.map(template => new document.defaultView.Option(template.name, template.id))); - pills.replaceChildren(...anki.templates.map((template, index) => { - const pill = document.createElement("button"); - pill.type = "button"; - pill.className = "anki-template-pill"; - pill.value = template.id; - pill.append(template.name); - if (index === 0) { - const badge = document.createElement("span"); - badge.className = "anki-template-pill-badge"; - badge.textContent = "Built-in"; - pill.append(badge); - } - pill.addEventListener("click", handleTemplateSelection); - return pill; - })); - renderedChoices = key; - } - select.value = selectedId; - for (const pill of pills.children) pill.setAttribute("aria-pressed", `${pill.value === selectedId}`); - } - - function renderManager() { - const { anki, index, template } = selected(); - renderChoices(anki); - const name = element("opt-anki-template-name"); - if (name !== document.activeElement && name.value !== template.name) name.value = template.name; - element("anki-template-position").textContent = `${index + 1} of ${anki.templates.length}`; - element("anki-template-role").hidden = index !== 0; - element("anki-template-previous").disabled = index === 0; - element("anki-template-next").disabled = index === anki.templates.length - 1; - element("anki-template-up").disabled = index === 0; - element("anki-template-down").disabled = index === anki.templates.length - 1; - element("anki-template-set-builtin").disabled = index === 0; - element("anki-template-delete").disabled = anki.templates.length === 1; - } - - function render() { - renderManager(); - editor.render(); - } - - element("anki-template-select").addEventListener("change", handleTemplateSelection); - element("anki-template-previous").addEventListener("click", () => { - const { anki, index } = selected(); - if (index > 0) setSelected(anki.templates[index - 1].id, true); - }); - element("anki-template-next").addEventListener("click", () => { - const { anki, index } = selected(); - if (index + 1 < anki.templates.length) setSelected(anki.templates[index + 1].id, true); - }); - element("opt-anki-template-name").addEventListener("input", () => { - element("anki-template-status").textContent = ""; - }); - element("opt-anki-template-name").addEventListener("change", event => { - const name = event.target.value.trim(); - if (!name || /[\u0000-\u001f\u007f]/u.test(name)) { - element("anki-template-status").textContent = "Enter a name for the Template."; - return; - } - const { anki, template } = selected(); - if (name === template.name) return; - saveTemplates(anki, anki.templates.map(value => value.id === template.id ? { ...value, name } : value)); - render(); - }); - element("anki-template-add").addEventListener("click", add); - element("anki-template-duplicate").addEventListener("click", duplicate); - element("anki-template-set-builtin").addEventListener("click", setBuiltin); - element("anki-template-up").addEventListener("click", () => move(-1)); - element("anki-template-down").addEventListener("click", () => move(1)); - element("anki-template-delete").addEventListener("click", remove); - - return { - render, - refresh: () => editor.refresh(), - dirty: () => { - const { template } = selected(); - return element("opt-anki-template-name").value !== template.name; - }, - }; -} diff --git a/vendor/hachidori/extension/anki-setup.js b/vendor/hachidori/extension/anki-setup.js deleted file mode 100644 index fe7ac986..00000000 --- a/vendor/hachidori/extension/anki-setup.js +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiAvailability } from "./anki.js"; -import { ankiPresetCoreMapped, applyAnkiPreset, resolveAnkiTemplates } from "./anki-templates.js"; - -/* - * Read-only Anki detection shared by startup and Settings: recognise an installed Senren, Lapis or - * Kiku note type, rank note types by distinct existing notes and decks by - * distinct notes represented in them, and propose the preset mapping for the - * winner. Nothing here writes to Anki; every call is one of the fixed - * read-only actions below, issued through the worker's AnkiConnect gateway. - */ - -export const ANKI_SETUP_FAMILIES = Object.freeze(["senren", "lapis", "kiku"]); -const FAMILY_LABELS = { senren: "Senren", lapis: "Lapis", kiku: "Kiku" }; -// The family name must lead the model name and end at a word boundary, so -// ordinary versioned names match ("Kiku v2", "Lapis 1.4") while an unrelated -// or ambiguous name that merely contains the word does not ("Kikuchi", "My Kiku"). -const FAMILY_PATTERN = /^(senren|lapis|kiku)(?![\p{L}\p{N}])/iu; - -export function ankiSetupFamily(modelName) { - const match = FAMILY_PATTERN.exec(String(modelName).trim()); - return match === null ? null : match[1].toLowerCase(); -} - -function positiveId(value) { - return Number.isSafeInteger(value) && value > 0; -} - -function idList(value, what) { - if (!Array.isArray(value) || !value.every(positiveId)) throw new Error(`AnkiConnect returned invalid ${what}.`); - return value; -} - -// The preset is the mapping the user would get from Settings; a model that -// carries a family name but not its field shape is not eligible. The preset -// must have mapped the family's core fields — a namesake with one recognised -// field would otherwise pass on its first field alone. -export function ankiSetupTemplates(family, model, deck, fields, baseConfig) { - const config = applyAnkiPreset({ ...baseConfig, model, deck }, fields, family); - if (!ankiPresetCoreMapped(config.fieldTemplates, family)) return null; - const resolved = resolveAnkiTemplates(config, fields); - const errors = ankiAvailability(config, { connected: true, model, decks: [deck], models: [model], fields, errors: [] }, resolved); - return errors.length === 0 ? config.fieldTemplates : null; -} - -function uniqueMaximum(entries) { - let best = null; - let tied = false; - for (const entry of entries) { - if (entry.count <= 0) continue; - if (best === null || entry.count > best.count) { - best = entry; - tied = false; - } else if (entry.count === best.count) { - tied = true; - } - } - return { best, tied }; -} - -function attention(detail) { - return { status: "needs-attention", detail, model: null, deck: null, fieldTemplates: null }; -} - -function modelMap(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("AnkiConnect returned an invalid note type list."); - return value; -} - -function fieldList(value) { - if (!Array.isArray(value) || value.some((field) => typeof field !== "string" || field === "")) { - throw new Error("AnkiConnect returned an invalid field list."); - } - return value; -} - -// Every note type whose name leads with a supported family and whose fields the -// preset can map, with the distinct notes each one already holds. -async function eligibleCandidates(invoke, baseConfig, models) { - const eligible = []; - for (const [model, id] of Object.entries(models)) { - const family = ankiSetupFamily(model); - if (family === null || !positiveId(id)) continue; - const fields = fieldList(await invoke("modelFieldNames", { modelName: model })); - // Deck is settled later; the shape check only needs the fields. - if (ankiSetupTemplates(family, model, "Default", fields, baseConfig) === null) continue; - const notes = idList(await invoke("findNotes", { query: `mid:${id}` }), "note IDs"); - eligible.push({ model, id, family, fields, count: new Set(notes).size }); - } - return eligible; -} - -/** - * The mapping the user already saved, checked the way Settings checks it: the - * note types, the decks and that model's fields are read and the shared - * availability rules decide. Nothing is written and nothing is proposed. - * @param {(action: string, params: object) => Promise} invoke fixed read-only AnkiConnect call - * @param {object} config the saved Anki options - */ -export async function verifyAnkiSetup(invoke, config) { - const models = modelMap(await invoke("modelNamesAndIds", {})); - const decks = await invoke("deckNames", {}); - if (!Array.isArray(decks) || decks.some((deck) => typeof deck !== "string")) { - throw new Error("AnkiConnect returned an invalid deck list."); - } - const fields = Object.hasOwn(models, config.model) ? fieldList(await invoke("modelFieldNames", { modelName: config.model })) : []; - const errors = ankiAvailability(config, { connected: true, model: config.model, models: Object.keys(models), decks, fields, errors: [] }); - return errors.length === 0 - ? { status: "already-configured", detail: null, model: config.model, deck: config.deck, fieldTemplates: null } - : attention(errors[0]); -} - -/** - * @param {(action: string, params: object) => Promise} invoke fixed read-only AnkiConnect call - * @param {object} baseConfig the current (unconfigured) Anki options - */ -export async function detectAnkiSetup(invoke, baseConfig) { - const eligible = await eligibleCandidates(invoke, baseConfig, modelMap(await invoke("modelNamesAndIds", {}))); - if (eligible.length === 0) return attention("No Senren, Lapis or Kiku note type with its expected fields was found."); - const ranked = uniqueMaximum(eligible); - if (ranked.best === null) return attention("The supported note types have no notes yet."); - if (ranked.tied) return attention("Two note types share the highest note count."); - const { model, id, family, fields } = ranked.best; - - // Filtered decks are temporary; the remaining cards are grouped by their - // exact deck and each deck counts the distinct notes it represents. - const cards = idList(await invoke("findCards", { query: `mid:${id} -deck:filtered` }), "card IDs"); - if (cards.length === 0) return attention(`${model} has no cards in an ordinary deck.`); - const decks = await invoke("getDecks", { cards }); - if (!decks || typeof decks !== "object" || Array.isArray(decks)) throw new Error("AnkiConnect returned an invalid deck grouping."); - const counted = []; - for (const [deck, deckCards] of Object.entries(decks)) { - const notes = idList(await invoke("cardsToNotes", { cards: idList(deckCards, "deck card IDs") }), "deck note IDs"); - counted.push({ deck, count: new Set(notes).size }); - } - const deckRank = uniqueMaximum(counted); - if (deckRank.best === null) return attention(`${model} has no notes in an ordinary deck.`); - if (deckRank.tied) return attention(`Two decks share the most ${model} notes.`); - const fieldTemplates = ankiSetupTemplates(family, model, deckRank.best.deck, fields, baseConfig); - if (fieldTemplates === null) return attention(`${model} does not match the ${FAMILY_LABELS[family]} field layout.`); - return { status: "configured", detail: null, model, deck: deckRank.best.deck, fieldTemplates }; -} diff --git a/vendor/hachidori/extension/anki-templates.js b/vendor/hachidori/extension/anki-templates.js deleted file mode 100644 index a3684b11..00000000 --- a/vendor/hachidori/extension/anki-templates.js +++ /dev/null @@ -1,285 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import "./reader-options.js"; - -const { ANKI_FIELDS } = globalThis.HDReaderOptions; -const CORE_MARKERS = ["expression", "reading", "furigana", "furigana-plain", "dictionary", "dictionary-alias", - "definition", "glossary", "glossary-brief", "glossary-no-dictionary", "glossary-plain", "glossary-plain-no-dictionary", - "glossary-first", "glossary-first-brief", "glossary-first-no-dictionary", "main-definition", "jpmn-primary-definition", - "conjugation", "part-of-speech", "phonetic-transcriptions", "tags", "popup-selection-text", "search-query", "document-title", - "sentence", "sentence-furigana", "sentence-furigana-plain", "cloze-prefix", "cloze-body", "cloze-suffix", - "frequency", "frequencies", "frequency-harmonic-rank", "frequency-harmonic-occurrence", "frequency-average-rank", - "frequency-average-occurrence", "pitch", "pitch-position", "pitch-accent-positions", "pitch-categories", - "pitch-accent-categories", "pitch-accent-graphs", "pitch-accent-graphs-jj", - "audio", "capture-animation", "capture-audio", "screenshot"]; -const MARKER_ALIASES = new Map([["pitch-accent", "pitch"], ["pitch-accents", "pitch"]]); -const MARKER_DESCRIPTIONS = { - expression: "Dictionary form of the selected term", - reading: "Reading of the selected term", - furigana: "Expression with ruby furigana", - "furigana-plain": "Expression with bracketed plain-text furigana", - dictionary: "Title of the first definition dictionary", - "dictionary-alias": "Display name of the first definition dictionary", - definition: "All definitions with dictionary names", - glossary: "All definitions with dictionary names", - "glossary-brief": "Brief definitions from every dictionary", - "glossary-no-dictionary": "All definitions without dictionary names", - "glossary-plain": "Plain-text definitions with dictionary names", - "glossary-plain-no-dictionary": "Plain-text definitions without dictionary names", - "glossary-first": "First available definition", - "glossary-first-brief": "Brief form of the first definition", - "glossary-first-no-dictionary": "First definition without its dictionary name", - "main-definition": "First available definition", - "jpmn-primary-definition": "First available definition", - conjugation: "Deinflection and conjugation path", - "part-of-speech": "Readable part-of-speech names", - "phonetic-transcriptions": "Available phonetic transcriptions", - tags: "Definition and term tags", - "popup-selection-text": "Text selected inside the lookup popup", - "search-query": "Text used for the lookup", - "document-title": "Title of the source page", - sentence: "Source sentence with the matched text emphasized", - "sentence-furigana": "Source sentence with furigana when available", - "sentence-furigana-plain": "Plain-text source sentence with furigana when available", - "cloze-prefix": "Sentence text before the match", - "cloze-body": "Matched sentence text", - "cloze-suffix": "Sentence text after the match", - frequency: "All available frequency values", - frequencies: "All available frequency values", - "frequency-harmonic-rank": "Harmonic mean of rank-based frequencies", - "frequency-harmonic-occurrence": "Harmonic mean of occurrence-based frequencies", - "frequency-average-rank": "Arithmetic mean of rank-based frequencies", - "frequency-average-occurrence": "Arithmetic mean of occurrence-based frequencies", - pitch: "Pitch accent patterns and transcriptions", - "pitch-position": "Pitch accent drop positions", - "pitch-accent-positions": "Pitch accent drop positions", - "pitch-categories": "Pitch accent categories", - "pitch-accent-categories": "Pitch accent categories", - "pitch-accent-graphs": "Japanese pitch accent SVG graphs", - "pitch-accent-graphs-jj": "Japanese pitch accent SVG graphs with kana labels (Jidoujisho style)", - audio: "Selected pronunciation audio", - "capture-animation": "Captured animated image", - "capture-audio": "Captured sentence audio", - screenshot: "Screenshot of the source page", -}; -const DYNAMIC_MARKER_OPTIONS = [ - ["single-glossary-DICTIONARY", "Definitions from one dictionary; replace DICTIONARY with its marker name"], - ["single-glossary-DICTIONARY-brief", "Brief definitions from one dictionary"], - ["single-glossary-DICTIONARY-no-dictionary", "Definitions from one dictionary without its name"], - ["single-glossary-DICTIONARY-plain", "Plain-text definitions from one dictionary"], - ["single-glossary-DICTIONARY-plain-no-dictionary", "Plain-text definitions from one dictionary without its name"], - ["single-glossary-id--PACKAGE-ID", "Definitions selected by the dictionary package ID"], - ["single-frequency-DICTIONARY", "Formatted values from one frequency dictionary"], - ["single-frequency-number-DICTIONARY", "Numeric value from one frequency dictionary"], -]; -export const ANKI_TEMPLATE_MARKERS = Object.freeze([...CORE_MARKERS, ...MARKER_ALIASES.keys()]); -export const ANKI_TEMPLATE_MARKER_OPTIONS = Object.freeze([ - ...CORE_MARKERS.map(marker => Object.freeze({ - marker, - value: `{${marker}}`, - description: MARKER_DESCRIPTIONS[marker], - })), - ...[...MARKER_ALIASES].map(([marker, canonical]) => Object.freeze({ - marker, - value: `{${marker}}`, - description: `Legacy spelling of {${canonical}}`, - })), - ...DYNAMIC_MARKER_OPTIONS.map(([marker, description]) => Object.freeze({ - marker, - value: `{${marker}}`, - description, - })), -]); -const MARKERS = new Set([...CORE_MARKERS, ...MARKER_ALIASES.keys()]); -const DYNAMIC_PREFIXES = ["single-glossary-", "single-frequency-"]; -const MARKER_PATTERN = /\{([^{}]+)\}/gu; -const BREAK_PATTERN = //giu; -const genericAliases = { - expression: ["Expression", "Word", "Term", "Front"], reading: ["Reading", "Word Reading", "WordReading", "Kana"], - definition: ["Definition", "Definitions", "Meaning", "Glossary"], sentence: ["Sentence", "Context", "Example Sentence"], - frequency: ["Frequency", "Frequencies"], pitch: ["Pitch Accent", "PitchAccent", "Pitch", "Accent"], - audio: ["WordAudio", "PronunciationAudio", "Pronunciation", "Audio"], - captureAnimation: ["Capture Animation", "CaptureAnimation", "Sentence Animation", "SentenceAnimation"], - captureAudio: ["Capture Audio", "CaptureAudio", "Sentence Audio", "SentenceAudio"], -}; -// Reviewed against the complete Kiku 2.1.0, Lapis 1.7.0 and Senren 5.1.0 -// package schemas. Every known unsupported field is explicit so the upstream -// compatibility contract can distinguish an intentional blank from drift. -const KIKU = { - Expression: "{expression}", ExpressionFurigana: "{furigana-plain}", ExpressionReading: "{reading}", ExpressionAudio: "{audio}", - RelatedExpression: "", SelectionText: "{popup-selection-text}", MainDefinition: "{main-definition}", DefinitionPicture: "", - Sentence: "{cloze-prefix}{cloze-body}{cloze-suffix}", SentenceFurigana: "{sentence-furigana-plain}", - SentenceTranslation: "", SentenceAudio: "", Picture: "{screenshot}", Glossary: "{glossary}", Hint: "", - IsWordAndSentenceCard: "", IsClickCard: "", IsSentenceCard: "", IsAudioCard: "", - PitchPosition: "{pitch-accent-positions}", PitchCategories: "{pitch-accent-categories}", Frequency: "{frequencies}", - FreqSort: "{frequency-harmonic-rank}", MiscInfo: "{document-title}", -}; -const LAPIS = { - Expression: "{expression}", ExpressionFurigana: "{furigana-plain}", ExpressionReading: "{reading}", ExpressionAudio: "{audio}", - SelectionText: "{popup-selection-text}", MainDefinition: "{main-definition}", DefinitionPicture: "", - Sentence: "{cloze-prefix}{cloze-body}{cloze-suffix}", SentenceFurigana: "", SentenceAudio: "", - Picture: "{screenshot}", Glossary: "{glossary}", Hint: "", - IsWordAndSentenceCard: "", IsClickCard: "", IsSentenceCard: "", IsAudioCard: "", - PitchPosition: "{pitch-accent-positions}", PitchCategories: "{pitch-accent-categories}", Frequency: "{frequencies}", - FreqSort: "{frequency-harmonic-rank}", MiscInfo: "{document-title}", -}; -const KIKU_LAPIS_SLOTS = { ExpressionFurigana: "expression-furigana", ExpressionReading: "reading", ExpressionAudio: "audio", - SelectionText: "selection-text", MainDefinition: "main-definition", SentenceFurigana: "sentence-furigana", - PitchPosition: "pitch", PitchCategories: "pitch-categories", FreqSort: "frequency-sort", MiscInfo: "document-title" }; -const SENREN = { - word: "{expression}", reading: "{reading}", - sentence: '{cloze-prefix}{cloze-body}{cloze-suffix}', - sentenceFurigana: '{sentence-furigana}', - sentenceTranslation: "", sentenceCard: "", audioCard: "", notes: "", hint: "", - picture: "{screenshot}", wordAudio: "{audio}", sentenceAudio: "", - selectionText: "{popup-selection-text}", definition: "{main-definition}", glossary: "{glossary}", - pitchAccents: "{pitch}", pitchPositions: "{pitch-accent-positions}", pitchCategories: "{pitch-accent-categories}", - frequencies: "{frequencies}", freqSort: "{frequency-harmonic-rank}", miscInfo: "{document-title}", - dictionaryPreference: "", -}; -const PRESETS = { kiku: KIKU, lapis: LAPIS, senren: SENREN }; -const fieldKey = value => value.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ""); -const knownMarker = value => MARKERS.has(value) || DYNAMIC_PREFIXES.some(prefix => value.startsWith(prefix) && value.length > prefix.length); -const blankTemplate = () => ({ value: "", overwriteMode: "coalesce" }); -const semanticMarker = semantic => ({ captureAnimation: "capture-animation", captureAudio: "capture-audio" })[semantic] ?? semantic; -const semanticLabel = semantic => ({ captureAnimation: "captured animation", captureAudio: "captured audio" })[semantic] ?? semantic; -// Names the fields Anki reported, so a stale mapping can be corrected without -// opening Anki. An empty list means the note type itself is still unknown. -const availableFields = fields => fields.length === 0 ? "" - : ` Its fields are ${fields.map(field => `“${field}”`).join(", ")}.`; - -export const escapeAnkiHtml = value => String(value).replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); - -export function ankiFieldNames(fields) { - return new Map(fields.map(field => [field.toLowerCase(), field])); -} - -export function ankiTemplateMarkerNames(template) { - return [...template.matchAll(MARKER_PATTERN)].map(match => { - const name = match[1].toLowerCase(); - return MARKER_ALIASES.get(name) ?? name; - }); -} - -export function ankiCaptureRequirements(templates) { - const markers = new Set(Object.values(templates).flatMap(template => ankiTemplateMarkerNames(template.value))); - return { - includeAnimation: markers.has("capture-animation"), - includeAudio: markers.has("capture-audio"), - includeScreenshot: markers.has("screenshot"), - }; -} - -export function ankiTemplateErrors(template) { - return [...new Set([...template.matchAll(MARKER_PATTERN)].filter(match => !knownMarker(match[1].toLowerCase())) - .map(match => `Unknown marker: ${match[0]}`))]; -} - -export function isAnkiAudioOnlyTemplate(template) { - let hasAudio = false; - const rest = template.replace(MARKER_PATTERN, (match, name) => { - if (name.toLowerCase() !== "audio") return match; - hasAudio = true; - return ""; - }).replace(BREAK_PATTERN, ""); - return hasAudio && !rest.trim(); -} - -export function renderAnkiTemplate(template, values) { - const errors = ankiTemplateErrors(template); - if (errors.length) throw new Error(errors.join("\n")); - return template.split(BREAK_PATTERN).flatMap(segment => { - const markers = [...segment.matchAll(MARKER_PATTERN)]; - const rendered = segment.replace(MARKER_PATTERN, (_, name) => { - const key = name.toLowerCase(); - return values[key] ?? values[MARKER_ALIASES.get(key)] ?? ""; - }); - return markers.length && !rendered.trim() ? [] : [rendered]; - }).join("
"); -} - -function basicTemplates(config, fields) { - const canonical = ankiFieldNames(fields); - const rows = new Map(fields.map(field => [field, blankTemplate()])); - const errors = []; - for (const semantic of ANKI_FIELDS) { - const name = config.fields[semantic]; - if (!name) continue; - const field = canonical.get(name.toLowerCase()); - if (!field) { - errors.push(`The ${semanticLabel(semantic)} mapping points at field “${name}”, which is unavailable in note type “${config.model}”.${availableFields(fields)}`); - continue; - } - const row = rows.get(field); - const marker = semantic === "pitch" && field.toLowerCase() === "pitchposition" ? "pitch-position" - : semanticMarker(semantic); - row.value += `${row.value ? "
" : ""}{${marker}}`; - } - return { templates: Object.fromEntries(rows), staleFields: [], errors }; -} - -// The core a mined card needs: the expression, its reading, the sentence and a -// definition body. A note type that only shares a family name maps fewer than -// these, so first-run detection can tell a real setup from a namesake. -export function ankiPresetCoreMapped(fieldTemplates, family) { - const table = PRESETS[family]; - if (table === undefined) return false; - const values = new Set(Object.values(fieldTemplates).map(template => template.value)); - const expression = table === SENREN ? table.word : table.Expression; - const reading = table === SENREN ? table.reading : table.ExpressionReading; - const definition = table === SENREN ? table.definition : table.MainDefinition; - const glossary = table === SENREN ? table.glossary : table.Glossary; - return values.has(expression) && values.has(reading) - && (values.has(definition) || values.has(glossary)) - && values.has(table.sentence ?? table.Sentence); -} - -export function resolveAnkiTemplates(config, fields) { - if (config.fieldTemplates === null) return basicTemplates(config, fields); - const saved = config.fieldTemplates; - const folded = new Map(); - for (const name of Object.keys(saved)) { - if (!folded.has(name.toLowerCase())) folded.set(name.toLowerCase(), name); - } - const used = new Set(); - const templates = Object.fromEntries(fields.map(field => { - const name = Object.hasOwn(saved, field) ? field : folded.get(field.toLowerCase()); - if (name === undefined) return [field, blankTemplate()]; - used.add(name); - return [field, { ...saved[name] }]; - })); - const staleFields = Object.keys(saved).filter(field => !used.has(field)); - const errors = staleFields.map(field => - `Template field “${field}” is unavailable in note type “${config.model}”.${availableFields(fields)}`); - for (const [field, template] of Object.entries(templates)) { - errors.push(...ankiTemplateErrors(template.value).map(error => `Field “${field}”: ${error}`)); - } - return { templates, staleFields, errors }; -} - -export function applyAnkiPreset(config, fields, preset) { - const table = PRESETS[preset] ?? KIKU; - const suggestions = new Map(); - if (preset === "automatic") { - for (const [semantic, aliases] of Object.entries(genericAliases)) { - for (const alias of aliases) suggestions.set(fieldKey(alias), { slot: semantic, value: `{${semanticMarker(semantic)}}` }); - } - } - for (const [field, value] of Object.entries(table)) { - if (preset === "automatic" && value === "") continue; - suggestions.set(fieldKey(field), { - slot: table === SENREN ? fieldKey(field) : KIKU_LAPIS_SLOTS[field] ?? field.toLowerCase(), value, - }); - } - const used = new Set(); - const fieldTemplates = Object.fromEntries(fields.map(field => { - const suggestion = suggestions.get(fieldKey(field)); - const template = blankTemplate(); - if (suggestion && !used.has(suggestion.slot)) { - used.add(suggestion.slot); - template.value = suggestion.value; - } - return [field, template]; - })); - return { ...config, fieldTemplates }; -} diff --git a/vendor/hachidori/extension/anki-values.js b/vendor/hachidori/extension/anki-values.js deleted file mode 100644 index 12bb7a3a..00000000 --- a/vendor/hachidori/extension/anki-values.js +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import "./render/glossary.js"; -import { ankiPitchGraphs } from "./anki-pitch.js"; -import { ankiTemplateMarkerNames, renderAnkiTemplate, escapeAnkiHtml as escape } from "./anki-templates.js"; - -// Browser-native port of GSM PR #549's hoshidicts_mining.py marker values. -// DOM glossary rendering and resource preparation remain separate; only values -// actually used by the selected templates are built here. -const uniqueTokens = values => [...new Set(values.flatMap(value => value.split(/[\s,]+/u).filter(Boolean)))]; -// Keep this sanitizer unchanged: frequency and existing title-based glossary -// marker mappings depend on its exact output. -const dictionaryMarker = name => name.replace(/[_\s]/gu, "-").replace(/[^\p{L}\p{N}-]/gu, "") - .replace(/-+/gu, "-").replace(/^-|-$/gu, "").toLowerCase(); -const normalisedDictionaryMarker = name => typeof name === "string" - ? dictionaryMarker(name.normalize("NFKC")) : ""; -const dictionaryIdMarker = id => /^[0-9a-f]{32}$/u.test(id) ? `id--${id}` : ""; -const GLOSSARY_MARKER_PREFIX = "single-glossary-"; -const GLOSSARY_IDENTITY_PHASES = ["legacy", "alias", "id"]; -const GLOSSARY_VARIANTS = [ - { ending: "-brief", options: { brief: true } }, - { ending: "-no-dictionary", options: { noDictionary: true } }, - { ending: "-plain", options: { plain: true } }, - { ending: "-plain-no-dictionary", options: { plain: true, noDictionary: true } }, -]; -const PARTS_OF_SPEECH = { v1: "Ichidan verb", v5: "Godan verb", vk: "Kuru verb", vs: "Suru verb", - vz: "Zuru verb", "adj-i": "I-adjective", n: "Noun" }; -const VALUE_ALIASES = { definition: "glossary", "main-definition": "glossary-first", "jpmn-primary-definition": "glossary-first", - frequency: "frequencies", "pitch-accent-positions": "pitch-position", "pitch-categories": "pitch-accent-categories" }; -const alias = (request, dictionary) => Object.hasOwn(request.dictionaryAliases, dictionary) - ? request.dictionaryAliases[dictionary] : dictionary; - -function expressionFurigana(term, plain) { - return globalThis.HDGlossary.segmentFurigana(term.expression, term.reading).map(({ text, reading }, index) => { - if (!reading) return escape(text); - const prefix = index ? " " : ""; - return plain ? `${prefix}${escape(text)}[${escape(reading)}]` - : `${escape(text)}${escape(reading)}`; - }).join(""); -} - -function frequencyNumber(frequency) { - const prefix = typeof frequency.displayValue === "string" ? /^\d+/u.exec(frequency.displayValue) : null; - const display = prefix ? Number(prefix[0]) : 0; - return display > 0 ? display : frequency.value; -} - -function frequencyAggregate(term, mode, harmonic) { - const values = []; - for (const group of term.frequencies) { - if (group.frequencyMode && group.frequencyMode !== mode) continue; - for (const frequency of group.frequencies) { - const value = frequencyNumber(frequency); - if (value > 0) { values.push(value); break; } - } - } - if (!values.length) return mode === "rank-based" ? "9999999" : "0"; - const mean = harmonic ? values.length / values.reduce((sum, value) => sum + 1 / value, 0) - : values.reduce((sum, value) => sum + value, 0) / values.length; - return String(Math.floor(mean)); -} - -function frequencyHtml(term) { - return term.frequencies.filter(group => group.frequencies.length).map(group => - `${escape(group.dictionary)}: ${group.frequencies.map(value => escape(value.displayValue ?? value.value)).join(", ")}` - ).join("
"); -} - -function singleFrequency(request, dictionary, numeric) { - const groups = request.term.frequencies.filter(group => group.dictionary === dictionary && group.frequencies.length); - if (numeric) { - const value = groups.length ? frequencyNumber(groups[0].frequencies[0]) : 0; - return value > 0 ? String(value) : ""; - } - const items = groups.flatMap(group => group.frequencies.map(value => - `
  • ${escape(alias(request, dictionary))}: ${escape(value.displayValue ?? value.value)}
  • `)); - return items.length ? `
      ${items.join("")}
    ` : ""; -} - -function pitchHtml(term) { - return term.pitches.map(group => { - const descriptions = group.pitches.map(pitch => { - const details = []; - if (pitch.nasal.length) details.push(`nasal ${pitch.nasal.join(",")}`); - if (pitch.devoice.length) details.push(`devoice ${pitch.devoice.join(",")}`); - return (pitch.pattern || `position ${pitch.position}`) + (details.length ? ` (${details.join("; ")})` : ""); - }); - descriptions.push(...group.transcriptions); - return descriptions.length ? `${escape(group.dictionary)}: ${descriptions.map(escape).join(", ")}` : ""; - }).filter(Boolean).join("
    "); -} - -function pitchCategories(term) { - const classes = new Set(uniqueTokens([term.rules])); - const inflected = ["v1", "v5", "vk", "vs", "vz", "adj-i"].some(rule => classes.has(rule)) - && !(classes.has("vs") && classes.has("n")); - const morae = globalThis.HDGlossary.splitPitchAccentMorae(term.reading || term.expression).length; - const categories = term.pitches.flatMap(group => group.pitches.map(pitch => { - if (pitch.position === 0) return "heiban"; - if (pitch.position < 0) return null; - if (inflected) return "kifuku"; - if (pitch.position === 1) return "atamadaka"; - return pitch.position >= morae ? "odaka" : "nakadaka"; - })); - return [...new Set(categories.filter(Boolean))].join(","); -} - -function requestedGlossaryMarkers(requestedNames) { - const exactRequests = new Map(); - const variantRequests = new Map(); - const candidateBases = new Set(); - for (const name of requestedNames) { - const key = name.slice(GLOSSARY_MARKER_PREFIX.length); - exactRequests.set(key, name); - candidateBases.add(key); - for (const { ending, options } of GLOSSARY_VARIANTS) { - if (!key.endsWith(ending)) continue; - const base = key.slice(0, -ending.length); - candidateBases.add(base); - if (!variantRequests.has(base)) variantRequests.set(base, []); - variantRequests.get(base).push({ name, options }); - } - } - return { exactRequests, variantRequests, candidateBases }; -} - -function glossaryDescriptors(request, candidateBases) { - const dictionaries = [...new Set(request.term.glossaries.map(glossary => glossary.dictionary))]; - const aliases = request.dictionaryAliases ?? {}; - const wantsId = [...candidateBases].some(key => /^id--[0-9a-f]{32}$/u.test(key)); - return dictionaries.map(dictionary => ({ - dictionary, - legacy: dictionaryMarker(dictionary), - alias: Object.hasOwn(aliases, dictionary) - ? normalisedDictionaryMarker(aliases[dictionary]) : "", - id: wantsId ? dictionaryIdMarker(request.dictionaryIds?.[dictionary]) : "", - })); -} - -function claimGlossaryOwner(owners, candidateBases, key, dictionary) { - if (!key || !candidateBases.has(key)) return; - if (!owners.has(key)) owners.set(key, dictionary); - else if (owners.get(key) !== dictionary) owners.set(key, null); -} - -function glossaryIdentityOwners(descriptors, candidateBases) { - const owners = new Map(); - for (const descriptor of descriptors) { - claimGlossaryOwner(owners, candidateBases, descriptor.legacy, descriptor.dictionary); - if (descriptor.alias !== descriptor.legacy) { - claimGlossaryOwner(owners, candidateBases, descriptor.alias, descriptor.dictionary); - } - } - return owners; -} - -function glossaryIdentity(descriptor, phase, owners) { - const key = descriptor[phase]; - return phase !== "alias" || owners.get(key) === descriptor.dictionary ? key : ""; -} - -function resolveGlossaryPhase(resolved, descriptors, phase, owners, exactRequests, variantRequests) { - for (const descriptor of descriptors) { - const key = glossaryIdentity(descriptor, phase, owners); - const name = key ? exactRequests.get(key) : null; - if (name && !resolved.has(name)) resolved.set(name, { dictionary: descriptor.dictionary }); - } - // Within each namespace, all exact bases retain precedence over suffix - // variants and dictionary order matches the former eager map. - for (const descriptor of descriptors) { - const key = glossaryIdentity(descriptor, phase, owners); - for (const match of key ? variantRequests.get(key) ?? [] : []) { - if (!resolved.has(match.name)) { - resolved.set(match.name, { dictionary: descriptor.dictionary, ...match.options }); - } - } - } -} - -function dynamicGlossaries(request, requestedNames) { - const { exactRequests, variantRequests, candidateBases } = requestedGlossaryMarkers(requestedNames); - const descriptors = glossaryDescriptors(request, candidateBases); - const owners = glossaryIdentityOwners(descriptors, candidateBases); - const resolved = new Map(); - // Complete the legacy namespace before considering new identities so aliases - // and IDs cannot steal an existing title-derived suffix marker. - for (const phase of GLOSSARY_IDENTITY_PHASES) { - resolveGlossaryPhase(resolved, descriptors, phase, owners, exactRequests, variantRequests); - } - return resolved; -} - -function dynamicFrequencies(request) { - const variants = new Map(); - for (const dictionary of request.frequencyDictionaries) { - const key = dictionaryMarker(dictionary); - if (!key) continue; - variants.set(`single-frequency-number-${key}`, { dictionary, numeric: true }); - variants.set(`single-frequency-${key}`, { dictionary, numeric: false }); - } - return variants; -} - -export async function buildAnkiFields(request, templates, { definition, audio = "" }) { - const { term } = request; - let sentenceParts; - const parts = () => sentenceParts ??= [request.sentence.slice(0, request.matchOffset), - request.sentence.slice(request.matchOffset, request.matchOffset + request.matched.length), - request.sentence.slice(request.matchOffset + request.matched.length)].map(escape); - const sentence = () => { const [prefix, body, suffix] = parts(); return `${prefix}${body}${suffix}`; }; - const firstDictionary = () => term.glossaries[0]?.dictionary || ""; - const table = { - expression: () => escape(term.expression), reading: () => escape(term.reading), - furigana: () => expressionFurigana(term, false), "furigana-plain": () => expressionFurigana(term, true), - dictionary: () => escape(firstDictionary()), "dictionary-alias": () => escape(alias(request, firstDictionary())), - glossary: () => definition({}), "glossary-brief": () => definition({ brief: true }), - "glossary-no-dictionary": () => definition({ noDictionary: true }), "glossary-plain": () => definition({ plain: true }), - "glossary-plain-no-dictionary": () => definition({ plain: true, noDictionary: true }), - "glossary-first": () => definition({ firstOnly: true }), - "glossary-first-brief": () => definition({ firstOnly: true, brief: true }), - "glossary-first-no-dictionary": () => definition({ firstOnly: true, noDictionary: true }), - conjugation: () => request.trace.map(step => escape(step.name)).join(" « ") || escape(term.rules), - "part-of-speech": () => uniqueTokens([term.rules, ...term.glossaries.map(glossary => glossary.termTags)]) - .map(tag => escape(Object.hasOwn(PARTS_OF_SPEECH, tag) ? PARTS_OF_SPEECH[tag] : tag)).join(", ") || "Unknown", - tags: () => uniqueTokens(term.glossaries.flatMap(glossary => [glossary.definitionTags, glossary.termTags])) - .map(tag => `${escape(tag)}`).join(", "), - "phonetic-transcriptions": () => { - const items = term.pitches.flatMap(group => group.transcriptions).filter(Boolean).map(value => - `
  • ${escape(value)}
  • `); - return items.length ? `
      ${items.join("")}
    ` : ""; - }, - "popup-selection-text": () => escape(request.popupSelectionText), "search-query": () => escape(request.searchQuery), - "document-title": () => escape(request.documentTitle), sentence, - // GSM falls back to highlighted text when its optional native tokenizer is - // unavailable. There is no MeCab/native-helper dependency in the extension. - "sentence-furigana": sentence, "sentence-furigana-plain": sentence, - "cloze-prefix": () => parts()[0], "cloze-body": () => parts()[1], "cloze-suffix": () => parts()[2], - frequencies: () => frequencyHtml(term), - "frequency-harmonic-rank": () => frequencyAggregate(term, "rank-based", true), - "frequency-harmonic-occurrence": () => frequencyAggregate(term, "occurrence-based", true), - "frequency-average-rank": () => frequencyAggregate(term, "rank-based", false), - "frequency-average-occurrence": () => frequencyAggregate(term, "occurrence-based", false), - pitch: () => pitchHtml(term), "pitch-position": () => [...new Set(term.pitches.flatMap(group => group.pitches.map(value => value.position)))].join(", "), - "pitch-accent-graphs": () => ankiPitchGraphs(term), - "pitch-accent-graphs-jj": () => ankiPitchGraphs(term, true), - "pitch-accent-categories": () => pitchCategories(term), audio: () => audio, - "capture-animation": () => request.capturePin?.animationFilename - && !request.captureUnavailable?.includes("animation") - ? `` : "", - "capture-audio": () => request.capturePin?.audioFilename - && !request.captureUnavailable?.includes("audio") - ? `[sound:${request.capturePin.audioFilename}]` : "", - // The viewport screenshot this mining request was made from. A capture or - // upload that failed marks itself unavailable, and the field stays empty - // rather than referring to a picture Anki does not have. - screenshot: () => request.screenshot?.filename && !request.captureUnavailable?.includes("screenshot") - ? `` : "", - }; - const values = new Map(); - let glossaries, frequencies; - const glossaryMarkerNames = new Set(); - const planned = Object.entries(templates).map(([field, template]) => { - const names = new Set(ankiTemplateMarkerNames(template.value)); - for (const name of names) { - if (name.startsWith(GLOSSARY_MARKER_PREFIX)) glossaryMarkerNames.add(name); - } - return { field, template, names, markers: {} }; - }); - function valueFor(name) { - if (Object.hasOwn(VALUE_ALIASES, name)) return valueFor(VALUE_ALIASES[name]); - if (values.has(name)) return values.get(name); - let value = ""; - if (Object.hasOwn(table, name)) value = table[name](); - else if (name.startsWith(GLOSSARY_MARKER_PREFIX)) { - glossaries ??= dynamicGlossaries(request, glossaryMarkerNames); - if (glossaries.has(name)) value = definition(glossaries.get(name)); - } else if (name.startsWith("single-frequency-")) { - frequencies ??= dynamicFrequencies(request); - const variant = frequencies.get(name); - if (variant) value = singleFrequency(request, variant.dictionary, variant.numeric); - } - values.set(name, value); - return value; - } - const pending = []; - for (const { names, markers } of planned) { - for (const name of names) { - const value = valueFor(name); - if (value && typeof value.then === "function") pending.push(value.then(resolved => { markers[name] = resolved; })); - else markers[name] = value; - } - } - // Only glossary/resource values need asynchronous settlement. Ordinary text - // and frequency templates should not create a promise per field and marker. - if (pending.length) await Promise.all(pending); - return Object.fromEntries(planned.map(({ field, template, markers }) => [field, renderAnkiTemplate(template.value, markers)])); -} diff --git a/vendor/hachidori/extension/anki-worker.js b/vendor/hachidori/extension/anki-worker.js deleted file mode 100644 index 247b12ae..00000000 --- a/vendor/hachidori/extension/anki-worker.js +++ /dev/null @@ -1,528 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { createAnkiMiningService } from "./anki-mining.js"; -import { enrichAnkiNote } from "./anki-enrichment.js"; -import { createAnkiMediaStore } from "./anki-media.js"; -import { ankiTemplateMarkerNames } from "./anki-templates.js"; -import { - CAPTURE_FILENAMES, CAPTURE_LIMITS, MAX_LINKED_SCREENSHOT_BYTES, decodedBase64Length, - validateLinkedAnkiClientMedia, -} from "./anki-client-media.js"; - -function assertCapturePin(pin) { - if (!pin || typeof pin !== "object" - || typeof pin.token !== "string" || !pin.token || pin.token.length > 256 - || typeof pin.captureSessionId !== "string" || !pin.captureSessionId || pin.captureSessionId.length > 256 - || !["texthooker", "cue", "dom", "recent"].includes(pin.sourceKind) - || typeof pin.sourceLabel !== "string" || !pin.sourceLabel || pin.sourceLabel.length > 100 - || typeof pin.partial !== "boolean" - || !Number.isFinite(pin.readyAtMs) - || !CAPTURE_FILENAMES.animation.test(pin.animationFilename) - || !CAPTURE_FILENAMES.audio.test(pin.audioFilename)) { - throw new Error("The captured-media pin is invalid or expired. Look up the text again."); - } -} - -// A note that was definitively not written leaves no picture of its own behind. -async function releaseScreenshot({ writeResources, invoke }) { - const filename = writeResources?.screenshotFilename; - if (typeof filename !== "string" || filename === "") return; - await invoke("deleteMediaFile", { filename }, 10_000); -} - -function validateCapture({ request, prepared, capture: selected }) { - const media = prepared.config.mediaCapture; - if (!media?.enabled) throw new Error("Enable media capture in Settings before using captured-media markers."); - if (selected.requirements.includeAnimation && !media.includeAnimation) { - throw new Error("This note maps captured animation, but animation capture is disabled."); - } - if (selected.requirements.includeAudio && !media.includeCapturedAudio) { - throw new Error("This note maps captured audio, but captured-audio output is disabled."); - } - assertCapturePin(request.capturePin); -} - -export function createAnkiWorkerService({ - gateway, - readOptions, - readDictionaries, - engine, - offscreen, - capture = null, - duplicateIndex, -}) { - const confirmedCaptureUploads = new Map(); - const linkedClientMedia = new WeakMap(); - const linkedClientPreflights = new WeakSet(); - const ankiMediaStore = createAnkiMediaStore(); - - function isLinkedSubmission(request) { - return linkedClientMedia.has(request); - } - - async function currentGeneration(request) { - const status = await engine({ type: "hd_status" }); - if (!status.ready || status.loading || status.generation !== request.generation) { - throw new Error("The dictionary generation changed or is being updated. Look up this result again before adding it."); - } - } - - async function dictionaryMedia(item, generation) { - const reply = await engine({ type: "hd_media", dictionary: item.dictionary, path: item.path, generation }); - const match = typeof reply.dataUrl === "string" - ? /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(reply.dataUrl) - : null; - if (!match) throw new Error("The dictionary image is no longer available or is malformed."); - return match[2]; - } - const audio = (request, config, { recordSpeech = true } = {}) => { - const clientSpeech = linkedClientMedia.get(request)?.speech; - return offscreen({ - type: "hd_anki_audio", - term: request.term, - selection: request.audioSelection, - sources: config.audioSources, - recordSpeech, - ...(linkedClientPreflights.has(request) ? { clientSpeechProbe: true } : {}), - ...(clientSpeech ? { clientSpeech } : {}), - }); - }; - const render = (request, templates, audio, resources) => offscreen({ type: "hd_anki_fields", request, templates, audio, - dictionaryPaths: resources.dictionaryPaths }); - - async function captureRequest(type, fields) { - if (typeof capture !== "function") throw new Error("The captured-media host is unavailable."); - const reply = await capture({ type, ...fields }); - if (!reply || reply.ok === false) throw new Error(reply?.error || "The captured-media host did not reply."); - return reply; - } - - async function uploadCaptureAsset(kind, expectedFilename, metadata, { request, invoke, configKey }) { - if (!metadata || metadata.filename !== expectedFilename - || !Number.isSafeInteger(metadata.byteLength) || metadata.byteLength < 1 - || metadata.byteLength > CAPTURE_LIMITS[kind]) { - throw new Error(`The encoded captured ${kind} is invalid or exceeds its size limit.`); - } - // An upload confirmed by one Anki endpoint says nothing about another. - const uploadKey = `${request.captureJobId}:${configKey}:${kind}`; - if (confirmedCaptureUploads.get(uploadKey) === expectedFilename) return; - const asset = isLinkedSubmission(request) - ? metadata - : await captureRequest("hd_capture_asset", { jobId: request.captureJobId, kind }); - const byteLength = decodedBase64Length(asset.data); - if (asset.filename !== expectedFilename || byteLength !== metadata.byteLength - || byteLength > CAPTURE_LIMITS[kind]) { - throw new Error(`The captured ${kind} payload changed during preparation.`); - } - await currentGeneration(request); - const stored = await invoke("storeMediaFile", { - filename: expectedFilename, - data: asset.data, - deleteExisting: false, - }, 30_000); - if (stored !== expectedFilename) { - throw new Error(`Anki stored the captured ${kind} under a different filename.`); - } - confirmedCaptureUploads.set(uploadKey, expectedFilename); - } - - // One pending viewport picture at a time: a later capture supersedes an - // earlier one, and a note that is written consumes it. Nothing is uploaded - // until then, so a rejected note leaves no unreferenced media in Anki. - let pendingScreenshot = null; - let screenshotRequestToken = null; - - // Stored inside the queued write, once the generation, configuration and - // duplicate decisions have been made. A refused upload is a warning, and the - // fields that referenced the picture are emptied so the note never points at - // an image Anki does not have. - async function storePendingScreenshot({ request, appliedFields, invoke }) { - const filename = request.screenshot?.filename; - if (typeof filename !== "string" || filename === "") return { warnings: [] }; - const reference = ``; - const fields = Object.keys(appliedFields).filter(field => appliedFields[field].includes(reference)); - if (fields.length === 0) { - // The fields this note actually applies keep their existing picture, so the - // one that was captured for it is released rather than left held. - if (!isLinkedSubmission(request) && pendingScreenshot?.token === request.screenshot.token) pendingScreenshot = null; - return { warnings: [] }; - } - const withoutPicture = reason => { - // Pronunciation enrichment renders this request again after the note is - // saved; keep that render from restoring a picture that was not stored. - request.captureUnavailable = [...(request.captureUnavailable ?? []), "screenshot"]; - for (const field of fields) appliedFields[field] = appliedFields[field].replaceAll(reference, ""); - return { warnings: [`Screenshot: ${reason}`] }; - }; - const pending = isLinkedSubmission(request) - ? linkedClientMedia.get(request)?.screenshot - : pendingScreenshot; - // Only this note's own picture is consumed: another Add's newer capture is - // left where it is rather than taken away from it. - if (!pending || pending.token !== request.screenshot.token || pending.filename !== filename) { - return withoutPicture("the captured picture was replaced before this note was saved."); - } - if (!isLinkedSubmission(request)) pendingScreenshot = null; - try { - const stored = await invoke("storeMediaFile", { filename, data: pending.data, deleteExisting: false }, 30_000); - if (stored !== filename) throw new Error("Anki stored it under a different filename."); - } catch (error) { - // The store may have happened even though its answer did not arrive, and - // the note is about to be written without the picture: take it back out. - await releaseScreenshot({ writeResources: { screenshotFilename: filename }, invoke }).catch(() => undefined); - return withoutPicture(error.message); - } - return { warnings: [], screenshotFilename: filename }; - } - - async function prepareCapture(context) { - const screenshot = await storePendingScreenshot(context); - let clip; - try { - clip = await prepareClipCapture(context); - } catch (error) { - // No note will be written, and this rejection never reaches the caller's - // own cleanup, so the picture is taken back out here. - await releaseScreenshot({ writeResources: screenshot, invoke: context.invoke }).catch(() => undefined); - throw error; - } - if (clip === null) { - return screenshot.warnings.length === 0 && screenshot.screenshotFilename === undefined ? null : screenshot; - } - return { ...clip, ...screenshot, warnings: [...clip.warnings, ...screenshot.warnings] }; - } - - async function prepareWrite(context) { - const captureResources = await prepareCapture(context); - try { - await ankiMediaStore.prepare({ - ...context, - media: dictionaryMedia, - validate: () => currentGeneration(context.request), - }); - } catch (error) { - await releaseScreenshot({ writeResources: captureResources, invoke: context.invoke }).catch(() => undefined); - throw error; - } - return captureResources; - } - - async function prepareClipCapture(context) { - const { appliedFields, capture: selected, request } = context; - await currentGeneration(request); - if (!selected) return null; - assertCapturePin(request.capturePin); - if (typeof request.captureJobId !== "string" || !request.captureJobId || request.captureJobId.length > 256) { - throw new Error("Encode the pinned clip before submitting this note."); - } - const supplied = linkedClientMedia.get(request)?.capture; - const status = isLinkedSubmission(request) - ? { state: "ready", warnings: supplied?.warnings, assets: supplied?.assets } - : await captureRequest("hd_capture_job_status", { jobId: request.captureJobId }); - if (status.state === "finishing") throw new Error("The selected clip is still finishing."); - if (status.state === "encoding") throw new Error("The selected clip is still encoding."); - if (status.state !== "ready") throw new Error(status.error || "The selected clip could not be encoded."); - - const kinds = [ - ["animation", "includeAnimation", request.capturePin.animationFilename], - ["audio", "includeAudio", request.capturePin.audioFilename], - ]; - for (const [kind, requirement, expectedFilename] of kinds) { - if (!selected.requirements[requirement]) continue; - if (!Object.values(appliedFields).some(value => value.includes(expectedFilename))) { - throw new Error(`The applied note fields do not reference the captured ${kind}.`); - } - await uploadCaptureAsset(kind, expectedFilename, status.assets?.[kind], context); - } - return { - captureJobId: request.captureJobId, - linkedClient: isLinkedSubmission(request), - warnings: Array.isArray(status.warnings) - ? status.warnings.filter(value => typeof value === "string").map(value => value.slice(0, 500)) : [], - }; - } - - async function completeCapture({ writeResources }) { - const jobId = writeResources?.captureJobId; - if (!jobId) return; - if (!writeResources.linkedClient) await captureRequest("hd_capture_complete", { jobId }); - for (const key of confirmedCaptureUploads.keys()) { - if (key.startsWith(`${jobId}:`)) confirmedCaptureUploads.delete(key); - } - } - - async function beforeMutation({ request, writeResources }) { - await currentGeneration(request); - if (!writeResources?.captureJobId || writeResources.linkedClient) return; - const status = await captureRequest("hd_capture_job_status", { jobId: writeResources.captureJobId }); - if (status.state !== "ready") throw new Error(status.error || "The captured-media export was cancelled or expired."); - } - - const mining = createAnkiMiningService({ gateway, - readConfig: async templateId => { - const options = await readOptions(); - const template = globalThis.HDReaderOptions.ankiTemplateConfig(options.anki, templateId); - if (template === null) return null; - return { - ...template, - audioSources: options.audioSources.filter(source => source.enabled), - mediaCapture: options.mediaCapture, - prepareAudioBeforeWrite: await gateway.isSubminerEndpoint?.(template.url) === true, - }; - }, - buildFields: async (request, current, { preflight = false } = {}) => { - if (!Number.isSafeInteger(request?.generation) || request.generation < 0 - || typeof request.term?.expression !== "string" || !request.term.expression - || typeof request.term.reading !== "string") throw new Error("Mining requires a current dictionary result."); - await currentGeneration(request); - const dictionaries = await readDictionaries(); - const resources = { dictionaryPaths: Object.fromEntries(dictionaries.filter(item => item.enabled !== false) - .map(item => [item.title, item.path])), audioPrepared: false, audio: null, deferDuplicateCheck: false }; - const first = current.resolved.templates[current.discovery.fields[0]]; - const firstFieldAudio = ankiTemplateMarkerNames(first.value).includes("audio"); - // SubMiner measures pronunciation as soon as the note is written. Include - // it in that write instead of racing its media enrichment with ours. - const prepareAudio = firstFieldAudio || (!preflight && current.config.prepareAudioBeforeWrite - && Object.values(current.resolved.templates).some(template => ankiTemplateMarkerNames(template.value).includes("audio"))); - if (prepareAudio && current.config.audioSources.length) { - // Audio in the first field is part of Anki's duplicate identity. A - // failed/stale selection must not turn that identity into text-only. - // Browser speech is audible work, so preflight verifies only that the - // active capture can record it and defers the exact duplicate identity - // until the user submits. - let prepared; - try { - prepared = await audio(request, current.config, { recordSpeech: !preflight }); - } catch (error) { - if (firstFieldAudio) throw error; - resources.pronunciationWarning = `Pronunciation: ${error.message}`; - } - if (prepared?.recordingRequired === true) { - if (!preflight) throw new Error("Browser text-to-speech was not recorded for this note."); - if (prepared.clientSpeech) resources.clientSpeech = prepared.clientSpeech; - resources.deferDuplicateCheck = true; - } - else if (prepared) { - resources.audioPrepared = true; - resources.audio = prepared; - } - } - const pronunciation = resources.audio ? `[sound:${resources.audio.filename}]` - : resources.deferDuplicateCheck ? "[sound:hachidori_pending_speech.wav]" : ""; - const built = await render(request, current.resolved.templates, pronunciation, resources); - return { ...resources, ...built }; - }, - validateCapture, - beforeWrite: prepareWrite, - beforeMutation, - preflightExtra: async ({ request, prepared, applied }) => { - if (!linkedClientPreflights.has(request)) return {}; - if (prepared.resources.clientSpeech) return { clientSpeech: prepared.resources.clientSpeech }; - if (prepared.resources.audioPrepared || !applied - || !Object.values(applied.templates).some(template => - ankiTemplateMarkerNames(template.value).includes("audio")) - || prepared.config.audioSources.length === 0) return {}; - try { - const planned = await audio(request, prepared.config, { recordSpeech: false }); - return planned?.recordingRequired === true && planned.clientSpeech - ? { clientSpeech: planned.clientSpeech } - : {}; - } catch { - // Non-first-field pronunciation remains best-effort. Submission will - // report the ordinary warning if no configured source is available. - return {}; - } - }, - afterConfirmed: completeCapture, - afterRejected: releaseScreenshot, - duplicateIndex, - enrich: context => enrichAnkiNote(context, { - audio, - render, - store: (file, kind) => ankiMediaStore.ensure({ - invoke: context.invoke, - filename: file.filename, - data: file.data, - kind, - }), - }), - }); - - // One viewport screenshot for the mining action being taken now. The caller - // owns the capture itself, because only it knows which page asked; the picture - // is held here under a name a field may reference and uploaded only when the - // note is written, so this reply is immediate and the reader can show itself - // again without waiting for Anki. - async function screenshot(captureViewport, templateId) { - const token = crypto.randomUUID(); - screenshotRequestToken = token; - const { anki } = await readOptions(); - const template = globalThis.HDReaderOptions.ankiTemplateConfig(anki, templateId); - if (template === null) throw new Error("The selected Anki Template is no longer available."); - if (template.captureScreenshot !== true) throw new Error("Screenshots when mining are turned off in Settings."); - const dataUrl = await captureViewport(); - // Capture retries can complete out of order. Only the latest request may - // publish its bytes, even if a newer picture has already been consumed. - if (screenshotRequestToken !== token) throw new Error("A newer capture replaced this screenshot request."); - const prefix = "data:image/jpeg;base64,"; - const data = typeof dataUrl === "string" && dataUrl.startsWith(prefix) - ? dataUrl.slice(prefix.length) : ""; - const byteLength = decodedBase64Length(data); - if (byteLength === null || byteLength > MAX_LINKED_SCREENSHOT_BYTES) { - throw new Error("This page produced no screenshot or exceeded the 6 MiB screenshot limit."); - } - pendingScreenshot = { token, filename: `hachidori-screenshot-${crypto.randomUUID()}.jpg`, data }; - return { token: pendingScreenshot.token, filename: pendingScreenshot.filename }; - } - - // An abandoned or definitively rejected submission releases only its own - // pending bytes; uploaded media has a separate write-outcome cleanup path. - function discardScreenshot(request) { - if (pendingScreenshot !== null && pendingScreenshot.token === request?.token) pendingScreenshot = null; - return { discarded: true }; - } - - async function submitRequest(request) { - try { - const result = await mining.submit(request); - if (["duplicate", "invalid"].includes(result.state)) discardScreenshot(request.screenshot); - return result; - } catch (error) { - // The mining service reports a possibly sent mutation as uncertain; - // a rejection here confirms that its note write never happened. - discardScreenshot(request.screenshot); - throw error; - } - } - - async function submitClient(request, clientMedia) { - const validated = validateLinkedAnkiClientMedia(request, clientMedia); - linkedClientMedia.set(request, validated); - try { - return await submitRequest(request); - } finally { - linkedClientMedia.delete(request); - } - } - - async function preflightClient(request) { - linkedClientPreflights.add(request); - try { - return await mining.preflight(request); - } finally { - linkedClientPreflights.delete(request); - } - } - - async function resolveClientSpeech(request, recordSpeech) { - const plan = request?.clientSpeech; - if (!plan || typeof plan !== "object" || Array.isArray(plan) - || typeof request.term?.expression !== "string" || typeof request.term.reading !== "string" - || plan.expression !== request.term.expression || plan.reading !== request.term.reading) { - throw new Error("The linked browser-speech request is invalid or stale."); - } - const options = await readOptions(); - const sources = options.audioSources.filter(source => source.enabled); - const source = sources.find(candidate => candidate.id === plan.sourceId - && JSON.stringify(candidate) === plan.sourceKey - && typeof candidate.type === "string" && candidate.type.startsWith("text-to-speech")); - if (!source) throw new Error("The browser-speech source changed. Check Audio Settings and try again."); - if (request.audioSelection !== undefined - && (request.audioSelection?.sourceId !== source.id || request.audioSelection.sourceKey !== plan.sourceKey)) { - throw new Error("The selected pronunciation changed before browser speech could be recorded."); - } - const file = await offscreen({ - type: "hd_anki_audio", - term: request.term, - selection: request.audioSelection, - sources: [source], - recordSpeech, - }); - if (!recordSpeech) { - if (file?.recordingRequired !== true) { - throw new Error("Browser text-to-speech preflight returned an unexpected result."); - } - return { available: true }; - } - const byteLength = decodedBase64Length(file?.data); - if (file?.sourceId !== source.id || typeof file.filename !== "string" - || byteLength === null || byteLength < 1) { - throw new Error("Browser text-to-speech produced no transferable WAV data."); - } - return { - sourceId: plan.sourceId, - sourceKey: plan.sourceKey, - expression: plan.expression, - reading: plan.reading, - filename: file.filename, - byteLength, - data: file.data, - }; - } - - const clientSpeech = request => resolveClientSpeech(request, true); - const preflightClientSpeech = request => resolveClientSpeech(request, false); - - // The reading browser owns these bytes. Export them only when submission is - // about to cross the sharing socket, without consulting its local engine or - // Anki endpoint. - async function clientMedia(request) { - const value = {}; - if (request?.screenshot && !request.captureUnavailable?.includes("screenshot")) { - const screenshot = pendingScreenshot; - if (!screenshot || screenshot.token !== request.screenshot.token - || screenshot.filename !== request.screenshot.filename) { - throw new Error("The screenshot was replaced before it could be sent to the linked Hachidori."); - } - value.screenshot = { token: screenshot.token, filename: screenshot.filename, data: screenshot.data }; - } - if (typeof request?.captureJobId === "string" && request.captureJobId !== "") { - assertCapturePin(request.capturePin); - const status = await captureRequest("hd_capture_job_status", { jobId: request.captureJobId }); - if (status.state === "finishing") throw new Error("The selected clip is still finishing."); - if (status.state === "encoding") throw new Error("The selected clip is still encoding."); - if (status.state !== "ready") throw new Error(status.error || "The selected clip could not be encoded."); - const assets = {}; - for (const kind of ["animation", "audio"]) { - const metadata = status.assets?.[kind]; - if (!metadata) continue; - const asset = await captureRequest("hd_capture_asset", { jobId: request.captureJobId, kind }); - assets[kind] = { filename: asset.filename, byteLength: metadata.byteLength, data: asset.data }; - } - value.capture = { - jobId: request.captureJobId, - warnings: Array.isArray(status.warnings) - ? status.warnings.filter(warning => typeof warning === "string") - .map(warning => warning.slice(0, 500)).slice(0, 64) - : [], - assets, - }; - } - if (request?.clientSpeech) value.speech = await clientSpeech(request); - return validateLinkedAnkiClientMedia(request, value); - } - - async function settleClientMedia(request, state) { - discardScreenshot(request?.screenshot); - const jobId = request?.captureJobId; - if (typeof jobId !== "string" || jobId === "") return { settled: true }; - if (["added", "updated"].includes(state)) { - await captureRequest("hd_capture_complete", { jobId }); - } else if (["duplicate", "invalid"].includes(state)) { - await captureRequest("hd_capture_cancel", { jobId }); - } - return { settled: true }; - } - - return { ...mining, preflightClient, preflightClientSpeech, submit: submitRequest, submitClient, - clientMedia, settleClientMedia, - screenshot, discardScreenshot, async maturity(request) { - try { - const options = await readOptions(); - return { mature: options.definitionBlurAnkiMature === true - && await duplicateIndex.has(options.anki, request?.term?.expression) }; - } catch { - // Missing local evidence never prevents dictionary lookup. - return { mature: false }; - } - } }; -} diff --git a/vendor/hachidori/extension/anki.js b/vendor/hachidori/extension/anki.js deleted file mode 100644 index 03bdabd7..00000000 --- a/vendor/hachidori/extension/anki.js +++ /dev/null @@ -1,285 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { ankiTemplateMarkerNames, resolveAnkiTemplates } from "./anki-templates.js"; -import "./reader-options.js"; - -export class AnkiTransportError extends Error { - constructor(message, { dispatched }) { - super(message); - Object.defineProperty(this, "name", { value: "AnkiTransportError", configurable: true }); - Object.defineProperty(this, "dispatched", { value: dispatched === true, enumerable: false }); - } -} - -export function isUndispatchedAnkiTransportError(error) { - return error instanceof AnkiTransportError && error.dispatched === false; -} - -// Every AnkiConnect API-v6 reply, including each sub-action reply inside a -// `multi` batch, is exactly `{ result, error }` with a string or null error. -const isEnvelope = payload => payload !== null && typeof payload === "object" && !Array.isArray(payload) - && Object.keys(payload).length === 2 && Object.hasOwn(payload, "result") && Object.hasOwn(payload, "error") - && (payload.error === null || typeof payload.error === "string"); -const invalidResponse = () => new Error("AnkiConnect returned an invalid response. Check the add-on and retry."); - -// AnkiConnect's own error strings name the cause but not what to do about it. -// Each translation keeps the original text so it can still be searched for. -const ANKI_CONNECT_EXPLANATIONS = [ - [/api key/iu, () => "AnkiConnect requires a valid API key. Enter the key from its add-on configuration."], - [/collection is not available/iu, - () => "Anki has no open collection. Open your profile in Anki, then retry."], - [/^deck was not found: (.+)$/iu, - ([, deck]) => `Anki has no deck named “${deck}”. Choose an available deck in Anki Settings.`], - [/^model was not found: (.+)$/iu, - ([, model]) => `Anki has no note type named “${model}”. Choose an available note type in Anki Settings.`], - [/^cannot create note because it is empty$/iu, - () => "Anki refused the note because its first field is empty. Map the first field to content this result has."], - [/^cannot create note because it is a duplicate$/iu, - () => "Anki refused the note because a note with the same first field already exists."], - [/^note was not found: (.+)$/iu, - ([, id]) => `Anki no longer has note ${id}. It was deleted or moved to another collection; refresh and retry.`], - [/unsupported action|unknown action/iu, - () => "The installed AnkiConnect add-on is too old for this request. Update AnkiConnect in Anki."], -]; - -// Turns a raw AnkiConnect error string into the message shown to the reader. -export function describeAnkiConnectError(error) { - for (const [pattern, explain] of ANKI_CONNECT_EXPLANATIONS) { - const match = pattern.exec(error); - if (match === null) continue; - const explanation = explain(match); - return pattern === ANKI_CONNECT_EXPLANATIONS[0][0] ? explanation : `${explanation} (AnkiConnect: ${error})`; - } - return `AnkiConnect: ${error}`; -} - -function unwrap(reply) { - if (reply.error !== null) throw new Error(describeAnkiConnectError(reply.error)); - return reply.result; -} - -// Unwraps the sub-action replies of one `invoke("multi", …)` result, throwing -// the first sub-action failure the way a direct request would. -export function ankiMultiResults(replies) { - return replies.map(unwrap); -} - -function names(action, reply) { - const result = unwrap(reply); - if (!Array.isArray(result) || result.some(name => typeof name !== "string" || name.trim() === "")) { - throw new Error(`AnkiConnect returned an invalid ${action} list.`); - } - // Exact names remain authoritative; model field order determines Anki's - // required first field. Never sort the returned list or truncate it. - return [...new Set(result)]; -} - -// GSM PR #549's API-v6 discovery, adapted to the MV3 worker. AnkiConnect -// handles requests through Anki's UI loop, so each endpoint gets a small, -// bounded set of transport lanes. Four lanes let a replacement Settings check -// pass one delayed stale reply without allowing an unbounded server-side queue. -// The private worker's feature handlers still select actions and bind every -// conversation to its configured endpoint and API key. -export function createAnkiGateway({ fetch = globalThis.fetch, timeoutMs = 10_000, - readSubminerProxyUrl = async () => (await globalThis.chrome?.storage?.local?.get("subminerAnkiProxyUrl"))?.subminerAnkiProxyUrl, -} = {}) { - const maximumActive = 4; - const queues = new Map(); - - async function isSubminerEndpoint(endpoint) { - const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint); - if (url === null) return false; - try { return url === await readSubminerProxyUrl(); } - catch { return false; } - } - - async function dispatch({ url, body, requestTimeoutMs }, queue) { - const controller = new AbortController(); - queue.controllers.add(controller); - const timer = setTimeout(() => controller.abort(), requestTimeoutMs); - const unavailable = () => new AnkiTransportError( - controller.signal.aborted ? "AnkiConnect timed out. Check its URL in Settings, open Anki and retry." - : "Open Anki with the AnkiConnect add-on installed, check its URL in Settings, then retry.", - { dispatched: true }, - ); - const interrupted = () => controller.signal.reason instanceof AnkiTransportError - ? controller.signal.reason : unavailable(); - try { - let response; - try { - response = await fetch(url, { method: "POST", credentials: "omit", redirect: "error", - headers: { "Content-Type": "application/json" }, signal: controller.signal, - body }); - } catch { - throw interrupted(); - } - if (!response.ok) throw new Error(response.status === 403 - ? "AnkiConnect denied permission. Allow this extension in AnkiConnect’s webCorsOriginList, then retry." - : `AnkiConnect returned HTTP ${response.status}.`); - const payload = await response.json().catch(() => null); - if (controller.signal.aborted) throw interrupted(); - if (!isEnvelope(payload)) throw invalidResponse(); - return unwrap(payload); - } finally { - clearTimeout(timer); - queue.controllers.delete(controller); - } - } - - function failQueue(url, queue, error) { - if (queue.failure !== null) return; - queue.failure = error; - if (queues.get(url) === queue) queues.delete(url); - for (const pending of queue.pending.splice(0)) { - pending.reject(new AnkiTransportError(error.message, { dispatched: false })); - } - // Every active entry has entered fetch, so aborting one cannot prove that - // its Anki mutation did not run. Mark active siblings dispatched and keep - // their outcomes conservative while ending a failed endpoint generation - // within one transport deadline. - for (const controller of queue.controllers) { - controller.abort(new AnkiTransportError(error.message, { dispatched: true })); - } - } - - async function runEntry(url, queue, entry) { - try { - entry.resolve(await dispatch(entry.request, queue)); - } catch (error) { - entry.reject(error); - if (error instanceof AnkiTransportError) failQueue(url, queue, error); - } finally { - queue.active -= 1; - pump(url, queue); - } - } - - function pump(url, queue) { - if (queue.failure === null) { - while (queue.active < maximumActive && queue.pending.length > 0) { - const entry = queue.pending.shift(); - queue.active += 1; - void runEntry(url, queue, entry); - } - } - if (queue.active === 0 && queue.pending.length === 0 && queues.get(url) === queue) { - queues.delete(url); - } - } - - function enqueue(request) { - let queue = queues.get(request.url); - if (!queue) { - queue = { pending: [], active: 0, controllers: new Set(), failure: null }; - queues.set(request.url, queue); - } - return new Promise((resolve, reject) => { - queue.pending.push({ request, resolve, reject }); - pump(request.url, queue); - }); - } - - // AnkiConnect's socket is polled on a timer, so every request costs one poll - // interval regardless of content and parallel requests serialise. A `multi` - // batch pays that once. AnkiConnect runs each sub-action through its ordinary - // handler, which checks the API key and picks the reply shape per sub-action, - // so every sub-action is bound to this conversation's key and API v6 here and - // the reply is an array of `{ result, error }` envelopes in request order. - async function invoke(action, params, apiKey, requestTimeoutMs = timeoutMs, - endpoint = globalThis.HDReaderOptions.DEFAULT_OPTIONS.anki.url) { - const url = globalThis.HDReaderOptions.normaliseAnkiConnectUrl(endpoint); - if (url === null) throw new Error("Enter a valid HTTP or HTTPS AnkiConnect URL in Settings, without a username or password."); - const privateParams = value => value && (Object.hasOwn(value, "subminerEnrich") - || Object.hasOwn(value, "subminerDuplicateNoteIds")); - const containsPrivateParams = privateParams(params) - || (action === "multi" && params.actions.some(entry => privateParams(entry.params))); - if (containsPrivateParams) { - if (!await isSubminerEndpoint(url)) { - const strip = value => { - if (!privateParams(value)) return value; - const { subminerEnrich, subminerDuplicateNoteIds, ...publicParams } = value; - return publicParams; - }; - params = action === "multi" - ? { ...strip(params), actions: params.actions.map(entry => ({ ...entry, params: strip(entry.params) })) } - : strip(params); - } - } - const key = apiKey ? { key: apiKey } : {}; - // Sub-actions are rebuilt from their action and params so nothing else a - // caller passes reaches the wire. - const actions = action === "multi" - ? params.actions.map(entry => ({ action: entry.action, params: entry.params, version: 6, ...key })) : null; - const body = JSON.stringify({ action, version: 6, params: actions ? { actions } : params, ...key }); - const result = await enqueue({ url, body, requestTimeoutMs }); - if (actions && (!Array.isArray(result) || result.length !== actions.length || !result.every(isEnvelope))) { - throw invalidResponse(); - } - return result; - } - - // One round trip: decks, note types and, speculatively, the configured note - // type's fields. The field reply is ignored when that note type is absent. - async function discover({ model, apiKey = "", url }) { - const errors = []; - let connected = false; - let replies; - try { - replies = await invoke("multi", { actions: [ - { action: "deckNames", params: {} }, - { action: "modelNames", params: {} }, - { action: "modelFieldNames", params: { modelName: model } }, - ] }, apiKey, undefined, url); - } catch (error) { - return { connected, model, decks: [], models: [], fields: [], errors: [error.message] }; - } - function read(action, reply) { - try { - const result = names(action, reply); - connected = true; - return result; - } catch (error) { - if (!errors.includes(error.message)) errors.push(error.message); - return []; - } - } - const decks = read("deckNames", replies[0]); - const models = read("modelNames", replies[1]); - const fields = models.includes(model) ? read("modelFieldNames", replies[2]) : []; - return { connected, model, decks, models, fields, errors }; - } - return { discover, invoke, isSubminerEndpoint }; -} - -// Shared by Settings and authoritative mining readiness checks. Validation -// reports missing choices instead of changing a saved or in-progress mapping. -export function ankiAvailability(config, discovery, resolvedTemplates) { - if (!discovery) return ["Refresh Anki to check this configuration."]; - if (!discovery.connected) return discovery.errors; - const errors = [...discovery.errors]; - if (!discovery.decks.includes(config.deck)) { - errors.push(config.deck - ? `Anki has no deck named “${config.deck}”. Choose an available deck.` - : "Choose an available deck."); - } - if (!discovery.models.includes(config.model)) { - errors.push(config.model - ? `Anki has no note type named “${config.model}”. Choose an available note type.` - : "Choose an available note type."); - } - if (config.model !== discovery.model) { - return [...errors, `Refresh fields for the selected note type, “${config.model}”.`]; - } - const resolved = resolvedTemplates ?? resolveAnkiTemplates(config, discovery.fields); - errors.push(...resolved.errors); - if (discovery.fields.length > 0 && !resolved.templates[discovery.fields[0]].value.trim()) { - errors.push(`Map the first field, “${discovery.fields[0]}”, of note type “${config.model}” before adding notes. Anki requires it.`); - } - if (discovery.fields.length > 0) { - const markers = ankiTemplateMarkerNames(resolved.templates[discovery.fields[0]].value); - if (markers.includes("capture-animation") || markers.includes("capture-audio") || markers.includes("screenshot")) { - errors.push(`Captured media cannot be mapped to the first field, “${discovery.fields[0]}”.`); - } - } - if (config.model && discovery.fields.length === 0 && errors.length === 0) errors.push("The selected note type has no fields."); - return errors; -} diff --git a/vendor/hachidori/extension/api-host.js b/vendor/hachidori/extension/api-host.js deleted file mode 100644 index b2dd8481..00000000 --- a/vendor/hachidori/extension/api-host.js +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Host side of the relay's Yomitan-compatible API (hachidori-anki - * docs/host-contract.md). The relay connects as a sharing client that lives - * inside Anki and forwards each HTTP request as an `hd_api_*` runtime message; - * this module answers them from the engine's results, projected onto the - * shapes Yomitan's own API produces so existing tools work unchanged. - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./render/glossary.js"; -import { escapeAnkiHtml } from "./anki-templates.js"; - -export { API_CAPABILITY, API_CLIENT_ORIGIN } from "./sharing-protocol.js"; - -export const API_REQUESTS = new Set([ - "hd_api_version", "hd_api_term_entries", "hd_api_kanji_entries", "hd_api_anki_fields", "hd_api_tokenize", - "hd_api_dictionaries", "hd_api_dictionary_open", "hd_api_dictionary_read", "hd_api_dictionary_close", -]); - -const AUDIO_TYPES = { aac: "audio/aac", flac: "audio/flac", m4a: "audio/mp4", mp3: "audio/mpeg", ogg: "audio/ogg", - wav: "audio/wav", webm: "audio/webm" }; - -function words(value) { - return String(value ?? "").split(/\s+/u).filter(Boolean); -} - -function strings(value) { - return Array.isArray(value) ? value.filter(item => typeof item === "string") : []; -} - -function tag(name, dictionary) { - return { name, category: "", order: 0, score: 0, content: [], dictionaries: [dictionary], redundant: false }; -} - -function parseGlossary(text) { - try { - const parsed = JSON.parse(text); - return Array.isArray(parsed) ? parsed : [parsed]; - } catch { - return [String(text)]; - } -} - -// Yomitan's TermDictionaryEntry, as far as the engine's result carries it. -function termEntry(result, where) { - const { term, matched, deinflected, trace } = result; - const first = term.glossaries[0]?.dictionary ?? ""; - const headwordTags = [...new Set(term.glossaries.flatMap(glossary => words(glossary.termTags)))]; - return { - type: "term", - isPrimary: true, - textProcessorRuleChainCandidates: [[]], - inflectionRuleChainCandidates: [{ - source: "dictionary", - inflectionRules: trace.map(step => ({ name: step.name, description: step.description })), - }], - score: term.score, - frequencyOrder: 0, - dictionaryIndex: where.index(first), - dictionaryAlias: where.alias(first), - sourceTermExactMatchCount: matched === term.expression ? 1 : 0, - matchPrimaryReading: false, - maxOriginalTextLength: matched.length, - headwords: [{ - index: 0, - term: term.expression, - reading: term.reading, - sources: [{ originalText: matched, transformedText: deinflected, deinflectedText: deinflected, - matchType: "exact", matchSource: "term", isPrimary: true }], - tags: headwordTags.map(name => tag(name, first)), - wordClasses: words(term.rules), - }], - definitions: term.glossaries.map((glossary, index) => ({ - index, - headwordIndices: [0], - dictionary: glossary.dictionary, - dictionaryIndex: where.index(glossary.dictionary), - dictionaryAlias: where.alias(glossary.dictionary), - id: index, - score: term.score, - frequencyOrder: 0, - sequences: [-1], - isPrimary: true, - tags: words(glossary.definitionTags).map(name => tag(name, glossary.dictionary)), - entries: parseGlossary(glossary.glossary), - })), - pronunciations: term.pitches.map(group => ({ - headwordIndex: 0, - dictionary: group.dictionary, - dictionaryIndex: where.index(group.dictionary), - dictionaryAlias: where.alias(group.dictionary), - pronunciations: [ - ...group.pitches.map(pitch => ({ type: "pitch-accent", positions: pitch.position, - nasalPositions: pitch.nasal, devoicePositions: pitch.devoice, tags: [] })), - ...group.transcriptions.map(ipa => ({ type: "phonetic-transcription", ipa, tags: [] })), - ], - })), - frequencies: term.frequencies.flatMap(group => group.frequencies.map(value => ({ - index: 0, - headwordIndex: 0, - dictionary: group.dictionary, - dictionaryIndex: where.index(group.dictionary), - dictionaryAlias: where.alias(group.dictionary), - hasReading: typeof value.reading === 'string' && value.reading.length > 0, - frequency: value.value, - displayValue: value.displayValue || null, - displayValueParsed: false, - }))).map((entry, index) => ({ ...entry, index })), - }; -} - -function kanjiEntry(character, entry, where) { - const stats = entry.stats.map(stat => ({ name: stat.name, category: "misc", content: "", order: 0, score: 0, - dictionary: entry.dictionary, value: stat.value })); - return { - type: "kanji", - character, - dictionary: entry.dictionary, - dictionaryIndex: where.index(entry.dictionary), - dictionaryAlias: where.alias(entry.dictionary), - onyomi: words(entry.onyomi), - kunyomi: words(entry.kunyomi), - tags: words(entry.tags).map(name => tag(name, entry.dictionary)), - stats: stats.length ? { misc: stats } : {}, - definitions: strings(entry.definitions), - frequencies: [], - }; -} - -// Yomitan's kanji note fields have no counterpart in mining, which is term-only. -function kanjiFields(character, entry, markers, where) { - const stat = name => entry.stats.find(item => item.name === name)?.value ?? ""; - const table = { - character: () => escapeAnkiHtml(character), - dictionary: () => escapeAnkiHtml(entry.dictionary), - "dictionary-alias": () => escapeAnkiHtml(where.alias(entry.dictionary)), - onyomi: () => words(entry.onyomi).map(escapeAnkiHtml).join(", "), - kunyomi: () => words(entry.kunyomi).map(escapeAnkiHtml).join(", "), - glossary: () => `
      ${strings(entry.definitions).map(text => `
    • ${escapeAnkiHtml(text)}
    • `).join("")}
    `, - tags: () => words(entry.tags).map(escapeAnkiHtml).join(", "), - "stroke-count": () => escapeAnkiHtml(stat("strokes")), - frequencies: () => escapeAnkiHtml(stat("freq")), - }; - return Object.fromEntries(markers.map(marker => [marker, Object.hasOwn(table, marker) ? table[marker]() : ""])); -} - -// Yomitan's distributeFuriganaInflected: the reading covers the stem shared by -// the dictionary form and the matched text; the inflected ending has none. -function furiganaSegments(expression, reading, matched) { - const { segmentFurigana } = globalThis.HDGlossary; - if (matched === expression) return segmentFurigana(expression, reading); - let stem = 0; - while (stem < expression.length && stem < matched.length && expression[stem] === matched[stem]) stem += 1; - const ending = expression.slice(stem); - if (stem === 0 || !reading.endsWith(ending)) return [{ text: matched, reading }]; - return [...segmentFurigana(expression.slice(0, stem), reading.slice(0, reading.length - ending.length)), - { text: matched.slice(stem), reading: "" }]; -} - -function fileName(title) { - const safe = String(title).replaceAll(/[\\/:*?"<>|\u0000-\u001f]/gu, "_").trim(); - return `${safe || "dictionary"}.hachidori.zip`; -} - -function requireText(value, name) { - if (typeof value !== "string") throw new Error(`${name} must be a string`); - return value; -} - -function requireStrings(value, name) { - if (!Array.isArray(value) || value.some(item => typeof item !== "string")) throw new Error(`${name} must be an array of strings`); - return value; -} - -// `engine(fields)` answers a "hoshidicts-offscreen" request, `render(fields)` -// a "hachidori-anki-render" one; both resolve to the reply envelope or throw -// its error. `readDictionaries()` is the stored dictionary list and -// `readAudioSources()` the enabled pronunciation sources. -export function createApiHost({ engine, render, readDictionaries, readAudioSources, version }) { - async function whereabouts() { - const dictionaries = await readDictionaries(); - const titles = dictionaries.map(item => item.title); - return { - dictionaries, - index: title => Math.max(0, titles.indexOf(title)), - alias: title => dictionaries.find(item => item.title === title)?.displayName || title, - }; - } - - async function lookup(text, fields = {}) { - const reply = await engine({ type: "hd_lookup", text, ...fields }); - return { results: reply.results, generation: reply.generation }; - } - - async function ankiTermFields(text, markers, maxEntries, includeMedia) { - const where = await whereabouts(); - const { results, generation } = await lookup(text, maxEntries > 0 ? { maxResults: maxEntries } : {}); - const enabled = where.dictionaries.filter(item => item.enabled !== false); - const dictionaryPaths = Object.fromEntries(enabled.map(item => [item.title, item.path])); - const frequencyModes = new Map(where.dictionaries.map(item => [item.title, item.frequencyMode])); - const templates = Object.fromEntries(markers.map(marker => [marker, { value: `{${marker}}`, overwriteMode: "coalesce" }])); - const fields = [], dictionaryMedia = [], audioMedia = []; - const seenMedia = new Set(); - for (const result of maxEntries > 0 ? results.slice(0, maxEntries) : results) { - const term = { ...result.term, frequencies: result.term.frequencies.map(group => - ({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) }; - const request = { ...result, term, generation, sentence: text, matchOffset: 0, matched: result.matched, - searchQuery: text, popupSelectionText: "", documentTitle: "", - dictionaryAliases: Object.fromEntries(where.dictionaries.filter(item => item.displayName).map(item => [item.title, item.displayName])), - dictionaryIds: Object.fromEntries(where.dictionaries.map(item => [item.title, item.id])), - frequencyDictionaries: where.dictionaries.filter(item => item.enabled && item.frequencyCount > 0).map(item => item.title) }; - let audio = ""; - if (markers.includes("audio") && includeMedia) { - const sources = await readAudioSources(); - const prepared = sources.length - ? await render({ type: "hd_anki_audio", term: result.term, sources, recordSpeech: false }).catch(() => null) - : null; - if (typeof prepared?.filename === "string" && typeof prepared.data === "string") { - audio = `[sound:${prepared.filename}]`; - const extension = prepared.filename.split(".").at(-1).toLowerCase(); - audioMedia.push({ term: term.expression, reading: term.reading, - mediaType: AUDIO_TYPES[extension] ?? "application/octet-stream", content: prepared.data, ankiFilename: prepared.filename }); - } - } - const built = await render({ type: "hd_anki_fields", request, templates, audio, dictionaryPaths }); - fields.push(Object.fromEntries(markers.map(marker => [marker, built.fields[marker] ?? ""]))); - if (!includeMedia) continue; - for (const item of built.media) { - if (seenMedia.has(item.filename)) continue; - seenMedia.add(item.filename); - const reply = await engine({ type: "hd_media", dictionary: item.dictionary, path: item.path, generation }); - const match = typeof reply.dataUrl === "string" ? /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(reply.dataUrl) : null; - if (!match) continue; - dictionaryMedia.push({ dictionary: item.dictionary, path: item.path, mediaType: match[1], content: match[2], ankiFilename: item.filename }); - } - } - return { fields, dictionaryMedia, audioMedia }; - } - - async function ankiKanjiFields(text, markers, maxEntries) { - const where = await whereabouts(); - const character = [...text][0] ?? ""; - const reply = await engine({ type: "hd_kanji", character }); - const entries = reply.kanji?.entries ?? []; - return { fields: (maxEntries > 0 ? entries.slice(0, maxEntries) : entries).map(entry => kanjiFields(character, entry, markers, where)), - dictionaryMedia: [], audioMedia: [] }; - } - - async function tokenize(text, index, scanLength) { - const lines = []; - for (const line of text.split("\n")) { - const segments = []; - const plain = (value) => { - if (value === "") return; - const last = segments.at(-1); - if (last && last.reading === "") last.text += value; - else segments.push({ text: value, reading: "" }); - }; - let position = 0; - while (position < line.length) { - const rest = line.slice(position); - const { results } = await lookup(rest, { maxResults: 1, ...(scanLength ? { scanLength } : {}) }); - const best = results[0]; - if (!best || !best.matched || !rest.startsWith(best.matched)) { - const step = String.fromCodePoint(rest.codePointAt(0)); - plain(step); - position += step.length; - continue; - } - for (const segment of furiganaSegments(best.term.expression, best.term.reading, best.matched)) { - if (segment.reading === "") plain(segment.text); - else segments.push({ text: segment.text, reading: segment.reading }); - } - position += best.matched.length; - } - lines.push(segments); - } - return { id: "scan", source: "scanning-parser", dictionary: null, index, content: lines }; - } - - const handlers = { - hd_api_version: () => ({ version }), - - async hd_api_term_entries(message) { - const terms = requireStrings(message.terms, "terms"); - const where = await whereabouts(); - const results = []; - for (const [index, text] of terms.entries()) { - const found = text === "" ? [] : (await lookup(text)).results; - results.push({ index, dictionaryEntries: found.map(result => termEntry(result, where)), - originalTextLength: found[0]?.matched.length ?? 0 }); - } - return { results }; - }, - - async hd_api_kanji_entries(message) { - const characters = requireStrings(message.characters, "characters"); - const where = await whereabouts(); - const results = []; - for (const [index, text] of characters.entries()) { - const entries = []; - for (const character of [...text]) { - const reply = await engine({ type: "hd_kanji", character }); - for (const entry of reply.kanji?.entries ?? []) entries.push(kanjiEntry(character, entry, where)); - } - results.push({ index, dictionaryEntries: entries }); - } - return { results }; - }, - - async hd_api_anki_fields(message) { - const text = requireText(message.text, "text"); - const markers = requireStrings(message.markers, "markers").map(marker => marker.toLowerCase()); - const maxEntries = Number.isSafeInteger(message.maxEntries) && message.maxEntries > 0 ? message.maxEntries : 0; - const includeMedia = message.includeMedia === true; - if (message.entryType === "kanji") return ankiKanjiFields(text, markers, maxEntries); - if (message.entryType !== "term") throw new Error(`unsupported entry type ${JSON.stringify(message.entryType)}`); - return ankiTermFields(text, markers, maxEntries, includeMedia); - }, - - async hd_api_tokenize(message) { - const texts = requireStrings(message.texts, "texts"); - const scanLength = Number.isSafeInteger(message.scanLength) && message.scanLength > 0 ? message.scanLength : 0; - const results = []; - for (const [index, text] of texts.entries()) results.push(await tokenize(text, index, scanLength)); - return { results }; - }, - - async hd_api_dictionaries() { - const dictionaries = await readDictionaries(); - return { dictionaries: dictionaries.map(item => ({ id: item.id, title: item.title, revision: item.revision, - fileName: fileName(item.title) })) }; - }, - - async hd_api_dictionary_open(message) { - const id = requireText(message.id, "id"); - const dictionary = (await readDictionaries()).find(item => item.id === id); - if (!dictionary) return { error: "unknown dictionary", notFound: true }; - const reply = await engine({ type: "hd_api_dictionary_open", id }); - return { token: reply.token, size: reply.size, fileName: fileName(dictionary.title) }; - }, - - async hd_api_dictionary_read(message) { - const reply = await engine({ type: "hd_api_dictionary_read", token: message.token, offset: message.offset, length: message.length }); - return { data: reply.data, eof: reply.eof }; - }, - - async hd_api_dictionary_close(message) { - await engine({ type: "hd_api_dictionary_close", token: message.token }); - return {}; - }, - }; - - return async function answerApiRequest(message) { - if (!Object.hasOwn(handlers, message.type)) throw new Error(`unsupported API request ${JSON.stringify(message.type)}`); - return handlers[message.type](message); - }; -} diff --git a/vendor/hachidori/extension/assets/ATTRIBUTION.md b/vendor/hachidori/extension/assets/ATTRIBUTION.md deleted file mode 100644 index 533fd2b6..00000000 --- a/vendor/hachidori/extension/assets/ATTRIBUTION.md +++ /dev/null @@ -1,19 +0,0 @@ -# Visual novel backgrounds - -Copyright © 2026 bee-san. - -`preview-background.webp` is the repository owner's supplied artwork from -`ChatGPT Image Sep 8, 2026, 06_29_20 AM.png`. On September 8, 2026, the owner -confirmed that they own its copyright and requested its use in Hachidori, with -ownership documented for Chrome publishing. - -`preview-background-2.webp` through `preview-background-6.webp` are additional -owner-supplied scenes, from the September 8, 2026 images made at 07:16:27, -07:16:31, 07:16:38, 07:16:43 and 07:18:10, respectively. The owner requested -their inclusion in the first-run and Design scene rotation. - -Each packaged file is that artwork cropped to the 1672 × 672 panel the scene -displays, encoded as WebP. - -See the [asset ownership and publishing record](../../docs/asset-rights.md) for -the source identities, checksums, logo inventory and authorized project use. diff --git a/vendor/hachidori/extension/assets/preview-background-2.webp b/vendor/hachidori/extension/assets/preview-background-2.webp deleted file mode 100644 index 2870d8d2..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background-2.webp and /dev/null differ diff --git a/vendor/hachidori/extension/assets/preview-background-3.webp b/vendor/hachidori/extension/assets/preview-background-3.webp deleted file mode 100644 index baa3a2af..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background-3.webp and /dev/null differ diff --git a/vendor/hachidori/extension/assets/preview-background-4.webp b/vendor/hachidori/extension/assets/preview-background-4.webp deleted file mode 100644 index eae57e76..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background-4.webp and /dev/null differ diff --git a/vendor/hachidori/extension/assets/preview-background-5.webp b/vendor/hachidori/extension/assets/preview-background-5.webp deleted file mode 100644 index e1756baf..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background-5.webp and /dev/null differ diff --git a/vendor/hachidori/extension/assets/preview-background-6.webp b/vendor/hachidori/extension/assets/preview-background-6.webp deleted file mode 100644 index 238721da..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background-6.webp and /dev/null differ diff --git a/vendor/hachidori/extension/assets/preview-background.webp b/vendor/hachidori/extension/assets/preview-background.webp deleted file mode 100644 index 1d3e1c0e..00000000 Binary files a/vendor/hachidori/extension/assets/preview-background.webp and /dev/null differ diff --git a/vendor/hachidori/extension/audio-cache.js b/vendor/hachidori/extension/audio-cache.js deleted file mode 100644 index fe45704c..00000000 --- a/vendor/hachidori/extension/audio-cache.js +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Shared retention policy for candidate metadata and offscreen-owned media. -// A value too large to retain is still usable by its caller, uncached. -export function createAudioCache({ maxEntries, maxBytes, ttlMs, onEvict, now = () => performance.now() }) { - const entries = new Map(); - let retainedBytes = 0; - - function remove(key) { - const entry = entries.get(key); - if (!entry) return; - entries.delete(key); - retainedBytes -= entry.bytes; - onEvict?.(entry.value); - } - - return { - delete: remove, - get(key) { - const entry = entries.get(key); - if (!entry) return undefined; - if (entry.expiresAt <= now()) { remove(key); return undefined; } - entries.delete(key); - entries.set(key, entry); - return entry.value; - }, - set(key, value, bytes) { - remove(key); - if (bytes > maxBytes) return false; - entries.set(key, { value, bytes, expiresAt: now() + ttlMs }); - retainedBytes += bytes; - while (entries.size > maxEntries || retainedBytes > maxBytes) remove(entries.keys().next().value); - return true; - }, - clear() { for (const key of entries.keys()) remove(key); }, - }; -} diff --git a/vendor/hachidori/extension/audio-content.js b/vendor/hachidori/extension/audio-content.js deleted file mode 100644 index 1ecdcd3a..00000000 --- a/vendor/hachidori/extension/audio-content.js +++ /dev/null @@ -1,327 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -(function () { - "use strict"; - - function current(record) { - return record.button.isConnected && !record.popup.hidden && record.isCurrent(); - } - - function setBusy(record, busy) { - record.button.setAttribute("aria-busy", String(busy)); - if (busy) record.button.dataset.state = "loading"; - else if (record.button.dataset.state !== "error") delete record.button.dataset.state; - record.button.setAttribute("aria-label", `${busy ? "Stop" : "Play"} pronunciation for ${record.term.expression}`); - record.button.title = `${busy ? "Stop" : "Play"} pronunciation; Shift-click, right-click or press Down for choices`; - } - - function createAudioController({ window, send, onMenuChange, onSelectionChange = () => {} }) { - const document = window.document; - const bound = new WeakMap(), visited = new WeakMap(), feedback = new WeakMap(); - const controls = new Set(); - let selections = new WeakMap(); - let options = window.HDReaderOptions.DEFAULT_OPTIONS; - let sourceKey = JSON.stringify(options.audioSources); - let active = null, menu = null; - let optionsReady = false; - // One first result per owner may wait for options or until the owner's - // blurred definitions are revealed; its first-visit key stays unconsumed meanwhile. - const pendingAutoplay = new Map(); - - function audioAvailable() { - return options.audioSources.some(source => source.enabled - && (source.type.startsWith("text-to-speech") || source.url.trim())); - } - - function updateControls() { - const available = audioAvailable(); - for (const record of controls) { - if (!record.button.isConnected) { controls.delete(record); continue; } - record.button.hidden = !available; - const control = record.button.closest(".gsm-hoshidicts-audio-control"); - if (control) control.hidden = !available; - } - } - - function firstVisit(record) { - if (!record.request) return false; - let keys = visited.get(record.request); - if (!keys) { keys = new Set(); visited.set(record.request, keys); } - if (keys.has(record.autoplayKey)) return false; - keys.add(record.autoplayKey); - return true; - } - - // A retired hold keeps its visit: the same request may bind again and its - // reveal still settles it, while a request that is gone has no key to - // spend. A manual play consumes every waiting result. - function cancelAutoplay(owner, consumeHeld = true) { - for (const [key, record] of pendingAutoplay) { - if (owner !== undefined && key !== owner) continue; - if (consumeHeld || !record.autoplayHeld?.()) firstVisit(record); - pendingAutoplay.delete(key); - } - } - - function autoplay(record) { - if (!current(record)) return; - if (!optionsReady || record.autoplayHeld?.()) { - const previous = pendingAutoplay.get(record.owner); - if (previous && previous.autoplayKey !== record.autoplayKey) firstVisit(previous); - pendingAutoplay.set(record.owner, record); - return; - } - if (firstVisit(record) && options.audioAutoplay && audioAvailable()) void play(record); - } - - function owns(operation) { - return active === operation && current(operation.record); - } - - function setStatus(record, text) { - if (text) record.button.dataset.state = "error"; - let output = feedback.get(record.button) || record.status; - if (!output && !text) return; - if (!output) { - output = document.createElement("output"); - output.className = "gsm-hoshidicts-audio-status"; - output.setAttribute("aria-live", "polite"); - record.button.after(output); - } - feedback.set(record.button, output); - output.textContent = text; - } - - function stop() { - if (!active) return; - const previous = active; - active = null; - setBusy(previous.record, false); - setStatus(previous.record, ""); - void send("hd_audio_stop", { playRequestId: previous.requestId }).catch(() => {}); - } - - function closeMenu(restoreFocus = true) { - if (!menu) return false; - const previous = menu; - menu = null; - if (active?.type === "hd_audio_candidates" && active.record === previous.record) stop(); - previous.element.remove(); - previous.record.button.setAttribute("aria-expanded", "false"); - if (restoreFocus && current(previous.record)) previous.record.button.focus({ preventScroll: true }); - onMenuChange(previous.record.owner); - return true; - } - - function retire(owner) { - cancelAutoplay(owner, false); - if (menu && (owner === undefined || menu.record.owner === owner)) closeMenu(false); - if (active && (owner === undefined || active.record.owner === owner)) stop(); - } - - async function request(record, type, fields, accept) { - cancelAutoplay(); - firstVisit(record); - stop(); - if (!current(record) || !audioAvailable()) return; - const operation = { record, type, requestId: window.crypto.randomUUID() }; - active = operation; - setBusy(record, true); - setStatus(record, ""); - try { - const reply = await send(type, { term: record.term, requestId: operation.requestId, ...fields }); - if (!owns(operation)) return; - if (!reply.ok) throw new Error(reply.error); - accept(reply); - } catch (error) { - if (owns(operation)) { - if (type === "hd_audio_play" && selections.get(record.result) === fields.selection) { - selections.delete(record.result); - onSelectionChange(record.owner); - } - setStatus(record, `Could not play: ${error.message}`); - if (menu?.record === record) menu.output.textContent = error.message; - } - } finally { - if (active === operation) { active = null; setBusy(record, false); } - } - } - - function play(record, selection = selections.get(record.result)) { - closeMenu(); - return request(record, "hd_audio_play", selection ? { selection } : {}, reply => { - setStatus(record, reply.status === "no-result" ? "No pronunciation was returned. Check Audio Settings." : ""); - // The pronunciation the user just heard is the one Add to Anki should - // attach, so a downloadable recording becomes the selection exactly as - // a menu choice would. Browser speech has no recording to pin. - if (!selection && reply.status === "success" && typeof reply.sourceKey === "string" - && typeof reply.candidate?.url === "string") { - selections.set(record.result, { sourceId: reply.sourceId, sourceKey: reply.sourceKey, ...record.term, - index: reply.candidate.index, url: reply.candidate.url, name: reply.candidate.name }); - onSelectionChange(record.owner); - } - }); - } - - function choices(record) { - closeMenu(false); - if (!current(record) || !audioAvailable()) return; - const element = document.createElement("section"); - element.className = "gsm-hoshidicts-audio-menu gsm-hoshidicts-audio-choices"; - element.setAttribute("role", "dialog"); - element.setAttribute("aria-label", `Pronunciation for ${record.term.expression}`); - const heading = document.createElement("strong"); - heading.className = "gsm-hoshidicts-audio-menu-heading"; - heading.textContent = `Pronunciation · ${record.term.expression}`; - const close = document.createElement("button"); - close.type = "button"; - close.className = "gsm-hoshidicts-audio-menu-item gsm-hoshidicts-audio-menu-close"; - close.textContent = "Close"; - close.addEventListener("click", () => closeMenu()); - const output = document.createElement("p"); - output.className = "gsm-hoshidicts-audio-menu-status"; - output.setAttribute("role", "status"); - output.textContent = "Finding choices…"; - element.append(heading, close, output); - record.popup.append(element); - menu = { element, output, record }; - record.button.setAttribute("aria-expanded", "true"); - onMenuChange(record.owner); - close.focus({ preventScroll: true }); - void request(record, "hd_audio_candidates", {}, reply => { - let count = 0; - for (const [sourceIndex, group] of reply.groups.entries()) { - const section = document.createElement("div"); - const title = document.createElement("h4"); - title.className = "gsm-hoshidicts-audio-menu-heading"; - title.textContent = `${sourceIndex + 1}. ${window.HDReaderOptions.AUDIO_SOURCE_LABELS[group.type]}`; - section.append(title); - if (group.error) { - const error = document.createElement("p"); - error.textContent = group.error; - section.append(error); - } - for (const [index, candidate] of (group.candidates || []).entries()) { - count += 1; - const button = document.createElement("button"); - button.className = "gsm-hoshidicts-audio-menu-item"; - button.type = "button"; - button.textContent = candidate.name || `Pronunciation ${index + 1}`; - button.addEventListener("click", () => { - const selection = { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term, - index, url: candidate.url ?? null, name: candidate.name }; - selections.set(record.result, selection); - onSelectionChange(record.owner); - void play(record, selection); - }); - section.append(button); - } - element.append(section); - } - setStatus(record, ""); - output.textContent = count ? "Choose a pronunciation to play." : "No pronunciations found. Check Audio Settings."; - onMenuChange(record.owner); - }); - } - - function bind(items, context) { - if (active?.record.owner === context.owner && !current(active.record)) stop(); - if (menu?.record.owner === context.owner && !current(menu.record)) closeMenu(false); - const first = items[0]; - if (!first) return; - const autoplayKey = JSON.stringify([context.request?.selectedDictionaryTab, first.result.term.expression, first.result.term.reading]); - for (const item of items) { - if (bound.has(item.button)) { bound.get(item.button).autoplayKey = autoplayKey; continue; } - const record = { ...item, ...context, autoplayKey, - term: { expression: item.result.term.expression, reading: item.result.term.reading || "" } }; - bound.set(item.button, record); - controls.add(record); - item.button.addEventListener("click", event => { - if (event.shiftKey) choices(record); - else if (active?.record.button === item.button) stop(); - else void play(record); - }); - item.button.addEventListener("contextmenu", event => { event.preventDefault(); choices(record); }); - item.button.addEventListener("keydown", event => { - if (event.key === "ArrowDown") { event.preventDefault(); choices(record); } - }); - } - updateControls(); - autoplay(bound.get(first.button)); - } - - // Keybinds play the entry's pronunciation even while it is already playing, - // which a click would stop. A source ID plays that source's first choice. - function playButton(button, sourceId = "") { - const record = bound.get(button); - if (!record || !current(record) || !audioAvailable()) return false; - if (!sourceId) { - void play(record); - return true; - } - void request(record, "hd_audio_candidates", {}, reply => { - const group = reply.groups.find(item => item.sourceId === sourceId); - const candidate = group?.candidates?.[0]; - if (!candidate) { - setStatus(record, "No pronunciation was returned. Check Audio Settings."); - return; - } - void play(record, { sourceId: group.sourceId, sourceKey: group.sourceKey, ...record.term, - index: 0, url: candidate.url ?? null, name: candidate.name }); - }); - return true; - } - - const listener = message => { - if (!active || message?.target !== "hachidori-audio-content" || message.type !== "hd_audio_playing" - || message.requestId !== active?.requestId || !current(active.record)) return; - active.record.button.dataset.state = "playing"; - }; - window.chrome.runtime.onMessage.addListener(listener); - // Chrome 128 can tear down the content owner before content.js receives - // pagehide. Listen here too so the offscreen player is stopped while this - // document can still identify its owned request. - const onPageHide = () => retire(); - window.addEventListener("pagehide", onPageHide); - return { - bind, retire, closeMenu, playButton, - hasMenu: owner => Boolean(menu && (owner === undefined || menu.record.owner === owner)), - selectionFor: result => selections.get(result) ?? null, - // Releases the owner's held first result for exactly this request once its - // definitions are revealed. A stale request's late reveal leaves a newer view alone. - settleAutoplay(owner, request) { - const record = pendingAutoplay.get(owner); - if (!record || record.request !== request) return; - pendingAutoplay.delete(owner); - autoplay(record); - }, - update(next, ready = true) { - const waiting = !optionsReady && ready ? [...pendingAutoplay] : []; - for (const [owner] of waiting) pendingAutoplay.delete(owner); - const nextKey = JSON.stringify(next.audioSources); - if (nextKey !== sourceKey) { retire(); selections = new WeakMap(); } - else if (options.audioAutoplay && !next.audioAutoplay) retire(); - sourceKey = nextKey; - options = next; - optionsReady = ready; - updateControls(); - for (const [owner, record] of waiting) { - pendingAutoplay.set(owner, record); - // Adopt the complete storage event, including lookup invalidation, - // before retrying a first result that rendered with unknown options. - window.queueMicrotask(() => { - if (pendingAutoplay.get(owner) !== record) return; - pendingAutoplay.delete(owner); - autoplay(record); - }); - } - }, - dispose() { - retire(); - controls.clear(); - window.removeEventListener("pagehide", onPageHide); - window.chrome.runtime.onMessage.removeListener(listener); - }, - }; - } - globalThis.HDAudio = { createAudioController }; -}()); diff --git a/vendor/hachidori/extension/audio-offscreen.js b/vendor/hachidori/extension/audio-offscreen.js deleted file mode 100644 index 9fb9b380..00000000 --- a/vendor/hachidori/extension/audio-offscreen.js +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { selectExtensionApi } from "./browser-api.js"; -import { createAudioPlayer } from "./audio-player.js"; -import { createAudioRepository, selectedAudioPlan } from "./audio-repository.js"; - -const TEST_TERM = { expression: "聞く", reading: "きく" }; -// Matches the reference Settings Test deadline; ordinary dictionary work never -// waits on this timer or the pronunciation's network/audio callbacks. -const TEST_TIMEOUT_MS = 15_000; -const FALLBACK_TIMEOUT_MS = 12_000; - -// Identify the played source the way the candidate menu does, so the reader -// can pin the pronunciation it just played as its Anki selection. -function withSourceKey(played, sources) { - if (played.status !== "success") return played; - const source = sources.find(candidate => candidate.id === played.sourceId); - return { ...played, sourceKey: JSON.stringify(source) }; -} - -export function createAudioService(window, repository = createAudioRepository({ window, fetch: window.fetch.bind(window), now: () => window.performance.now() })) { - const extensionApi = selectExtensionApi(window); - const player = createAudioPlayer({ window, repository }); - let active = null; - let watchingVoices = false; - - function stop(reason = new DOMException("Playback stopped.", "AbortError")) { - active?.controller.abort(reason); - player.stop(reason); - } - window.addEventListener("pagehide", () => { stop(); player.dispose(); }, { once: true }); - - function voices() { - if (!watchingVoices) { - window.speechSynthesis.addEventListener("voiceschanged", () => { - extensionApi.runtime.sendMessage({ target: "hachidori-audio-ui", type: "hd_audio_voices_changed", voices: voices() }) - .catch(() => {}); // The Settings page may already have closed. - }); - watchingVoices = true; - } - return window.speechSynthesis.getVoices().map(({ voiceURI, name, lang, localService, default: isDefault }) => - ({ voiceURI, name, lang, localService, default: isDefault })); - } - - async function candidateGroups(sources, term, signal) { - const groups = []; - for (const source of sources) { - const group = { sourceId: source.id, sourceKey: JSON.stringify(source), type: source.type }; - try { - group.candidates = await repository.candidates(source, term, signal); - } catch (error) { - signal.throwIfAborted(); - group.error = error.message; - } - groups.push(group); - } - signal.throwIfAborted(); - return { groups }; - } - - return async message => { - if (message.type === "hd_audio_voices") return { voices: voices() }; - if (message.type === "hd_audio_stop") { - if (active && active.owner === message.owner && active.requestId === message.playRequestId) stop(); - return { status: "cancelled" }; - } - if (!["hd_audio_test", "hd_audio_play", "hd_audio_candidates"].includes(message.type)) throw new Error("Unknown audio request."); - stop(); - const operation = { owner: message.owner, requestId: message.requestId, controller: new AbortController() }; - active = operation; - const isTest = message.type === "hd_audio_test"; - let remaining = isTest ? TEST_TIMEOUT_MS : FALLBACK_TIMEOUT_MS; - let timer = null, armedAt; - function resumeDeadline() { - if (timer !== null) return; - armedAt = window.performance.now(); - timer = window.setTimeout(() => { - if (active === operation) stop(new Error(isTest ? "Audio Test timed out after 15 seconds." : "Pronunciation discovery timed out after 12 seconds.")); - }, remaining); - } - function pauseDeadline() { - if (timer === null) return; - window.clearTimeout(timer); - timer = null; - remaining = Math.max(0, remaining - (window.performance.now() - armedAt)); - } - resumeDeadline(); - try { - if (isTest) return await player.play(message.source, TEST_TERM); - const { signal } = operation.controller; - if (message.type === "hd_audio_candidates") return await candidateGroups(message.sources, message.term, signal); - const plan = message.selection - ? await selectedAudioPlan(repository, message.sources, message.term, message.selection, signal) : { sources: message.sources }; - signal.throwIfAborted(); - const played = await player.playSources(plan.sources, message.term, { candidate: plan.candidate, - onResolving: resumeDeadline, - onPlaying(value) { - if (active !== operation) return; - // This bounds discovery/fallback, not the duration of a playable file. - pauseDeadline(); - extensionApi.runtime.sendMessage({ target: "hachidori-audio-events", type: "hd_audio_playing", - owner: operation.owner, requestId: operation.requestId, ...value }).catch(() => {}); - }, - }); - return withSourceKey(played, plan.sources); - } catch (error) { - if (operation.controller.signal.aborted && error?.name === "AbortError") return { status: "cancelled" }; - throw error; - } - finally { - window.clearTimeout(timer); - if (active === operation) active = null; - } - }; -} diff --git a/vendor/hachidori/extension/audio-player.js b/vendor/hachidori/extension/audio-player.js deleted file mode 100644 index 23897d8f..00000000 --- a/vendor/hachidori/extension/audio-player.js +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { createAudioRepository } from "./audio-repository.js"; -import { resolveSpeech } from "./speech.js"; - -// One pronunciation owner; playback leases and native speech callbacks belong -// to that operation. The shared offscreen repository owns warm media, not WASM. -export function createAudioPlayer({ window, fetch, repository = createAudioRepository({ window, fetch }) }) { - let current = null; - - function stop(reason = new DOMException("Playback stopped.", "AbortError")) { - current?.abort(reason); - current = null; - } - - async function playUrl(candidate, signal, onPlaying) { - const lease = await repository.acquire(candidate, signal); - let audio; - let abort; - try { - signal.throwIfAborted(); - audio = new window.Audio(lease.url); - const ended = new Promise((resolve, reject) => { - audio.onended = resolve; - audio.onplaying = () => { if (!signal.aborted) onPlaying?.(candidate); }; - audio.onerror = () => { - lease.invalidate(); - reject(new Error("The pronunciation could not be decoded or played.")); - }; - abort = () => reject(signal.reason); - signal.addEventListener("abort", abort, { once: true }); - }); - await Promise.all([Promise.resolve().then(() => audio.play()), ended]); - } finally { - if (audio) { - signal.removeEventListener("abort", abort); - audio.onended = audio.onerror = audio.onplaying = null; - audio.pause(); - audio.removeAttribute("src"); - audio.load(); - } - lease.release(); - } - } - - async function playSpeech(source, term, signal, onPlaying) { - const { speech, utterance, candidate } = await resolveSpeech(window, source, term, signal); - signal.throwIfAborted(); - let abort; - try { - await new Promise((resolve, reject) => { - utterance.onend = resolve; - utterance.onstart = () => { if (!signal.aborted) onPlaying?.(candidate); }; - utterance.onerror = event => { - const detail = event.error ? ` (${event.error})` : ""; - reject(new Error(`Text-to-speech could not be played${detail}.`)); - }; - abort = () => { - // Cancel synchronously, before a newer operation can speak. A late - // finally calling global speech.cancel() would stop that new voice. - utterance.onend = utterance.onerror = utterance.onstart = null; - speech.cancel(); - reject(signal.reason); - }; - signal.addEventListener("abort", abort, { once: true }); - speech.speak(utterance); - }); - return candidate; - } finally { - signal.removeEventListener("abort", abort); - utterance.onend = utterance.onerror = utterance.onstart = null; - } - } - - async function firstPlayable(found, signal, onPlaying, onResolving) { - let failure; - for (const [index, entry] of found.entries()) { - try { - onResolving?.(); - const candidate = { ...entry, index: entry.index ?? index }; - await playUrl(candidate, signal, onPlaying); - return candidate; - } catch (error) { - signal.throwIfAborted(); - failure = error; - } - } - throw failure; - } - - async function playSources(sources, term, { onPlaying, onResolving, candidate: selectedCandidate } = {}) { - stop(); - const controller = new AbortController(); - current = controller; - const { signal } = controller; - try { - let failure; - for (const source of sources) { - try { - onResolving?.(); - const playing = candidate => onPlaying?.({ sourceId: source.id, candidate }); - const candidate = await playSource(source, term, signal, playing, onResolving, selectedCandidate); - signal.throwIfAborted(); - if (candidate) return { status: "success", sourceId: source.id, candidate }; - } catch (error) { - signal.throwIfAborted(); - failure = error; - } - } - if (failure) throw failure; - return { status: "no-result" }; - } catch (error) { - if (signal.aborted && signal.reason?.name === "AbortError") return { status: "cancelled" }; - throw signal.aborted ? signal.reason : error; - } finally { - if (current === controller) current = null; - } - } - - async function playSource(source, term, signal, onPlaying, onResolving, selectedCandidate) { - if (source.type.startsWith("text-to-speech")) return playSpeech(source, term, signal, onPlaying); - const found = selectedCandidate ? [selectedCandidate] : await repository.candidates(source, term, signal); - signal.throwIfAborted(); - return found.length ? firstPlayable(found, signal, onPlaying, onResolving) : null; - } - - return { - stop, playSources, - play: (source, term) => playSources([source], term), - dispose() { stop(); repository.clear(); }, - }; -} diff --git a/vendor/hachidori/extension/audio-repository.js b/vendor/hachidori/extension/audio-repository.js deleted file mode 100644 index 5cf65bfc..00000000 --- a/vendor/hachidori/extension/audio-repository.js +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { createAudioCache } from "./audio-cache.js"; -import { audioSourceUrl, parseAudioSourceList } from "./audio-sources.js"; - -export async function selectedAudioPlan(repository, sources, term, selection, signal) { - const source = sources.find(source => source.id === selection.sourceId && JSON.stringify(source) === selection.sourceKey); - if (!source || selection.expression !== term.expression || selection.reading !== term.reading) { - throw new Error("This pronunciation selection is no longer current. Choose it again."); - } - const candidates = await repository.candidates(source, term, signal); - const candidate = candidates[selection.index]; - if (!candidate || (candidate.url ?? null) !== selection.url || candidate.name !== selection.name) { - throw new Error("The provider's pronunciation choices changed. Choose again."); - } - return { sources: [source], candidate: { ...candidate, index: selection.index } }; -} - -export function createAudioRepository({ window, fetch, now = () => performance.now() }) { - // GSM PR #549's retention budgets, not input or playback size limits. - const candidates = createAudioCache({ maxEntries: 256, maxBytes: 2 * 1024 * 1024, ttlMs: 5 * 60_000, now }); - const media = createAudioCache({ maxEntries: 64, maxBytes: 64 * 1024 * 1024, ttlMs: 30 * 60_000, now, - onEvict(entry) { entry.retained = false; releaseUnused(entry); } }); - const encoder = new TextEncoder(); - - function releaseUnused(entry) { - if (!entry.retained && entry.users === 0) window.URL.revokeObjectURL(entry.url); - } - - async function response(url, signal) { - const result = await fetch(url, { credentials: "omit", signal }); - if (!result.ok) throw new Error(`Audio provider returned HTTP ${result.status}.`); - return result; - } - - return { - async candidates(source, term, signal) { - signal.throwIfAborted(); - const key = JSON.stringify([source, term.expression, term.reading]); - const cached = candidates.get(key); - if (cached) return cached; - let found = []; - if (source.type.startsWith("text-to-speech")) found = [{ name: source.voice || "Automatic Japanese" }]; - else if (source.url) { - const url = audioSourceUrl(source.url, term); - if (source.type === "custom") found = [{ url, name: "" }]; - else found = parseAudioSourceList(await (await response(url, signal)).json()); - } - signal.throwIfAborted(); - candidates.set(key, found, encoder.encode(key).byteLength + encoder.encode(JSON.stringify(found)).byteLength); - return found; - }, - async acquire(candidate, signal) { - signal.throwIfAborted(); - let entry = media.get(candidate.url); - if (entry) entry.users += 1; - else { - const blob = await (await response(candidate.url, signal)).blob(); - signal.throwIfAborted(); - entry = { blob, url: window.URL.createObjectURL(blob), users: 1, retained: false }; - entry.retained = media.set(candidate.url, entry, blob.size + encoder.encode(candidate.url).byteLength); - } - return { - url: entry.url, - blob: entry.blob, - release() { entry.users -= 1; releaseUnused(entry); }, - invalidate() { if (media.get(candidate.url) === entry) media.delete(candidate.url); }, - }; - }, - clear() { candidates.clear(); media.clear(); }, - }; -} diff --git a/vendor/hachidori/extension/audio-settings.js b/vendor/hachidori/extension/audio-settings.js deleted file mode 100644 index c9b1d0ed..00000000 --- a/vendor/hachidori/extension/audio-settings.js +++ /dev/null @@ -1,177 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import { selectExtensionApi } from "./browser-api.js"; -import { reorderSettingsRows } from "./settings-dom.js"; -function labelControl(control, label) { - if (control.getAttribute("aria-label") !== label) control.setAttribute("aria-label", label); -} - -function setTesting(row, testing) { - const text = testing ? "Stop" : "Test"; - if (row.test.textContent !== text) row.test.textContent = text; - labelControl(row.test, `${testing ? "Stop Test" : "Test 聞く / きく"}: ${row.number.textContent.toLowerCase()}`); -} - -export function createAudioSettingsController({ document, readSources, editSources, send }) { - const window = document.defaultView; - const extensionApi = selectExtensionApi(window); - const list = document.getElementById("audio-source-list"); - const rows = new Map(); - const labels = window.HDReaderOptions.AUDIO_SOURCE_LABELS; - let voices = []; - let voiceVersion = 0; - let active = null; - - function stop() { - if (!active) return; - const previous = active; - active = null; - previous.row.status.textContent = ""; - setTesting(previous.row, false); - void send("hd_audio_stop", { playRequestId: previous.requestId }).catch(() => {}); - } - - function change(id, patch) { - editSources(readSources().map(source => source.id === id ? { ...source, ...patch } : source)); - render(); - } - - async function testSource(id, row) { - if (active?.id === id) { stop(); return; } - stop(); - const source = readSources().find(source => source.id === id); - const operation = { id, row, source: JSON.stringify(source), requestId: window.crypto.randomUUID() }; - row.testedSource = operation.source; - active = operation; - row.status.textContent = ""; - setTesting(row, true); - try { - const reply = await send("hd_audio_test", { source, requestId: operation.requestId }); - if (active !== operation) return; - if (!reply.ok) throw new Error(reply.error); - row.status.textContent = reply.status === "no-result" ? "No pronunciation was returned." : ""; - } catch (error) { - if (active === operation) row.status.textContent = `Could not play: ${error.message}`; - } finally { - if (active === operation) { active = null; setTesting(row, false); } - } - } - - function move(id, offset) { - const sources = [...readSources()]; - const index = sources.findIndex(source => source.id === id); - [sources[index], sources[index + offset]] = [sources[index + offset], sources[index]]; - editSources(sources); - render(); - } - - function createRow(source) { - const element = document.createElement("li"); - element.className = "audio-source-row"; - // Only static markup. Provider names and user text are assigned as text or - // control values below, never interpolated into HTML. - element.innerHTML = `
    - -
    -
    - - - -
    `; - 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"(
    ch
    )"), - R"({"tag":"table","content":[{"tag":"tbody","content":[{"tag":"tr","content":[)" - R"({"tag":"td","colSpan":2,"rowSpan":3,"content":["c"]},{"tag":"th","content":["h"]}]}]}]})", - "table with spans; non-numeric span ignored; tbody inserted by the parser"); - check_eq(body(R"(
    sd
    )"), - R"({"tag":"details","open":true,"content":[{"tag":"summary","content":["s"]},"d"]})", "details open"); - check_eq(body("(かん)"), - R"({"tag":"ruby","content":["漢",{"tag":"rp","content":["("]},{"tag":"rt","content":["かん"]},)" - R"~({"tag":"rp","content":[")"]}]})~", - "ruby"); -} - -void test_styles() { - check_eq(body(R"(s)"), - R"({"tag":"span","style":{"color":"red","textDecorationLine":["underline","line-through"],"fontSize":"12px"},)" - R"("content":["s"]})", - "inline style mapping and text-decoration splitting"); - check_eq(body(R"(s)"), - R"({"tag":"span","style":{"fontWeight":"normal"},"content":["s"]})", "inline style overrides default"); - check_eq(body(R"(f)"), - R"({"tag":"span","style":{"color":"red","fontSize":"3","fontFamily":"Arial"},"content":["f"]})", "font"); - check_eq(body(R"(
    c
    )"), - R"({"tag":"table","content":[{"tag":"tbody","content":[{"tag":"tr","content":[{"tag":"td","content":["c"]}]}]}]})", - "style dropped on tags whose schema has none"); - mdict::ConvertResult result = mdict::convert_html(R"(x)", {}); - check(result.inline_stylesheets.size() == 2, "two non-empty style blocks collected"); - if (result.inline_stylesheets.size() == 2) { - check_eq(result.inline_stylesheets[0].first, "inline/1.css", "inline stylesheet name"); - check_eq(result.inline_stylesheets[0].second, ".a{color:red}", "inline stylesheet text"); - check_eq(result.inline_stylesheets[1].first, "inline/2.css", "second inline stylesheet name"); - } - result = mdict::convert_html(R"(s)", {}); - check(result.asset_references == std::vector{"img/bg.png"}, "url() in inline style referenced"); - check(result.glossary_json.find(R"("background":"url(\"mdict-media/img/bg.png\") no-repeat")") != std::string::npos, - "url() in inline style rewritten"); -} - -void test_links() { - check_eq(body(R"(e)"), - R"({"tag":"a","href":"?query=%E9%A3%9F%E3%81%B9%E3%82%8B","content":["e"]})", "entry:// link"); - check_eq(body(R"(e)"), R"({"tag":"a","href":"?query=a%20b","content":["e"]})", "bword://"); - check_eq(body(R"(e)"), R"({"tag":"a","href":"?query=term","content":["e"]})", "x: link"); - check_eq(body(R"(e)"), - R"({"tag":"a","href":"https://example.com/a?b=1","content":["e"]})", "https kept"); - check_eq(body(R"~(efg)~"), - R"({"tag":"a","href":"#","content":["e"]},{"tag":"a","href":"#","content":["f"]},)" - R"({"tag":"a","href":"#","content":["g"]})", - "script and fragment links neutralised"); - check_eq(body(R"(e)"), - R"({"tag":"a","href":"media:mdict-media/img/x%20y.png","content":["e"]})", "relative path -> media:"); - check_eq(body(R"(e)"), R"({"tag":"a","href":"#","content":["e"]})", - "sound:// is # with audio disabled"); - mdict::ConvertOptions audio; - audio.enable_audio = true; - check_eq(body(R"(e)", audio), - R"({"tag":"a","href":"media:mdict-media/a.spx","content":["e"]})", "sound:// with audio enabled"); - mdict::ConvertResult result = mdict::convert_html(R"(ef)", {}); - check(result.asset_references == std::vector{"a.spx", "img/p.png"}, "link asset references collected"); - check_eq(body(R"()"), - R"({"tag":"a","href":"media:mdict-media/snd/a.mp3","content":["audio"]},)" - R"({"tag":"a","href":"media:mdict-media/v.mp4","content":["cap"]})", - "audio/video become links"); -} - -void test_images() { - check_eq(body(R"(pic)"), - R"({"tag":"img","path":"mdict-media/img/pic.png","data":{"tag":"img","class":"c"},"width":10,"height":20,)" - R"("title":"t","alt":"pic"})", - "img attributes"); - check_eq(body(R"()"), "", "img without a usable source dropped"); - mdict::ConvertResult result = - mdict::convert_html(R"(t)", {}); - check(result.embedded_assets.size() == 2, "two embedded assets"); - if (result.embedded_assets.size() == 2) { - const auto& png = result.embedded_assets[0]; - check(png.path.starts_with("mdict-media/embedded/image/") && png.path.ends_with(".png"), "png asset path " + png.path); - check(png.data == std::vector{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, "base64 decoded"); - const auto& txt = result.embedded_assets[1]; - check(txt.path.starts_with("mdict-media/embedded/text/") && txt.path.ends_with(".bin"), "text asset path " + txt.path); - check(std::string(txt.data.begin(), txt.data.end()) == "hi there", "percent-decoded payload"); - check(result.glossary_json.find("\"path\":\"" + png.path + "\"") != std::string::npos, "img path points at asset"); - check(result.glossary_json.find("\"href\":\"media:" + txt.path + "\"") != std::string::npos, "link points at asset"); - } - check_eq(body(R"()"), "", "malformed data URL dropped"); - // Identical data yields one asset with the same name whichever entry saw it first. - mdict::ConvertResult twice = mdict::convert_html( - R"()", {}); - check(twice.embedded_assets.size() == 1, "identical embedded assets deduplicated"); -} - -void test_depth_limit() { - std::string html; - for (int i = 0; i < 40; ++i) { - html += "
    "; - } - html += "deep"; - for (int i = 0; i < 40; ++i) { - html += "
    "; - } - mdict::ConvertOptions options; - options.max_depth = 20; - const std::string content = body(html, options); - size_t divs = 0; - for (size_t pos = content.find("{\"tag\":\"div\""); pos != std::string::npos; - pos = content.find("{\"tag\":\"div\"", pos + 1)) { - divs++; - } - check(divs == 19, "nesting flattened to the depth limit (19 nested divs under the root), got " + std::to_string(divs)); - check(content.find("deep") != std::string::npos, "deep text kept"); -} - -void test_apply_stylesheet() { - const std::string sheet = "1\n\n\n2\n\n\n"; - check_eq(mdict::apply_stylesheet("`1`to eat`2` (ichidan)", sheet), "to eat (ichidan)", - "backtick styles expanded"); - check_eq(mdict::apply_stylesheet("plain `1`x\n`9`kept", sheet), "plain x\r\nkept", - "line-ending segment, unknown style keeps text"); - check_eq(mdict::apply_stylesheet("no markers", sheet), "no markers", "no markers"); - check_eq(mdict::apply_stylesheet("`1`x", ""), "`1`x", "no stylesheet"); - check_eq(mdict::apply_stylesheet("a ` b `1`c", sheet), "a ` b c", "stray backtick is text"); -} - -void test_paths_and_css() { - check_eq(mdict::normalize_asset_path("\\Images\\BG.PNG?x=1#frag"), "Images/BG.PNG", "backslashes, query, fragment"); - check_eq(mdict::normalize_asset_path("/images/space%20name.png"), "images/space name.png", "percent decoded"); - check_eq(mdict::normalize_asset_path("images/bad%ZZname.png"), "images/bad%ZZname.png", "malformed escape kept"); - check_eq(mdict::normalize_asset_path("../../etc/passwd"), "etc/passwd", "traversal collapsed"); - check_eq(mdict::normalize_asset_path("a/./b/../c"), "a/c", "dot segments"); - check_eq(mdict::normalize_asset_path("entry://x"), "", "scheme is not an asset"); - check_eq(mdict::normalize_asset_path("//host/x"), "", "protocol-relative is not an asset"); - check_eq(mdict::normalize_asset_path("file:///abs/x.png"), "abs/x.png", "file scheme stripped"); - check_eq(mdict::normalize_asset_path("../images/bg.png", "styles/extra.css"), "images/bg.png", - "relative to stylesheet"); - check_eq(mdict::normalize_asset_path("./bg.png", "top.css"), "bg.png", "relative to top-level stylesheet"); - - std::vector refs; - check_eq(mdict::rewrite_css_asset_urls(R"(a{background:url("../images/bg.png") ;b:URL( 'x.png' );c:url(data:x,y)})", - "mdict-media/", "styles/extra.css", refs), - R"(a{background:url("mdict-media/images/bg.png") ;b:url("mdict-media/x.png");c:url(data:x,y)})", - "css url rewriting (only ./ and ../ resolve against the stylesheet path)"); - check(refs == std::vector{"images/bg.png", "x.png"}, "css references collected"); - check_eq(mdict::rewrite_css_asset_urls("url(", "mdict-media/", "", refs), "url(", "unterminated url() kept"); - check_eq(mdict::encode_uri_component("a b/漢-_.!~*'()"), "a%20b%2F%E6%BC%A2-_.!~*'()", "encodeURIComponent"); -} -} - -int main() { - test_basic_formatting(); - test_data_and_attributes(); - test_styles(); - test_links(); - test_images(); - test_depth_limit(); - test_apply_stylesheet(); - test_paths_and_css(); - if (failures == 0) { - std::printf("ok\n"); - } - return failures == 0 ? 0 : 1; -} diff --git a/vendor/hachidori/third_party/hoshidicts/tests/import_equivalence_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/import_equivalence_test.cpp deleted file mode 100644 index 4485a86f..00000000 --- a/vendor/hachidori/third_party/hoshidicts/tests/import_equivalence_test.cpp +++ /dev/null @@ -1,237 +0,0 @@ -// Regression guard for the importer's output format. -// -// Imports tests/fixtures/yomitan/small_dict.zip into a fresh directory and -// checks the SHA-256 of every output file against tests/fixtures/yomitan/ -// golden.sha256, which was produced by the importer before the DictionarySource -// seam existed. index.json is compared with its importDate removed. The import -// is run twice, normal and low_ram, and both must match the golden. -// -// import_equivalence_test -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "hoshidicts/importer.hpp" - -namespace { -// FIPS 180-4 SHA-256, enough for a test. -class Sha256 { - public: - void update(const uint8_t* data, size_t len) { - total_ += len; - while (len > 0) { - const size_t take = std::min(len, buffer_.size() - buffered_); - std::memcpy(buffer_.data() + buffered_, data, take); - buffered_ += take; - data += take; - len -= take; - if (buffered_ == buffer_.size()) { - block(buffer_.data()); - buffered_ = 0; - } - } - } - - std::string hex() { - const uint64_t bits = total_ * 8; - const uint8_t one = 0x80; - update(&one, 1); - const uint8_t zero = 0; - while (buffered_ != 56) { - update(&zero, 1); - } - std::array length{}; - for (int i = 0; i < 8; ++i) { - length[static_cast(i)] = static_cast(bits >> (56 - 8 * i)); - } - update(length.data(), length.size()); - std::string out; - static const char digits[] = "0123456789abcdef"; - for (uint32_t word : state_) { - for (int shift = 28; shift >= 0; shift -= 4) { - out += digits[(word >> shift) & 0xf]; - } - } - return out; - } - - private: - static uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } - - void block(const uint8_t* p) { - static constexpr std::array k = { - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2}; - std::array w{}; - for (size_t i = 0; i < 16; ++i) { - w[i] = (uint32_t{p[4 * i]} << 24) | (uint32_t{p[4 * i + 1]} << 16) | (uint32_t{p[4 * i + 2]} << 8) | - uint32_t{p[4 * i + 3]}; - } - for (size_t i = 16; i < 64; ++i) { - const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); - const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); - w[i] = w[i - 16] + s0 + w[i - 7] + s1; - } - uint32_t a = state_[0], b = state_[1], c = state_[2], d = state_[3]; - uint32_t e = state_[4], f = state_[5], g = state_[6], h = state_[7]; - for (size_t i = 0; i < 64; ++i) { - const uint32_t s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); - const uint32_t ch = (e & f) ^ (~e & g); - const uint32_t t1 = h + s1 + ch + k[i] + w[i]; - const uint32_t s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); - const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); - const uint32_t t2 = s0 + maj; - h = g; - g = f; - f = e; - e = d + t1; - d = c; - c = b; - b = a; - a = t1 + t2; - } - state_[0] += a; - state_[1] += b; - state_[2] += c; - state_[3] += d; - state_[4] += e; - state_[5] += f; - state_[6] += g; - state_[7] += h; - } - - std::array state_ = {0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, - 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19}; - std::array buffer_{}; - size_t buffered_ = 0; - uint64_t total_ = 0; -}; - -std::string read_file(const std::filesystem::path& path) { - std::ifstream in(path, std::ios::binary); - std::ostringstream out; - out << in.rdbuf(); - return out.str(); -} - -std::string sha256_hex(std::string data) { - Sha256 sha; - sha.update(reinterpret_cast(data.data()), data.size()); - return sha.hex(); -} - -// The import date is the only thing that legitimately differs between runs. -std::string strip_import_date(std::string json) { - static const std::regex import_date(R"("importDate":\d+)"); - return std::regex_replace(json, import_date, R"("importDate":0)"); -} - -std::map load_golden(const std::filesystem::path& path) { - std::map golden; - std::ifstream in(path); - std::string line; - while (std::getline(in, line)) { - if (line.empty() || line[0] == '#') { - continue; - } - const size_t split = line.find_first_of(" \t"); - if (split == std::string::npos) { - continue; - } - const std::string hash = line.substr(0, split); - const size_t name_start = line.find_first_not_of(" \t*", split); - golden[line.substr(name_start)] = hash; - } - return golden; -} - -std::filesystem::path fresh_temp_dir(const char* tag) { - std::random_device rd; - const auto dir = std::filesystem::temp_directory_path() / - ("hoshidicts-eq-" + std::string(tag) + "-" + std::to_string(rd())); - std::filesystem::create_directories(dir); - return dir; -} - -int check_import(const std::string& fixture, const std::map& golden, bool low_ram) { - const auto out_dir = fresh_temp_dir(low_ram ? "lowram" : "normal"); - int failures = 0; - const ImportResult result = dictionary_importer::import(fixture, out_dir.string(), low_ram); - if (!result.success) { - std::printf("FAIL low_ram=%d import failed: %s\n", low_ram, result.error.c_str()); - std::filesystem::remove_all(out_dir); - return 1; - } - const auto dict_dir = out_dir / result.title; - - std::map actual; - for (const auto& entry : std::filesystem::directory_iterator(dict_dir)) { - std::string data = read_file(entry.path()); - const std::string name = entry.path().filename().string(); - if (name == "index.json") { - data = strip_import_date(std::move(data)); - } - actual[name] = sha256_hex(std::move(data)); - } - - for (const auto& [name, hash] : golden) { - auto it = actual.find(name); - if (it == actual.end()) { - std::printf("FAIL low_ram=%d missing output %s\n", low_ram, name.c_str()); - failures++; - } else if (it->second != hash) { - std::printf("FAIL low_ram=%d %s\n expected %s\n actual %s\n", low_ram, name.c_str(), hash.c_str(), - it->second.c_str()); - failures++; - } - } - for (const auto& [name, hash] : actual) { - if (!golden.contains(name)) { - std::printf("FAIL low_ram=%d unexpected output %s (%s)\n", low_ram, name.c_str(), hash.c_str()); - failures++; - } - } - - std::filesystem::remove_all(out_dir); - if (failures == 0) { - std::printf("ok low_ram=%d %zu files match\n", low_ram, golden.size()); - } - return failures; -} -} - -int main(int argc, char** argv) { - if (argc < 3) { - std::printf("usage: %s \n", argv[0]); - return 2; - } - // Known-answer check so a broken hash cannot make everything "match". - if (sha256_hex("abc") != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") { - std::printf("FAIL sha256 self test\n"); - return 1; - } - const auto golden = load_golden(argv[2]); - if (golden.empty()) { - std::printf("FAIL no golden hashes in %s\n", argv[2]); - return 1; - } - int failures = 0; - failures += check_import(argv[1], golden, false); - failures += check_import(argv[1], golden, true); - return failures == 0 ? 0 : 1; -} diff --git a/vendor/hachidori/third_party/hoshidicts/tests/json_skip_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/json_skip_test.cpp deleted file mode 100644 index 51fcc2b8..00000000 --- a/vendor/hachidori/third_party/hoshidicts/tests/json_skip_test.cpp +++ /dev/null @@ -1,83 +0,0 @@ -// Differential test: skip_json_container must stop exactly where glaze's raw_json_view -// skip stops (or fail exactly when it fails) on generated arrays with nested -// values, strings full of quotes, brackets and backslash runs straddling the -// 64-byte blocks, multibyte text, and truncated input. -#include "json/json_skip.hpp" -#include -#include -#include -#include - -static std::string gen(std::mt19937& rng, int depth) { - std::uniform_int_distribution kind(0, 9); - std::string s = "["; - int n = std::uniform_int_distribution(0, 6)(rng); - for (int i = 0; i < n; ++i) { - if (i) s += ","; - int k = kind(rng); - if (k < 4 || depth > 4) { - // string with random content including quotes, brackets, backslash runs, multibyte - s += '"'; - int len = std::uniform_int_distribution(0, 150)(rng); - for (int j = 0; j < len; ++j) { - int c = std::uniform_int_distribution(0, 12)(rng); - switch (c) { - case 0: s += "\\\""; break; - case 1: s += "\\\\"; break; - case 2: { int run = std::uniform_int_distribution(1, 9)(rng); for (int r = 0; r < run; ++r) s += "\\\\"; if (rng() & 1) s += "\\\""; break; } - case 3: s += "["; break; - case 4: s += "]"; break; - case 5: s += "\\u30c6"; break; - case 6: s += "テスト"; break; - case 7: s += "\\n"; break; - default: s += char('a' + (rng() % 26)); break; - } - } - s += '"'; - } else if (k < 6) { - s += std::to_string(int(rng() % 1000)); - } else if (k < 8) { - s += gen(rng, depth + 1); - } else { - s += "{\"k\":" + gen(rng, depth + 1) + ",\"x\":\"]]][[\\\"\"}"; - } - } - s += "]"; - return s; -} - -int main() { - std::mt19937 rng(12345); - long cases = 0, mismatches = 0; - for (int iter = 0; iter < 200000; ++iter) { - std::string doc = gen(rng, 0); - if (iter % 3 == 1) { - // Object at the top level: wrap the array's items as values. - doc = "{\"a\":" + doc + ",\"b\":{\"c\":\"}}]\",\"d\":[" + gen(rng, 3) + "]},\"e\":" + gen(rng, 2) + "}"; - } - // pad with a tail so the value is followed by more input, like in a bank - std::string buf = doc + ",\"tail\",[1,2]]"; - // sometimes truncate to test the unexpected-end path - bool truncated = (iter % 7 == 0); - if (truncated) buf = doc.substr(0, std::uniform_int_distribution(0, doc.size() - 1)(rng)); - const char* begin = buf.data(); - const char* end = buf.data() + buf.size(); - const char* mine = hoshidicts::skip_json_container(begin, end); - glz::raw_json_view ref; - glz::context ctx{}; - const char* it = begin; - glz::from::op(ref, ctx, it, end); - bool ref_ok = !bool(ctx.error); - const char* ref_end = ref_ok ? it : nullptr; - ++cases; - if ((mine == nullptr) != (ref_end == nullptr) || (mine && mine != ref_end)) { - ++mismatches; - if (mismatches <= 5) { - std::printf("MISMATCH truncated=%d mine=%ld ref=%ld doc=%.*s\n", truncated, mine ? long(mine - begin) : -1L, - ref_end ? long(ref_end - begin) : -1L, int(std::min(buf.size(), 300)), buf.data()); - } - } - } - std::printf("cases=%ld mismatches=%ld\n", cases, mismatches); - return mismatches ? 1 : 0; -} diff --git a/vendor/hachidori/third_party/hoshidicts/tests/long_key_scan_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/long_key_scan_test.cpp deleted file mode 100644 index c08bf715..00000000 --- a/vendor/hachidori/third_party/hoshidicts/tests/long_key_scan_test.cpp +++ /dev/null @@ -1,243 +0,0 @@ -// Long-key scan index (src/scan_index.hpp): a dictionary with keys longer than -// the scan length must still have them found when the input begins like one, -// an inflected form of such a key must be found through deinflection, and a -// scan shorter than eight code points must not extend at all. -#include "hoshidicts/deinflector.hpp" -#include "hoshidicts/importer.hpp" -#include "hoshidicts/lookup.hpp" -#include "hoshidicts/query.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -uint32_t crc32(const std::string& data) { - uint32_t crc = 0xFFFFFFFFu; - for (unsigned char c : data) { - crc ^= c; - for (int i = 0; i < 8; ++i) { - crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); - } - } - return ~crc; -} - -template -void put(std::string& out, T value) { - char buf[sizeof(T)]; - std::memcpy(buf, &value, sizeof(T)); - out.append(buf, sizeof(T)); -} - -// A stored (method 0) ZIP: local headers, then the central directory, then EOCD. -std::string build_zip(const std::vector>& files) { - std::string out; - std::string central; - for (const auto& [name, data] : files) { - const uint32_t offset = static_cast(out.size()); - const uint32_t crc = crc32(data); - const uint32_t size = static_cast(data.size()); - put(out, 0x04034b50); - put(out, 20); - put(out, 0); - put(out, 0); - put(out, 0); - put(out, 0); - put(out, crc); - put(out, size); - put(out, size); - put(out, static_cast(name.size())); - put(out, 0); - out += name; - out += data; - - put(central, 0x02014b50); - put(central, 20); - put(central, 20); - put(central, 0); - put(central, 0); - put(central, 0); - put(central, 0); - put(central, crc); - put(central, size); - put(central, size); - put(central, static_cast(name.size())); - put(central, 0); - put(central, 0); - put(central, 0); - put(central, 0); - put(central, 0); - put(central, offset); - central += name; - } - const uint32_t central_offset = static_cast(out.size()); - out += central; - put(out, 0x06054b50); - put(out, 0); - put(out, 0); - put(out, static_cast(files.size())); - put(out, static_cast(files.size())); - put(out, static_cast(central.size())); - put(out, central_offset); - put(out, 0); - return out; -} - -int failures = 0; - -void check(bool ok, const std::string& what) { - if (!ok) { - ++failures; - std::fprintf(stderr, "FAIL: %s\n", what.c_str()); - } -} - -bool has_expression(const std::vector& results, std::string_view expression) { - for (const auto& r : results) { - if (r.term.expression == expression) { - return true; - } - } - return false; -} - -size_t cp_len(std::string_view s) { - size_t n = 0; - for (unsigned char c : s) n += (c & 0xC0) != 0x80; - return n; -} - -std::string term(std::string_view expression, std::string_view reading, std::string_view rules = "") { - std::string t = "[\""; - t += expression; - t += "\",\""; - t += reading; - t += "\",\"\",\""; - t += rules; - t += "\",1,[\"gloss\"],0,\"\"]"; - return t; -} - -} // namespace - -int main() { - const std::filesystem::path root = - std::filesystem::temp_directory_path() / ("hoshidicts-long-key-test-" + std::to_string(std::random_device{}())); - std::filesystem::remove_all(root); - std::filesystem::create_directories(root); - - // A 27-code-point proverb whose reading is longer still; a 17-code-point - // phrase ending in a verb (rules v1 so 〜られなかった deinflects); a - // two-character key sharing the proverb's first characters but not its first - // eight; and an ordinary verb. - const std::string proverb = "身体髪膚これを父母に受くあえて毀傷せざるは孝の始めなり"; - const std::string proverb_reading = "しんたいはっぷこれをふぼにうくあえてきしょうせざるはこうのはじめなり"; - const std::string phrase = "自分の思うところをはっきりと述べる"; - const std::string bank = "[" + term(proverb, proverb_reading) + "," + - term(phrase, "じぶんのおもうところをはっきりとのべる", "v1") + "," + term("身体", "しんたい") + - "," + term("食べる", "たべる", "v1") + "]"; - const std::string index = R"({"title":"long-key-test","format":3,"revision":"1"})"; - const std::string zip = build_zip({{"index.json", index}, {"term_bank_1.json", bank}}); - const std::filesystem::path zip_path = root / "long-key-test.zip"; - { - std::ofstream f(zip_path, std::ios::binary); - f.write(zip.data(), static_cast(zip.size())); - } - - const std::filesystem::path out_dir = root / "out"; - std::filesystem::create_directories(out_dir); - const auto result = dictionary_importer::import(zip_path.string(), out_dir.string()); - check(result.success, "import succeeded: " + result.error); - if (!result.success) { - return 1; - } - const std::filesystem::path dict_dir = out_dir / result.summary.title; - check(std::filesystem::is_regular_file(dict_dir / "scan.idx"), "importer wrote scan.idx"); - - DictionaryQuery query; - check(query.add_term_dict(dict_dir.string()), "add_term_dict"); - check(query.max_long_key_length() == cp_len(proverb_reading), - "max_long_key_length is the reading's length " + std::to_string(cp_len(proverb_reading)) + ", got " + - std::to_string(query.max_long_key_length())); - check(query.long_key_length(proverb) == cp_len(proverb), "long_key_length(proverb) is its length"); - check(query.long_key_length("身体髪膚これを父") == cp_len(proverb), "eight-code-point prefix resolves the proverb"); - check(query.long_key_length(proverb_reading) == cp_len(proverb_reading), "the reading is indexed too"); - check(query.long_key_length("身体") == 0, "shorter than eight code points resolves nothing"); - check(query.long_key_length("食べるのが好きです") == 0, "a prefix of no long key resolves nothing"); - - Deinflector deinflector; - Lookup lookup(query, deinflector); - const std::string tail = "と昔から言われている。"; - - // Scan 16 alone cannot see a 26-code-point key; the index extends it. - { - const auto results = lookup.lookup(proverb + tail, 16, 16); - check(has_expression(results, proverb), "proverb found with scan 16 through the long-key index"); - check(has_expression(results, "身体"), "shorter matches are still reported"); - bool matched_whole = false; - for (const auto& r : results) { - if (r.term.expression == proverb) { - matched_whole = r.matched == proverb; - } - } - check(matched_whole, "the proverb's matched text is the whole proverb"); - } - - // An inflected form of the 17-code-point verb phrase: surface 22 code points. - { - const std::string inflected = "自分の思うところをはっきりと述べられなかった"; - const auto results = lookup.lookup(inflected + tail, 16, 16); - check(has_expression(results, phrase), "inflected long phrase found through deinflection"); - } - - // Not a long-key prefix: the ordinary scan result only. - { - const auto results = lookup.lookup("食べられなかった" + proverb, 16, 16); - check(has_expression(results, "食べる"), "ordinary verb still found"); - check(!has_expression(results, proverb), "no extension for a prefix that is not a long key"); - } - - // Scan shorter than the eight-code-point prefix never extends. - { - const auto results = lookup.lookup(proverb + tail, 16, 4); - check(has_expression(results, "身体"), "scan 4 finds the two-character key"); - check(!has_expression(results, proverb), "scan 4 does not extend to the proverb"); - } - - // Scan 8 is the shortest that can extend. - { - const auto results = lookup.lookup(proverb + tail, 16, 8); - check(has_expression(results, proverb), "scan 8 extends to the proverb"); - } - - // The text itself bounds the extension. - { - const auto results = lookup.lookup(proverb.substr(0, proverb.find("せざる")), 16, 16); - check(!has_expression(results, proverb), "a truncated proverb is not matched"); - check(has_expression(results, "身体"), "truncated input still yields the short key"); - } - - // Per-dictionary lookup consults only that dictionary's index. - { - const auto results = lookup.lookup_dictionary(proverb + tail, dict_dir.string(), 16, 16); - check(has_expression(results, proverb), "lookup_dictionary extends through its own index"); - const std::string other = dict_dir.string() + "-missing"; - check(query.long_key_length(proverb, &other) == 0, "an unknown dictionary path resolves nothing"); - } - - std::filesystem::remove_all(root); - if (failures) { - std::fprintf(stderr, "%d failure(s)\n", failures); - return 1; - } - std::puts("long-key scan: ok"); - return 0; -} diff --git a/vendor/hachidori/third_party/hoshidicts/tests/mdict_reader_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/mdict_reader_test.cpp deleted file mode 100644 index 620f886f..00000000 --- a/vendor/hachidori/third_party/hoshidicts/tests/mdict_reader_test.cpp +++ /dev/null @@ -1,300 +0,0 @@ -// Unit tests for mdict::Reader against the fixtures written by -// tests/fixtures/mdict/gen_fixtures.py. -// -// mdict_reader_test -#include -#include -#include -#include -#include -#include -#include - -#include "mdict/mdict_reader.hpp" -#include "mdict/ripemd128.hpp" - -namespace { -int failures = 0; -std::filesystem::path fixtures; - -void check(bool ok, const std::string& what) { - if (!ok) { - std::printf("FAIL %s\n", what.c_str()); - failures++; - } -} - -template -void check_eq(const T& actual, const T& expected, const std::string& what) { - if (actual != expected) { - std::printf("FAIL %s\n", what.c_str()); - if constexpr (std::is_convertible_v) { - std::printf(" expected: %s\n actual: %s\n", std::string(expected).c_str(), std::string(actual).c_str()); - } else { - std::printf(" expected: %s\n actual: %s\n", std::to_string(expected).c_str(), - std::to_string(actual).c_str()); - } - failures++; - } -} - -std::string hex(const std::array& digest) { - std::string out; - for (uint8_t b : digest) { - char buf[3]; - std::snprintf(buf, sizeof buf, "%02x", b); - out += buf; - } - return out; -} - -// Reads every record of the file through the block-cursor API. -std::vector> all_records(const mdict::Reader& reader, bool text) { - const std::vector keys = reader.read_all_keys(); - std::vector> out; - size_t block_index = static_cast(-1); - std::vector block; - for (size_t i = 0; i < keys.size(); ++i) { - const uint64_t next = i + 1 < keys.size() ? keys[i + 1].record_offset : reader.record_space_size(); - const size_t wanted = reader.record_block_for(keys[i].record_offset); - if (wanted != block_index) { - block = reader.read_record_block(wanted); - block_index = wanted; - } - const std::string_view record = reader.record_in_block(block, block_index, keys[i].record_offset, next); - out.emplace_back(keys[i].key, text ? reader.record_text(record) : std::string(record)); - } - return out; -} - -void expect_text_fixture(const mdict::Reader& reader, const std::string& name) { - check_eq(reader.key_count(), 4, name + ": key count"); - const auto records = all_records(reader, true); - check_eq(records.size(), 4, name + ": record count"); - if (records.size() != 4) { - return; - } - check_eq(records[0].first, "alpha", name + ": key 0"); - check_eq(records[0].second, "first definition", name + ": record 0"); - check_eq(records[1].first, "beta", name + ": key 1"); - check_eq(records[1].second, "second\ndefinition with newline", name + ": record 1"); - check_eq(records[2].second, "third", name + ": record 2"); - check_eq(records[3].first, "日本語", name + ": unicode key"); - check_eq(records[3].second, "Japanese text \"quoted\"", name + ": record 3"); -} - -void test_ripemd128() { - check_eq(hex(mdict::ripemd128(nullptr, 0)), "cdf26213a150dc3ecb610f18f6b38b46", "ripemd128 empty"); - const std::string abc = "abc"; - check_eq(hex(mdict::ripemd128(reinterpret_cast(abc.data()), abc.size())), - "c14a12199c66e4ba84636b0f69144c77", "ripemd128 abc"); - const std::string md = "message digest"; - check_eq(hex(mdict::ripemd128(reinterpret_cast(md.data()), md.size())), - "9e327b3d6e523062afc1132d7df9d1b8", "ripemd128 message digest"); - const std::string eighty = "12345678901234567890123456789012345678901234567890123456789012345678901234567890"; - check_eq(hex(mdict::ripemd128(reinterpret_cast(eighty.data()), eighty.size())), - "3f45ef194732c2dbb2c4a2c769795fa3", "ripemd128 two blocks"); -} - -void test_v2_text() { - mdict::Reader reader; - reader.open(fixtures / "v2_utf8_zlib_text.mdx"); - const auto& h = reader.header(); - check(h.kind == mdict::Kind::Mdx, "v2 text: kind"); - check_eq(h.engine_version, "2.0", "v2 text: engine version"); - check(h.encoding == mdict::Encoding::Utf8, "v2 text: encoding"); - check_eq(h.format, "Text", "v2 text: format"); - check_eq(h.title, "Text Fixture", "v2 text: title"); - check_eq(h.description, "A text fixture & entities", "v2 text: description unescaped"); - check_eq(h.encrypted, 0, "v2 text: encrypted"); - check(h.compact, "v2 text: compact"); - check_eq(reader.key_blocks().size(), 2, "v2 text: key block count (3 per block)"); - check_eq(reader.key_blocks()[0].first_key, "alpha", "v2 text: first key of block 0"); - check_eq(reader.key_blocks()[0].last_key, "gamma", "v2 text: last key of block 0"); - expect_text_fixture(reader, "v2 text"); -} - -void test_v2_lzo_html() { - mdict::Reader reader; - reader.open(fixtures / "v2_utf8_lzo_html.mdx"); - check_eq(reader.header().format, "Html", "v2 html: format"); - check_eq(reader.header().stylesheet, "1\n\n\n2\n\n\n", "v2 html: stylesheet"); - check_eq(reader.key_count(), 9, "v2 html: key count"); - check_eq(reader.key_blocks().size(), 3, "v2 html: key blocks"); - check(reader.record_blocks().size() >= 2, "v2 html: several record blocks"); - const auto records = all_records(reader, true); - check_eq(records.size(), 9, "v2 html: record count"); - if (records.size() != 9) { - return; - } - check_eq(records[1].first, "alias", "v2 html: alias key"); - check_eq(records[1].second, "@@@LINK=@@@LINK_target\r\n", "v2 html: link record"); - check_eq(records[2].first, "dup", "v2 html: duplicate key 1"); - check_eq(records[3].first, "dup", "v2 html: duplicate key 2"); - check_eq(records[3].second, "

    second dup

    ", "v2 html: duplicate record kept apart"); - check_eq(records[6].second, - "
    かん
    ", - "v2 html: LZO record"); - check_eq(records[7].first, "食べる", "v2 html: unicode key"); - check_eq(records[7].second, "`1`to eat`2` (ichidan)", "v2 html: backtick styles untouched"); - - // Reading one key block at a time gives the same keys as read_all_keys. - std::vector block1; - reader.read_key_block(1, block1); - check_eq(block1.size(), 3, "v2 html: block 1 has 3 keys"); - if (block1.size() == 3) { - check_eq(block1[0].key, "dup", "v2 html: block 1 starts at second dup"); - } -} - -void test_v2_utf16_encrypted() { - mdict::Reader reader; - reader.open(fixtures / "v2_utf16_encrypted2.mdx"); - check(reader.header().encoding == mdict::Encoding::Utf16le, "utf16: encoding"); - check_eq(reader.header().encrypted, 2, "utf16: encrypted flag"); - check_eq(reader.key_blocks()[1].first_key, "日本語", "utf16: index key decoded"); - expect_text_fixture(reader, "utf16 encrypted"); -} - -void test_v1() { - mdict::Reader reader; - reader.open(fixtures / "v1_utf8_stored.mdx"); - check_eq(reader.header().engine_version, "1.2", "v1: engine version"); - check_eq(reader.header().title, "V1 Fixture", "v1: title"); - expect_text_fixture(reader, "v1"); -} - -void test_mdd() { - mdict::Reader reader; - reader.open(fixtures / "v2_utf8_lzo_html.mdd"); - check(reader.header().kind == mdict::Kind::Mdd, "mdd: kind"); - check(reader.header().encoding == mdict::Encoding::Utf16le, "mdd: keys are UTF-16"); - const auto records = all_records(reader, false); - check_eq(records.size(), 5, "mdd: record count"); - if (records.size() != 4) { - return; - } - check_eq(records[0].first, "\\a.spx", "mdd: key 0"); - check_eq(records[0].second, "not really speex", "mdd: raw record"); - check_eq(records[1].first, "\\img\\pic.png", "mdd: key 1"); - check_eq(records[1].second.size(), 69, "mdd: png size"); - check(records[1].second.starts_with("\x89PNG"), "mdd: png bytes"); - check_eq(records[3].first, "\\..\\evil.png", "mdd: traversal key is delivered verbatim"); -} - -void expect_open_fails(const char* file, const std::string& needle) { - mdict::Reader reader; - try { - reader.open(fixtures / file); - // Opening only reads the indexes; a bad record block shows up when read. - for (size_t i = 0; i < reader.record_blocks().size(); ++i) { - reader.read_record_block(i); - } - check(false, std::string(file) + ": expected an error containing \"" + needle + "\""); - } catch (const mdict::Error& e) { - const std::string message = e.what(); - check(message.find(needle) != std::string::npos, - std::string(file) + ": error \"" + message + "\" does not mention \"" + needle + "\""); - } -} - -void test_malformed() { - expect_open_fails("bad_truncated.mdx", "truncated file"); - expect_open_fails("bad_adler.mdx", "Adler-32"); - expect_open_fails("bad_huge_block.mdx", "impossible size"); - expect_open_fails("bad_encrypted1.mdx", "registration-protected"); - expect_open_fails("bad_gbk.mdx", "unsupported MDX encoding: GBK"); - expect_open_fails("bad_v3.mdx", "unsupported MDX engine version 3.0"); - expect_open_fails("does_not_exist.mdx", "could not open"); -} - -void test_sniff() { - auto head = [](const std::filesystem::path& path) { - std::ifstream in(path, std::ios::binary); - std::vector bytes(64); - in.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - bytes.resize(static_cast(in.gcount())); - return bytes; - }; - auto mdx = head(fixtures / "v2_utf8_zlib_text.mdx"); - check(mdict::looks_like_mdict(mdx.data(), mdx.size()), "sniff: mdx"); - auto mdd = head(fixtures / "v2_utf8_lzo_html.mdd"); - check(mdict::looks_like_mdict(mdd.data(), mdd.size()), "sniff: mdd"); - auto zip = head(fixtures / ".." / "yomitan" / "small_dict.zip"); - check(!mdict::looks_like_mdict(zip.data(), zip.size()), "sniff: zip is not mdict"); - const uint8_t junk[] = {0, 0, 0, 0}; - check(!mdict::looks_like_mdict(junk, sizeof junk), "sniff: zeros"); -} - -void test_utf16() { - const uint8_t text[] = {0x3d, 0xd8, 0x00, 0xde, 0x41, 0x00, 0x00, 0xdc}; // U+1F600, 'A', lone low surrogate - check_eq(mdict::utf16le_to_utf8(text, sizeof text), "\xf0\x9f\x98\x80" "A" "\xef\xbf\xbd", - "utf16: surrogate pair, ascii, lone surrogate"); -} - -// Corrupt the fixtures at random positions; every outcome must be either a -// clean read or an mdict::Error, never a crash or another exception type. -void test_mutations() { - std::mt19937 rng(20260921); - const std::filesystem::path scratch = - std::filesystem::temp_directory_path() / ("hoshidicts-mdict-mutation-" + std::to_string(rng())); - std::filesystem::create_directories(scratch); - const char* sources[] = {"v2_utf8_zlib_text.mdx", "v2_utf8_lzo_html.mdx", "v2_utf16_encrypted2.mdx", - "v1_utf8_stored.mdx", "v2_utf8_lzo_html.mdd"}; - size_t clean = 0; - size_t rejected = 0; - for (const char* source : sources) { - std::ifstream in(fixtures / source, std::ios::binary); - std::vector original((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - for (int round = 0; round < 400; ++round) { - std::vector mutated = original; - const int flips = 1 + static_cast(rng() % 4); - for (int f = 0; f < flips; ++f) { - mutated[rng() % mutated.size()] = static_cast(rng()); - } - if (round % 50 == 49) { - mutated.resize(rng() % mutated.size()); - } - const auto path = scratch / "mutant.mdx"; - { - std::ofstream out(path, std::ios::binary | std::ios::trunc); - out.write(mutated.data(), static_cast(mutated.size())); - } - try { - mdict::Reader reader; - reader.open(path); - all_records(reader, reader.header().kind == mdict::Kind::Mdx); - clean++; - } catch (const mdict::Error&) { - rejected++; - } catch (const std::exception& e) { - check(false, std::string("mutation of ") + source + " escaped as " + e.what()); - } - } - } - std::filesystem::remove_all(scratch); - std::printf("mutations: %zu read cleanly, %zu rejected\n", clean, rejected); -} -} - -int main(int argc, char** argv) { - if (argc < 2) { - std::printf("usage: %s \n", argv[0]); - return 2; - } - fixtures = argv[1]; - test_ripemd128(); - test_utf16(); - test_v2_text(); - test_v2_lzo_html(); - test_v2_utf16_encrypted(); - test_v1(); - test_mdd(); - test_malformed(); - test_sniff(); - test_mutations(); - if (failures == 0) { - std::printf("ok\n"); - } - return failures == 0 ? 0 : 1; -} diff --git a/vendor/hachidori/third_party/hoshidicts/tests/mdict_test.cpp b/vendor/hachidori/third_party/hoshidicts/tests/mdict_test.cpp deleted file mode 100644 index 3cb79b26..00000000 --- a/vendor/hachidori/third_party/hoshidicts/tests/mdict_test.cpp +++ /dev/null @@ -1,237 +0,0 @@ -// End-to-end MDX import: dictionary_importer::import on the fixtures in -// tests/fixtures/mdict, then DictionaryQuery lookups against the result. -// -// mdict_test -#include -#include -#include -#include -#include -#include -#include -#include - -#include "hoshidicts/importer.hpp" -#include "hoshidicts/query.hpp" - -namespace { -int failures = 0; -std::filesystem::path fixtures; - -void check(bool ok, const std::string& what) { - if (!ok) { - std::printf("FAIL %s\n", what.c_str()); - failures++; - } -} - -void check_contains(const std::string& haystack, const std::string& needle, const std::string& what) { - if (haystack.find(needle) == std::string::npos) { - std::printf("FAIL %s\n \"%s\" not in: %s\n", what.c_str(), needle.c_str(), haystack.c_str()); - failures++; - } -} - -std::filesystem::path fresh_dir(const char* tag) { - std::random_device rd; - const auto dir = std::filesystem::temp_directory_path() / ("hoshidicts-mdict-" + std::string(tag) + "-" + - std::to_string(rd())); - std::filesystem::create_directories(dir); - return dir; -} - -// Copies the fixture (and its sibling MDD when present) into `dir` so MDD -// discovery runs on real neighbours and imports it there. -ImportResult import_fixture(const std::filesystem::path& dir, const char* name, bool low_ram) { - std::filesystem::copy_file(fixtures / name, dir / name, std::filesystem::copy_options::overwrite_existing); - std::filesystem::path mdd = fixtures / name; - mdd.replace_extension(".mdd"); - if (std::filesystem::exists(mdd)) { - std::filesystem::copy_file(mdd, dir / mdd.filename(), std::filesystem::copy_options::overwrite_existing); - } - return dictionary_importer::import((dir / name).string(), dir.string(), low_ram); -} - -std::string glossary_of(const std::vector& results, size_t term = 0, size_t glossary = 0) { - if (results.size() <= term || results[term].glossaries.size() <= glossary) { - return {}; - } - return results[term].glossaries[glossary].glossary; -} - -std::string read_file(const std::filesystem::path& path) { - std::ifstream in(path, std::ios::binary); - return std::string(std::istreambuf_iterator(in), {}); -} - -void test_html_import() { - const auto dir = fresh_dir("html"); - const ImportResult result = import_fixture(dir, "v2_utf8_lzo_html.mdx", false); - check(result.success, "html: import succeeded: " + result.error); - if (!result.success) { - return; - } - check(result.title == "HTML Fixture", "html: title from header, got " + result.title); - // 7 non-redirect entries + 1 resolved alias; the alias to a missing target is dropped. - check(result.summary.counts.terms.total == 8, "html: 8 term rows, got " + - std::to_string(result.summary.counts.terms.total)); - check(result.summary.sequenced, "html: sequenced"); - check(result.summary.revision == "mdx import", "html: revision"); - check(result.summary.description == "HTML fixture with links, duplicates and a stylesheet", "html: description"); - // style.css, utf16.css, a.spx (sound://), img/pic.png; ../evil.png is rejected. - check(result.summary.counts.media.total == 4, - "html: 4 media files, got " + std::to_string(result.summary.counts.media.total)); - - const std::string dict = (dir / result.title).string(); - DictionaryQuery query; - check(query.add_term_dict(dict), "html: dictionary loads"); - - check_contains(glossary_of(query.query("entry")), R"("href":"?query=alias")", "html: entry:// link"); - check_contains(glossary_of(query.query("entry")), R"({"tag":"a","href":"#","content":["snd"]})", - "html: sound:// is # with audio off"); - check_contains(glossary_of(query.query("alias")), "target of a link", "html: alias resolves to its target"); - check(query.query("missing-alias").empty(), "html: alias to a missing target is dropped"); - check(query.query("@@@LINK_target").size() == 1, "html: target itself is still a headword"); - - const auto dup = query.query("dup"); - check(dup.size() == 1 && dup[0].glossaries.size() == 2, "html: duplicate headwords keep both entries"); - check_contains(glossary_of(dup, 0, 0), "first dup", "html: first duplicate"); - check_contains(glossary_of(dup, 0, 1), "second dup", "html: second duplicate"); - - check_contains(glossary_of(query.query("食べる")), - R"({"tag":"span","style":{"fontWeight":"bold"},"content":["to eat"]})", - "html: StyleSheet backticks expanded and converted"); - check_contains(glossary_of(query.query("ruby")), R"({"tag":"img","path":"mdict-media/img/pic.png"})", - "html: image path under mdict-media/"); - check_contains(glossary_of(query.query("見出し")), R"("style":{"color":"red","fontSize":"12px"})", - "html: inline style on a Unicode headword"); - - const auto styles = query.get_styles(); - check(styles.size() == 1, "html: one stylesheet"); - if (styles.size() == 1) { - check_contains(styles[0].styles, "/* Source: style.css */\n.mdx-red { color: red; }", "html: MDD css"); - check_contains(styles[0].styles, "/* Source: utf16.css */\n.u16::before { content: \"\xe2\x86\x92\"; }", - "html: BOM-less UTF-16 css decoded"); - check_contains(styles[0].styles, "/* Source: entry/inline/1.css */\n.inline-x { color: blue; }", - "html: inline