From d9fdc7ef6d6b6608db513697a6de182d0a33cd64 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 22 Sep 2026 00:21:19 -0700 Subject: [PATCH] feat(dictionary): add Hachidori backend support - Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt --- .gitignore | 2 + README.md | 7 +- changes/hachidori-backend.md | 13 + changes/hachidori-setup-docs.md | 4 + changes/overlay-modal-first-open.md | 4 + config.example.jsonc | 7 + docs-site/anki-integration.md | 12 + docs-site/configuration.md | 6 + docs-site/public/config.example.jsonc | 7 + docs-site/shortcuts.md | 4 +- docs-site/usage.md | 29 +- docs/architecture/README.md | 6 + docs/workflow/verification.md | 1 + launcher/commands/playback-command.ts | 8 +- launcher/config.ts | 12 +- launcher/config/args-normalizer.ts | 4 + launcher/config/cli-parser-builder.ts | 2 + launcher/main.test.ts | 42 +- launcher/parse-args.test.ts | 9 + launcher/setup-gate.test.ts | 106 +- launcher/setup-gate.ts | 53 +- package.json | 10 +- scripts/build-hachidori.mjs | 36 + scripts/check-dictionary-backends.cjs | 110 + scripts/check-hachidori-parser.cjs | 248 + scripts/hachidori-anki-audio.test.ts | 137 + scripts/hachidori-host.test.ts | 132 + scripts/package-audit.cjs | 8 + scripts/smoke-package.cjs | 49 +- src/anki-integration.ts | 3 + .../anki-connect-proxy.test.ts | 64 + src/anki-integration/anki-connect-proxy.ts | 38 +- src/cli/args.test.ts | 19 + src/cli/args.ts | 12 +- src/cli/help.test.ts | 1 + src/cli/help.ts | 1 + src/config/definitions.ts | 2 + src/config/definitions/defaults-core.ts | 2 + src/config/definitions/options-core.ts | 7 + src/config/definitions/template-sections.ts | 8 + src/config/resolve/core-domains.ts | 11 + src/config/resolve/dictionary-backend.test.ts | 30 + src/config/settings/registry.ts | 10 +- src/core/services/app-lifecycle.test.ts | 1 + src/core/services/cli-command.test.ts | 16 + src/core/services/cli-command.ts | 9 + src/core/services/hachidori-extension.test.ts | 55 + src/core/services/hachidori-extension.ts | 59 + src/core/services/startup-bootstrap.test.ts | 1 + .../tokenizer/hachidori-anki-settings.test.ts | 167 + .../tokenizer/hachidori-anki-settings.ts | 120 + .../tokenizer/hachidori-parser-bridge.test.ts | 446 + .../tokenizer/hachidori-parser-bridge.ts | 262 + .../tokenizer/yomitan-parser-runtime.ts | 117 +- .../tokenizer/yomitan-scan-runtime-script.ts | 4 +- src/core/services/yomitan-extension-copy.ts | 5 +- src/core/services/yomitan-settings.ts | 14 +- src/main.ts | 169 +- src/main/cli-runtime.ts | 2 + src/main/dependencies.ts | 2 + src/main/overlay-runtime.test.ts | 56 +- src/main/overlay-runtime.ts | 5 + .../runtime/cli-command-context-deps.test.ts | 1 + src/main/runtime/cli-command-context-deps.ts | 2 + .../cli-command-context-factory.test.ts | 1 + .../cli-command-context-main-deps.test.ts | 1 + .../runtime/cli-command-context-main-deps.ts | 2 + src/main/runtime/cli-command-context.test.ts | 1 + src/main/runtime/cli-command-context.ts | 2 + .../composers/cli-startup-composer.test.ts | 1 + .../runtime/first-run-setup-service.test.ts | 118 + src/main/runtime/first-run-setup-service.ts | 103 +- .../runtime/first-run-setup-window.test.ts | 48 + src/main/runtime/first-run-setup-window.ts | 113 +- src/main/runtime/startup-mode-flags.test.ts | 7 + src/main/runtime/startup-mode-flags.ts | 4 +- src/main/runtime/stats-server-runtime.ts | 1 + src/main/runtime/tray-main-actions.test.ts | 4 + src/main/runtime/tray-main-actions.ts | 8 + src/main/runtime/tray-main-deps.test.ts | 4 + src/main/runtime/tray-main-deps.ts | 8 + .../runtime/tray-runtime-handlers.test.ts | 2 + src/main/runtime/tray-runtime.test.ts | 175 +- src/main/runtime/tray-runtime.ts | 14 +- .../runtime/yomitan-anki-server-sync.test.ts | 13 + src/main/runtime/yomitan-anki-server-sync.ts | 6 +- src/preload-clipboard.test.ts | 1 + src/preload.ts | 38 + src/renderer/modals/session-help-sections.ts | 4 +- .../modals/youtube-track-picker.test.ts | 2 +- src/renderer/modals/youtube-track-picker.ts | 8 - src/settings/settings-anki-controls.ts | 4 +- src/shared/dictionary-external-link.test.ts | 19 + src/shared/dictionary-external-link.ts | 10 + src/shared/hachidori-sharing.test.ts | 160 + src/shared/hachidori-sharing.ts | 92 + src/shared/setup-state.test.ts | 40 + src/shared/setup-state.ts | 44 +- src/stats-word-helper.ts | 69 +- src/types/config.ts | 4 + vendor/hachidori/LICENSE | 674 + vendor/hachidori/README.md | 53 + vendor/hachidori/SOURCE.json | 45 + vendor/hachidori/UPSTREAM-README.md | 134 + vendor/hachidori/docs/asset-rights.md | 111 + vendor/hachidori/docs/source-build.md | 120 + vendor/hachidori/extension/README.md | 186 + vendor/hachidori/extension/anki-addon.js | 13 + vendor/hachidori/extension/anki-audio.js | 113 + .../hachidori/extension/anki-client-media.js | 144 + vendor/hachidori/extension/anki-content.js | 677 + vendor/hachidori/extension/anki-digest.js | 5 + vendor/hachidori/extension/anki-duplicates.js | 188 + vendor/hachidori/extension/anki-enrichment.js | 54 + vendor/hachidori/extension/anki-glossary.js | 130 + .../hachidori/extension/anki-index-cache.js | 343 + .../hachidori/extension/anki-index-worker.js | 14 + vendor/hachidori/extension/anki-index.js | 265 + vendor/hachidori/extension/anki-media.js | 132 + vendor/hachidori/extension/anki-mining.js | 475 + vendor/hachidori/extension/anki-offscreen.js | 89 + vendor/hachidori/extension/anki-pitch.js | 72 + vendor/hachidori/extension/anki-resources.js | 41 + vendor/hachidori/extension/anki-settings.js | 1023 + vendor/hachidori/extension/anki-setup.js | 142 + vendor/hachidori/extension/anki-templates.js | 285 + vendor/hachidori/extension/anki-values.js | 296 + vendor/hachidori/extension/anki-worker.js | 528 + vendor/hachidori/extension/anki.js | 285 + vendor/hachidori/extension/api-host.js | 359 + .../hachidori/extension/assets/ATTRIBUTION.md | 19 + .../assets/preview-background-2.webp | Bin 0 -> 153232 bytes .../assets/preview-background-3.webp | Bin 0 -> 145796 bytes .../assets/preview-background-4.webp | Bin 0 -> 191904 bytes .../assets/preview-background-5.webp | Bin 0 -> 178482 bytes .../assets/preview-background-6.webp | Bin 0 -> 154272 bytes .../extension/assets/preview-background.webp | Bin 0 -> 147244 bytes vendor/hachidori/extension/audio-cache.js | 36 + vendor/hachidori/extension/audio-content.js | 327 + vendor/hachidori/extension/audio-offscreen.js | 114 + vendor/hachidori/extension/audio-player.js | 131 + .../hachidori/extension/audio-repository.js | 71 + vendor/hachidori/extension/audio-settings.js | 177 + vendor/hachidori/extension/audio-sources.js | 40 + vendor/hachidori/extension/avif-sequence.js | 135 + vendor/hachidori/extension/background.js | 3358 +++ vendor/hachidori/extension/backup-archive.js | 116 + .../hachidori/extension/backup-automatic.js | 130 + .../hachidori/extension/backup-downloads.js | 54 + vendor/hachidori/extension/backup-settings.js | 277 + vendor/hachidori/extension/backup-state.js | 115 + vendor/hachidori/extension/base64.js | 39 + vendor/hachidori/extension/blob-download.js | 15 + vendor/hachidori/extension/browser-api.js | 44 + .../extension/capture-audio-worklet.js | 53 + vendor/hachidori/extension/capture-buffer.js | 225 + vendor/hachidori/extension/capture-content.js | 721 + .../extension/capture-encoder-client.js | 63 + .../extension/capture-encoder-worker.js | 39 + .../extension/capture-frame-client.js | 48 + .../extension/capture-frame-worker.js | 43 + vendor/hachidori/extension/capture-host.js | 683 + vendor/hachidori/extension/capture-session.js | 559 + vendor/hachidori/extension/capture-speech.js | 80 + .../hachidori/extension/capture-timeline.js | 192 + vendor/hachidori/extension/capture.css | 51 + vendor/hachidori/extension/capture.html | 75 + vendor/hachidori/extension/capture.js | 145 + .../hachidori/extension/chrome-offscreen.js | 44 + vendor/hachidori/extension/content.css | 26 + vendor/hachidori/extension/content.js | 4063 +++ .../extension/custom-button-settings.js | 232 + .../hachidori/extension/custom-dictionary.js | 365 + .../hachidori/extension/custom-javascript.js | 32 + vendor/hachidori/extension/design-preview.css | 6 + .../hachidori/extension/design-preview.html | 25 + vendor/hachidori/extension/design-preview.js | 282 + .../extension/dictionary-group-state.js | 55 + .../hachidori/extension/dictionary-groups.js | 201 + .../extension/dictionary-import-archive.js | 84 + .../hachidori/extension/dictionary-import.js | 101 + .../extension/dictionary-name-drafts.js | 144 + .../extension/dictionary-progress.css | 77 + .../extension/dictionary-progress.js | 115 + vendor/hachidori/extension/engine-service.js | 3249 +++ .../extension/engine-worker-idbfs.js | 13 + .../extension/engine-worker-runtime.js | 109 + vendor/hachidori/extension/engine-worker.js | 11 + .../extension/experimental-settings.js | 48 + .../hachidori/extension/external-link-host.js | 65 + vendor/hachidori/extension/external-links.js | 31 + .../extension/firefox-background.html | 11 + .../hachidori/extension/firefox-background.js | 13 + vendor/hachidori/extension/firefox-host.js | 146 + vendor/hachidori/extension/icons.css | 54 + .../hachidori/extension/icons/fluent/LICENSE | 21 + .../hachidori/extension/icons/fluent/add.svg | 3 + .../icons/fluent/arrow-clockwise.svg | 3 + .../extension/icons/fluent/arrow-down.svg | 3 + .../extension/icons/fluent/arrow-right.svg | 3 + .../extension/icons/fluent/arrow-sync.svg | 3 + .../extension/icons/fluent/arrow-up.svg | 3 + .../extension/icons/fluent/book-search.svg | 3 + .../extension/icons/fluent/checkmark.svg | 3 + .../extension/icons/fluent/desktop.svg | 3 + .../extension/icons/fluent/dismiss.svg | 3 + .../extension/icons/fluent/document-add.svg | 3 + .../extension/icons/fluent/document-edit.svg | 3 + .../hachidori/extension/icons/fluent/edit.svg | 3 + .../extension/icons/fluent/error-circle.svg | 3 + .../hachidori/extension/icons/fluent/key.svg | 3 + .../icons/fluent/more-horizontal.svg | 3 + .../hachidori/extension/icons/fluent/open.svg | 3 + .../extension/icons/fluent/reorder.svg | 3 + .../extension/icons/fluent/settings.svg | 3 + .../extension/icons/fluent/sources.json | 28 + .../extension/icons/fluent/speaker-2.svg | 3 + .../hachidori/extension/icons/fluent/star.svg | 3 + .../extension/icons/fluent/subtract.svg | 3 + .../extension/icons/hachidori-128.png | Bin 0 -> 8505 bytes .../extension/icons/hachidori-16.png | Bin 0 -> 680 bytes .../extension/icons/hachidori-32.png | Bin 0 -> 1666 bytes .../extension/icons/hachidori-48.png | Bin 0 -> 2691 bytes vendor/hachidori/extension/json-value.js | 24 + .../hachidori/extension/keybind-settings.js | 232 + .../hachidori/extension/local-audio-setup.js | 104 + .../hachidori/extension/local-audio-source.js | 11 + .../hachidori/extension/local-file-access.js | 155 + .../extension/lookup-stats-identity.js | 22 + vendor/hachidori/extension/lookup-stats.js | 59 + .../extension/managed-dictionary-source.js | 215 + .../hachidori/extension/manifest.firefox.json | 120 + vendor/hachidori/extension/manifest.json | 130 + vendor/hachidori/extension/media-limits.js | 10 + vendor/hachidori/extension/offscreen.html | 17 + vendor/hachidori/extension/offscreen.js | 402 + .../extension/opfs-capability-worker.js | 62 + vendor/hachidori/extension/overlay-mode.js | 31 + vendor/hachidori/extension/reader-options.js | 818 + .../extension/recommended-dictionaries.js | 124 + .../extension/recommended-install-client.js | 74 + .../hachidori/extension/render/ATTRIBUTION.md | 46 + vendor/hachidori/extension/render/glossary.js | 1477 + vendor/hachidori/extension/render/popup.js | 4232 +++ vendor/hachidori/extension/render/reader.css | 2841 ++ vendor/hachidori/extension/response-limits.js | 75 + vendor/hachidori/extension/sample-meal.svg | 1 + vendor/hachidori/extension/settings-dom.js | 43 + vendor/hachidori/extension/settings-search.js | 105 + vendor/hachidori/extension/settings.css | 2285 ++ vendor/hachidori/extension/settings.html | 1456 + vendor/hachidori/extension/settings.js | 3630 +++ vendor/hachidori/extension/setup-installer.js | 217 + vendor/hachidori/extension/setup-state.js | 251 + vendor/hachidori/extension/sharing-client.js | 288 + vendor/hachidori/extension/sharing-host.js | 244 + .../hachidori/extension/sharing-protocol.js | 291 + .../hachidori/extension/sharing-settings.js | 302 + vendor/hachidori/extension/speech.js | 48 + .../hachidori/extension/startup-practice.js | 158 + vendor/hachidori/extension/startup.css | 336 + vendor/hachidori/extension/startup.html | 63 + vendor/hachidori/extension/startup.js | 1080 + vendor/hachidori/extension/subminer-host.js | 71 + .../extension/texthooker-protocol.js | 49 + vendor/hachidori/extension/toolbar.css | 40 + vendor/hachidori/extension/toolbar.html | 34 + vendor/hachidori/extension/toolbar.js | 143 + .../extension/vendor/avif-THIRD_PARTY_NOTICES | 81 + .../extension/vendor/avif-encoder.mjs | 2 + .../extension/vendor/avif-encoder.wasm | Bin 0 -> 1983140 bytes .../vendor/hoshidicts-licenses/README.md | 13 + .../hoshidicts-licenses/compiler-rt-LICENSE | 311 + .../hoshidicts-licenses/emscripten-LICENSE | 102 + .../vendor/hoshidicts-licenses/glaze-LICENSE | 21 + .../hoshidicts-licenses/gumbo-parser-LICENSE | 179 + .../gumbo-parser-LICENSE-hashmap.c | 24 + .../hoshidicts-licenses/hoshidicts-LICENSE | 674 + .../kanji-processor-LICENSE | 21 + .../vendor/hoshidicts-licenses/libcxx-LICENSE | 311 + .../hoshidicts-licenses/libcxxabi-LICENSE | 311 + .../hoshidicts-licenses/libdeflate-COPYING | 22 + .../hoshidicts-licenses/libunwind-LICENSE | 311 + .../vendor/hoshidicts-licenses/lzokay-LICENSE | 22 + .../vendor/hoshidicts-licenses/musl-COPYRIGHT | 193 + .../reader-and-language-NOTICE | 33 + .../unordered_dense-LICENSE | 21 + .../hoshidicts-licenses/utf8proc-LICENSE.md | 93 + .../vendor/hoshidicts-licenses/utfcpp-LICENSE | 23 + .../vendor/hoshidicts-licenses/xxHash-LICENSE | 26 + .../vendor/hoshidicts-licenses/zstd-COPYING | 339 + .../vendor/hoshidicts-licenses/zstd-LICENSE | 30 + .../vendor/hoshidicts-threaded-idbfs.mjs | 2 + .../vendor/hoshidicts-threaded-idbfs.wasm | Bin 0 -> 2592397 bytes .../extension/vendor/hoshidicts-threaded.mjs | 2 + .../extension/vendor/hoshidicts-threaded.wasm | Bin 0 -> 2682421 bytes .../hachidori/extension/vendor/hoshidicts.mjs | 2 + .../extension/vendor/hoshidicts.wasm | Bin 0 -> 2524167 bytes vendor/hachidori/extension/vendor/zip-LICENSE | 28 + vendor/hachidori/extension/vendor/zip.js | 1 + vendor/hachidori/extension/visual-novel.css | 69 + vendor/hachidori/extension/visual-novel.js | 40 + .../scripts/anki-note-type-compatibility.mjs | 70 + .../scripts/anki-note-type-upstream.py | 236 + .../scripts/capture-store-assets.mjs | 160 + vendor/hachidori/scripts/check-release.mjs | 128 + vendor/hachidori/scripts/chrome-web-store.mjs | 256 + vendor/hachidori/scripts/firefox-package.json | 21 + vendor/hachidori/scripts/lint-firefox.mjs | 47 + vendor/hachidori/scripts/package-store.py | 200 + vendor/hachidori/scripts/prepare-firefox.mjs | 48 + vendor/hachidori/scripts/store-sources.json | 29 + .../hachidori/scripts/vendor-fluent-icons.py | 44 + .../scripts/verify-firefox-package.mjs | 84 + .../third_party/hoshidicts/.clang-format | 3 + .../third_party/hoshidicts/.clang-tidy | 32 + .../third_party/hoshidicts/.gitignore | 56 + .../third_party/hoshidicts/.gitmodules | 24 + .../third_party/hoshidicts/CMakeLists.txt | 199 + .../hachidori/third_party/hoshidicts/LICENSE | 674 + .../third_party/hoshidicts/Package.swift | 58 + .../third_party/hoshidicts/README.md | 116 + .../hoshidicts/benchmark/import.cpp | 59 + .../hoshidicts/benchmark/lookup.cpp | 103 + .../third_party/hoshidicts/cli/main.c | 232 + .../third_party/hoshidicts/cli/main.cpp | 222 + .../hoshidicts/external/gumbo-parser/LICENSE | 179 + .../external/gumbo-parser/LICENSE-hashmap.c | 24 + .../external/gumbo-parser/README.md | 17 + .../external/gumbo-parser/UPSTREAM-README.md | 41 + .../external/gumbo-parser/src/ascii.c | 75 + .../external/gumbo-parser/src/ascii.h | 115 + .../external/gumbo-parser/src/attribute.c | 42 + .../external/gumbo-parser/src/attribute.h | 17 + .../external/gumbo-parser/src/char_ref.c | 22225 ++++++++++++++++ .../external/gumbo-parser/src/char_ref.h | 29 + .../external/gumbo-parser/src/error.c | 658 + .../external/gumbo-parser/src/error.h | 152 + .../external/gumbo-parser/src/foreign_attrs.c | 103 + .../external/gumbo-parser/src/hashmap.c | 1154 + .../external/gumbo-parser/src/hashmap.h | 62 + .../gumbo-parser/src/insertion_mode.h | 33 + .../external/gumbo-parser/src/macros.h | 91 + .../gumbo-parser/src/nokogiri_gumbo.h | 938 + .../external/gumbo-parser/src/parser.c | 4924 ++++ .../external/gumbo-parser/src/parser.h | 41 + .../external/gumbo-parser/src/replacement.h | 33 + .../external/gumbo-parser/src/string_buffer.c | 103 + .../external/gumbo-parser/src/string_buffer.h | 68 + .../external/gumbo-parser/src/string_piece.c | 48 + .../external/gumbo-parser/src/string_set.c | 41 + .../external/gumbo-parser/src/string_set.h | 21 + .../external/gumbo-parser/src/svg_attrs.c | 174 + .../external/gumbo-parser/src/svg_tags.c | 137 + .../external/gumbo-parser/src/tag.c | 223 + .../external/gumbo-parser/src/tag_lookup.c | 382 + .../external/gumbo-parser/src/tag_lookup.h | 13 + .../external/gumbo-parser/src/token_buffer.c | 79 + .../external/gumbo-parser/src/token_buffer.h | 71 + .../external/gumbo-parser/src/token_type.h | 17 + .../external/gumbo-parser/src/tokenizer.c | 3494 +++ .../external/gumbo-parser/src/tokenizer.h | 112 + .../gumbo-parser/src/tokenizer_states.h | 339 + .../external/gumbo-parser/src/utf8.c | 245 + .../external/gumbo-parser/src/utf8.h | 164 + .../external/gumbo-parser/src/util.c | 66 + .../external/gumbo-parser/src/util.h | 34 + .../external/gumbo-parser/src/vector.c | 111 + .../external/gumbo-parser/src/vector.h | 45 + .../hoshidicts/external/lzokay/LICENSE | 22 + .../hoshidicts/external/lzokay/README.md | 8 + .../hoshidicts/external/lzokay/lzokay.cpp | 647 + .../hoshidicts/external/lzokay/lzokay.hpp | 79 + .../hoshidicts/include/hoshidicts.h | 6 + .../include/hoshidicts/deinflector.hpp | 80 + .../include/hoshidicts/importer.hpp | 56 + .../hoshidicts/include/hoshidicts/lookup.hpp | 42 + .../hoshidicts/include/hoshidicts/query.hpp | 174 + .../hoshidicts/include/hoshidicts_c.h | 196 + .../hoshidicts/include/module.modulemap | 5 + .../hoshidicts/src/deinflector.cpp | 1361 + .../third_party/hoshidicts/src/hash/bloom.cpp | 91 + .../third_party/hoshidicts/src/hash/bloom.hpp | 37 + .../third_party/hoshidicts/src/hash/hash.cpp | 75 + .../third_party/hoshidicts/src/hash/hash.hpp | 33 + .../hoshidicts/src/hoshidicts_c.cpp | 512 + .../third_party/hoshidicts/src/importer.cpp | 1180 + .../hoshidicts/src/json/json_skip.cpp | 165 + .../hoshidicts/src/json/json_skip.hpp | 48 + .../hoshidicts/src/json/yomitan_parser.cpp | 244 + .../hoshidicts/src/json/yomitan_parser.hpp | 91 + .../third_party/hoshidicts/src/lookup.cpp | 323 + .../src/mdict/html_to_structured.cpp | 1068 + .../src/mdict/html_to_structured.hpp | 76 + .../hoshidicts/src/mdict/mdict_reader.cpp | 632 + .../hoshidicts/src/mdict/mdict_reader.hpp | 150 + .../hoshidicts/src/mdict/mdict_source.cpp | 577 + .../hoshidicts/src/mdict/mdict_source.hpp | 112 + .../hoshidicts/src/mdict/ripemd128.cpp | 100 + .../hoshidicts/src/mdict/ripemd128.hpp | 13 + .../hoshidicts/src/memory/memory.cpp | 164 + .../hoshidicts/src/memory/memory.hpp | 22 + .../third_party/hoshidicts/src/path_utils.hpp | 16 + .../third_party/hoshidicts/src/query.cpp | 752 + .../hoshidicts/src/query_internal.hpp | 34 + .../third_party/hoshidicts/src/scan_index.hpp | 80 + .../src/source/dictionary_source.hpp | 50 + .../hoshidicts/src/source/zip_source.cpp | 34 + .../hoshidicts/src/source/zip_source.hpp | 28 + .../src/text_processor/kanji_variants.cpp | 358 + .../src/text_processor/text_processor.cpp | 462 + .../src/text_processor/text_processor.hpp | 14 + .../third_party/hoshidicts/src/zip/zip.cpp | 167 + .../third_party/hoshidicts/src/zip/zip.hpp | 38 + .../src/zstd/zstd_compress_sequences.c | 491 + .../hoshidicts/tests/deps_smoke_test.cpp | 92 + .../tests/fixtures/mdict/bad_adler.mdx | Bin 0 -> 939 bytes .../tests/fixtures/mdict/bad_encrypted1.mdx | Bin 0 -> 939 bytes .../tests/fixtures/mdict/bad_gbk.mdx | Bin 0 -> 935 bytes .../tests/fixtures/mdict/bad_huge_block.mdx | Bin 0 -> 939 bytes .../tests/fixtures/mdict/bad_truncated.mdx | Bin 0 -> 898 bytes .../tests/fixtures/mdict/bad_v3.mdx | Bin 0 -> 939 bytes .../tests/fixtures/mdict/gen_fixtures.py | 338 + .../tests/fixtures/mdict/v1_utf8_stored.mdx | Bin 0 -> 878 bytes .../fixtures/mdict/v2_utf16_encrypted2.mdx | Bin 0 -> 980 bytes .../tests/fixtures/mdict/v2_utf8_lzo_html.mdd | Bin 0 -> 1149 bytes .../tests/fixtures/mdict/v2_utf8_lzo_html.mdx | Bin 0 -> 1724 bytes .../fixtures/mdict/v2_utf8_zlib_text.mdx | Bin 0 -> 1053 bytes .../tests/fixtures/yomitan/gen_fixture.py | 128 + .../tests/fixtures/yomitan/golden.sha256 | 11 + .../tests/fixtures/yomitan/small_dict.zip | Bin 0 -> 3164 bytes .../tests/html_to_structured_test.cpp | 229 + .../tests/import_equivalence_test.cpp | 237 + .../hoshidicts/tests/json_skip_test.cpp | 83 + .../hoshidicts/tests/long_key_scan_test.cpp | 243 + .../hoshidicts/tests/mdict_reader_test.cpp | 300 + .../hoshidicts/tests/mdict_test.cpp | 237 + .../hoshidicts/tests/score_roundtrip_test.cpp | 213 + vendor/hachidori/wasm/CMakeLists.txt | 102 + vendor/hachidori/wasm/avif/CMakeLists.txt | 47 + .../hachidori/wasm/avif/THIRD_PARTY_NOTICES | 81 + vendor/hachidori/wasm/avif/build.sh | 21 + vendor/hachidori/wasm/avif/encoder.c | 149 + vendor/hachidori/wasm/bindings.cpp | 1231 + vendor/hachidori/wasm/build.sh | 52 + vendor/hachidori/wasm/env.sh | 25 + 446 files changed, 109060 insertions(+), 244 deletions(-) create mode 100644 changes/hachidori-backend.md create mode 100644 changes/hachidori-setup-docs.md create mode 100644 changes/overlay-modal-first-open.md create mode 100644 scripts/build-hachidori.mjs create mode 100644 scripts/check-dictionary-backends.cjs create mode 100644 scripts/check-hachidori-parser.cjs create mode 100644 scripts/hachidori-anki-audio.test.ts create mode 100644 scripts/hachidori-host.test.ts create mode 100644 src/config/resolve/dictionary-backend.test.ts create mode 100644 src/core/services/hachidori-extension.test.ts create mode 100644 src/core/services/hachidori-extension.ts create mode 100644 src/core/services/tokenizer/hachidori-anki-settings.test.ts create mode 100644 src/core/services/tokenizer/hachidori-anki-settings.ts create mode 100644 src/core/services/tokenizer/hachidori-parser-bridge.test.ts create mode 100644 src/core/services/tokenizer/hachidori-parser-bridge.ts create mode 100644 src/shared/dictionary-external-link.test.ts create mode 100644 src/shared/dictionary-external-link.ts create mode 100644 src/shared/hachidori-sharing.test.ts create mode 100644 src/shared/hachidori-sharing.ts create mode 100644 vendor/hachidori/LICENSE create mode 100644 vendor/hachidori/README.md create mode 100644 vendor/hachidori/SOURCE.json create mode 100644 vendor/hachidori/UPSTREAM-README.md create mode 100644 vendor/hachidori/docs/asset-rights.md create mode 100644 vendor/hachidori/docs/source-build.md create mode 100644 vendor/hachidori/extension/README.md create mode 100644 vendor/hachidori/extension/anki-addon.js create mode 100644 vendor/hachidori/extension/anki-audio.js create mode 100644 vendor/hachidori/extension/anki-client-media.js create mode 100644 vendor/hachidori/extension/anki-content.js create mode 100644 vendor/hachidori/extension/anki-digest.js create mode 100644 vendor/hachidori/extension/anki-duplicates.js create mode 100644 vendor/hachidori/extension/anki-enrichment.js create mode 100644 vendor/hachidori/extension/anki-glossary.js create mode 100644 vendor/hachidori/extension/anki-index-cache.js create mode 100644 vendor/hachidori/extension/anki-index-worker.js create mode 100644 vendor/hachidori/extension/anki-index.js create mode 100644 vendor/hachidori/extension/anki-media.js create mode 100644 vendor/hachidori/extension/anki-mining.js create mode 100644 vendor/hachidori/extension/anki-offscreen.js create mode 100644 vendor/hachidori/extension/anki-pitch.js create mode 100644 vendor/hachidori/extension/anki-resources.js create mode 100644 vendor/hachidori/extension/anki-settings.js create mode 100644 vendor/hachidori/extension/anki-setup.js create mode 100644 vendor/hachidori/extension/anki-templates.js create mode 100644 vendor/hachidori/extension/anki-values.js create mode 100644 vendor/hachidori/extension/anki-worker.js create mode 100644 vendor/hachidori/extension/anki.js create mode 100644 vendor/hachidori/extension/api-host.js create mode 100644 vendor/hachidori/extension/assets/ATTRIBUTION.md create mode 100644 vendor/hachidori/extension/assets/preview-background-2.webp create mode 100644 vendor/hachidori/extension/assets/preview-background-3.webp create mode 100644 vendor/hachidori/extension/assets/preview-background-4.webp create mode 100644 vendor/hachidori/extension/assets/preview-background-5.webp create mode 100644 vendor/hachidori/extension/assets/preview-background-6.webp create mode 100644 vendor/hachidori/extension/assets/preview-background.webp create mode 100644 vendor/hachidori/extension/audio-cache.js create mode 100644 vendor/hachidori/extension/audio-content.js create mode 100644 vendor/hachidori/extension/audio-offscreen.js create mode 100644 vendor/hachidori/extension/audio-player.js create mode 100644 vendor/hachidori/extension/audio-repository.js create mode 100644 vendor/hachidori/extension/audio-settings.js create mode 100644 vendor/hachidori/extension/audio-sources.js create mode 100644 vendor/hachidori/extension/avif-sequence.js create mode 100644 vendor/hachidori/extension/background.js create mode 100644 vendor/hachidori/extension/backup-archive.js create mode 100644 vendor/hachidori/extension/backup-automatic.js create mode 100644 vendor/hachidori/extension/backup-downloads.js create mode 100644 vendor/hachidori/extension/backup-settings.js create mode 100644 vendor/hachidori/extension/backup-state.js create mode 100644 vendor/hachidori/extension/base64.js create mode 100644 vendor/hachidori/extension/blob-download.js create mode 100644 vendor/hachidori/extension/browser-api.js create mode 100644 vendor/hachidori/extension/capture-audio-worklet.js create mode 100644 vendor/hachidori/extension/capture-buffer.js create mode 100644 vendor/hachidori/extension/capture-content.js create mode 100644 vendor/hachidori/extension/capture-encoder-client.js create mode 100644 vendor/hachidori/extension/capture-encoder-worker.js create mode 100644 vendor/hachidori/extension/capture-frame-client.js create mode 100644 vendor/hachidori/extension/capture-frame-worker.js create mode 100644 vendor/hachidori/extension/capture-host.js create mode 100644 vendor/hachidori/extension/capture-session.js create mode 100644 vendor/hachidori/extension/capture-speech.js create mode 100644 vendor/hachidori/extension/capture-timeline.js create mode 100644 vendor/hachidori/extension/capture.css create mode 100644 vendor/hachidori/extension/capture.html create mode 100644 vendor/hachidori/extension/capture.js create mode 100644 vendor/hachidori/extension/chrome-offscreen.js create mode 100644 vendor/hachidori/extension/content.css create mode 100644 vendor/hachidori/extension/content.js create mode 100644 vendor/hachidori/extension/custom-button-settings.js create mode 100644 vendor/hachidori/extension/custom-dictionary.js create mode 100644 vendor/hachidori/extension/custom-javascript.js create mode 100644 vendor/hachidori/extension/design-preview.css create mode 100644 vendor/hachidori/extension/design-preview.html create mode 100644 vendor/hachidori/extension/design-preview.js create mode 100644 vendor/hachidori/extension/dictionary-group-state.js create mode 100644 vendor/hachidori/extension/dictionary-groups.js create mode 100644 vendor/hachidori/extension/dictionary-import-archive.js create mode 100644 vendor/hachidori/extension/dictionary-import.js create mode 100644 vendor/hachidori/extension/dictionary-name-drafts.js create mode 100644 vendor/hachidori/extension/dictionary-progress.css create mode 100644 vendor/hachidori/extension/dictionary-progress.js create mode 100644 vendor/hachidori/extension/engine-service.js create mode 100644 vendor/hachidori/extension/engine-worker-idbfs.js create mode 100644 vendor/hachidori/extension/engine-worker-runtime.js create mode 100644 vendor/hachidori/extension/engine-worker.js create mode 100644 vendor/hachidori/extension/experimental-settings.js create mode 100644 vendor/hachidori/extension/external-link-host.js create mode 100644 vendor/hachidori/extension/external-links.js create mode 100644 vendor/hachidori/extension/firefox-background.html create mode 100644 vendor/hachidori/extension/firefox-background.js create mode 100644 vendor/hachidori/extension/firefox-host.js create mode 100644 vendor/hachidori/extension/icons.css create mode 100644 vendor/hachidori/extension/icons/fluent/LICENSE create mode 100644 vendor/hachidori/extension/icons/fluent/add.svg create mode 100644 vendor/hachidori/extension/icons/fluent/arrow-clockwise.svg create mode 100644 vendor/hachidori/extension/icons/fluent/arrow-down.svg create mode 100644 vendor/hachidori/extension/icons/fluent/arrow-right.svg create mode 100644 vendor/hachidori/extension/icons/fluent/arrow-sync.svg create mode 100644 vendor/hachidori/extension/icons/fluent/arrow-up.svg create mode 100644 vendor/hachidori/extension/icons/fluent/book-search.svg create mode 100644 vendor/hachidori/extension/icons/fluent/checkmark.svg create mode 100644 vendor/hachidori/extension/icons/fluent/desktop.svg create mode 100644 vendor/hachidori/extension/icons/fluent/dismiss.svg create mode 100644 vendor/hachidori/extension/icons/fluent/document-add.svg create mode 100644 vendor/hachidori/extension/icons/fluent/document-edit.svg create mode 100644 vendor/hachidori/extension/icons/fluent/edit.svg create mode 100644 vendor/hachidori/extension/icons/fluent/error-circle.svg create mode 100644 vendor/hachidori/extension/icons/fluent/key.svg create mode 100644 vendor/hachidori/extension/icons/fluent/more-horizontal.svg create mode 100644 vendor/hachidori/extension/icons/fluent/open.svg create mode 100644 vendor/hachidori/extension/icons/fluent/reorder.svg create mode 100644 vendor/hachidori/extension/icons/fluent/settings.svg create mode 100644 vendor/hachidori/extension/icons/fluent/sources.json create mode 100644 vendor/hachidori/extension/icons/fluent/speaker-2.svg create mode 100644 vendor/hachidori/extension/icons/fluent/star.svg create mode 100644 vendor/hachidori/extension/icons/fluent/subtract.svg create mode 100644 vendor/hachidori/extension/icons/hachidori-128.png create mode 100644 vendor/hachidori/extension/icons/hachidori-16.png create mode 100644 vendor/hachidori/extension/icons/hachidori-32.png create mode 100644 vendor/hachidori/extension/icons/hachidori-48.png create mode 100644 vendor/hachidori/extension/json-value.js create mode 100644 vendor/hachidori/extension/keybind-settings.js create mode 100644 vendor/hachidori/extension/local-audio-setup.js create mode 100644 vendor/hachidori/extension/local-audio-source.js create mode 100644 vendor/hachidori/extension/local-file-access.js create mode 100644 vendor/hachidori/extension/lookup-stats-identity.js create mode 100644 vendor/hachidori/extension/lookup-stats.js create mode 100644 vendor/hachidori/extension/managed-dictionary-source.js create mode 100644 vendor/hachidori/extension/manifest.firefox.json create mode 100644 vendor/hachidori/extension/manifest.json create mode 100644 vendor/hachidori/extension/media-limits.js create mode 100644 vendor/hachidori/extension/offscreen.html create mode 100644 vendor/hachidori/extension/offscreen.js create mode 100644 vendor/hachidori/extension/opfs-capability-worker.js create mode 100644 vendor/hachidori/extension/overlay-mode.js create mode 100644 vendor/hachidori/extension/reader-options.js create mode 100644 vendor/hachidori/extension/recommended-dictionaries.js create mode 100644 vendor/hachidori/extension/recommended-install-client.js create mode 100644 vendor/hachidori/extension/render/ATTRIBUTION.md create mode 100644 vendor/hachidori/extension/render/glossary.js create mode 100644 vendor/hachidori/extension/render/popup.js create mode 100644 vendor/hachidori/extension/render/reader.css create mode 100644 vendor/hachidori/extension/response-limits.js create mode 100644 vendor/hachidori/extension/sample-meal.svg create mode 100644 vendor/hachidori/extension/settings-dom.js create mode 100644 vendor/hachidori/extension/settings-search.js create mode 100644 vendor/hachidori/extension/settings.css create mode 100644 vendor/hachidori/extension/settings.html create mode 100644 vendor/hachidori/extension/settings.js create mode 100644 vendor/hachidori/extension/setup-installer.js create mode 100644 vendor/hachidori/extension/setup-state.js create mode 100644 vendor/hachidori/extension/sharing-client.js create mode 100644 vendor/hachidori/extension/sharing-host.js create mode 100644 vendor/hachidori/extension/sharing-protocol.js create mode 100644 vendor/hachidori/extension/sharing-settings.js create mode 100644 vendor/hachidori/extension/speech.js create mode 100644 vendor/hachidori/extension/startup-practice.js create mode 100644 vendor/hachidori/extension/startup.css create mode 100644 vendor/hachidori/extension/startup.html create mode 100644 vendor/hachidori/extension/startup.js create mode 100644 vendor/hachidori/extension/subminer-host.js create mode 100644 vendor/hachidori/extension/texthooker-protocol.js create mode 100644 vendor/hachidori/extension/toolbar.css create mode 100644 vendor/hachidori/extension/toolbar.html create mode 100644 vendor/hachidori/extension/toolbar.js create mode 100644 vendor/hachidori/extension/vendor/avif-THIRD_PARTY_NOTICES create mode 100644 vendor/hachidori/extension/vendor/avif-encoder.mjs create mode 100644 vendor/hachidori/extension/vendor/avif-encoder.wasm create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/README.md create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/compiler-rt-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/emscripten-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/glaze-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/gumbo-parser-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/gumbo-parser-LICENSE-hashmap.c create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/hoshidicts-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/kanji-processor-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/libcxx-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/libcxxabi-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/libdeflate-COPYING create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/libunwind-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/lzokay-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/musl-COPYRIGHT create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/reader-and-language-NOTICE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/unordered_dense-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/utf8proc-LICENSE.md create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/utfcpp-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/xxHash-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/zstd-COPYING create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-licenses/zstd-LICENSE create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-threaded-idbfs.mjs create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-threaded-idbfs.wasm create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-threaded.mjs create mode 100644 vendor/hachidori/extension/vendor/hoshidicts-threaded.wasm create mode 100644 vendor/hachidori/extension/vendor/hoshidicts.mjs create mode 100644 vendor/hachidori/extension/vendor/hoshidicts.wasm create mode 100644 vendor/hachidori/extension/vendor/zip-LICENSE create mode 100644 vendor/hachidori/extension/vendor/zip.js create mode 100644 vendor/hachidori/extension/visual-novel.css create mode 100644 vendor/hachidori/extension/visual-novel.js create mode 100644 vendor/hachidori/scripts/anki-note-type-compatibility.mjs create mode 100644 vendor/hachidori/scripts/anki-note-type-upstream.py create mode 100644 vendor/hachidori/scripts/capture-store-assets.mjs create mode 100644 vendor/hachidori/scripts/check-release.mjs create mode 100644 vendor/hachidori/scripts/chrome-web-store.mjs create mode 100644 vendor/hachidori/scripts/firefox-package.json create mode 100644 vendor/hachidori/scripts/lint-firefox.mjs create mode 100644 vendor/hachidori/scripts/package-store.py create mode 100644 vendor/hachidori/scripts/prepare-firefox.mjs create mode 100644 vendor/hachidori/scripts/store-sources.json create mode 100644 vendor/hachidori/scripts/vendor-fluent-icons.py create mode 100644 vendor/hachidori/scripts/verify-firefox-package.mjs create mode 100644 vendor/hachidori/third_party/hoshidicts/.clang-format create mode 100644 vendor/hachidori/third_party/hoshidicts/.clang-tidy create mode 100644 vendor/hachidori/third_party/hoshidicts/.gitignore create mode 100644 vendor/hachidori/third_party/hoshidicts/.gitmodules create mode 100644 vendor/hachidori/third_party/hoshidicts/CMakeLists.txt create mode 100644 vendor/hachidori/third_party/hoshidicts/LICENSE create mode 100644 vendor/hachidori/third_party/hoshidicts/Package.swift create mode 100644 vendor/hachidori/third_party/hoshidicts/README.md create mode 100644 vendor/hachidori/third_party/hoshidicts/benchmark/import.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/benchmark/lookup.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/cli/main.c create mode 100644 vendor/hachidori/third_party/hoshidicts/cli/main.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/LICENSE create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/LICENSE-hashmap.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/README.md create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/UPSTREAM-README.md create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/ascii.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/ascii.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/attribute.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/attribute.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/char_ref.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/char_ref.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/error.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/error.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/foreign_attrs.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/hashmap.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/hashmap.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/insertion_mode.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/macros.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/nokogiri_gumbo.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/parser.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/parser.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/replacement.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/string_buffer.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/string_buffer.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/string_piece.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/string_set.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/string_set.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/svg_attrs.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/svg_tags.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tag.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tag_lookup.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tag_lookup.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/token_buffer.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/token_buffer.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/token_type.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tokenizer.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tokenizer.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/tokenizer_states.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/utf8.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/utf8.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/util.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/util.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/vector.c create mode 100644 vendor/hachidori/third_party/hoshidicts/external/gumbo-parser/src/vector.h create mode 100644 vendor/hachidori/third_party/hoshidicts/external/lzokay/LICENSE create mode 100644 vendor/hachidori/third_party/hoshidicts/external/lzokay/README.md create mode 100644 vendor/hachidori/third_party/hoshidicts/external/lzokay/lzokay.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/external/lzokay/lzokay.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts.h create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts/deinflector.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts/importer.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts/lookup.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts/query.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/include/hoshidicts_c.h create mode 100644 vendor/hachidori/third_party/hoshidicts/include/module.modulemap create mode 100644 vendor/hachidori/third_party/hoshidicts/src/deinflector.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/hash/bloom.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/hash/bloom.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/hash/hash.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/hash/hash.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/hoshidicts_c.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/importer.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/json/json_skip.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/json/json_skip.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/json/yomitan_parser.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/json/yomitan_parser.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/lookup.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/html_to_structured.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/html_to_structured.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/mdict_reader.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/mdict_reader.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/mdict_source.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/mdict_source.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/ripemd128.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/mdict/ripemd128.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/memory/memory.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/memory/memory.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/path_utils.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/query.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/query_internal.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/scan_index.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/source/dictionary_source.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/source/zip_source.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/source/zip_source.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/text_processor/kanji_variants.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/text_processor/text_processor.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/text_processor/text_processor.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/zip/zip.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/zip/zip.hpp create mode 100644 vendor/hachidori/third_party/hoshidicts/src/zstd/zstd_compress_sequences.c create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/deps_smoke_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_adler.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_encrypted1.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_gbk.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_huge_block.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_truncated.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/bad_v3.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/gen_fixtures.py create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v1_utf8_stored.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf16_encrypted2.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdd create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_lzo_html.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/mdict/v2_utf8_zlib_text.mdx create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/gen_fixture.py create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/golden.sha256 create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/small_dict.zip create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/html_to_structured_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/import_equivalence_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/json_skip_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/long_key_scan_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/mdict_reader_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/mdict_test.cpp create mode 100644 vendor/hachidori/third_party/hoshidicts/tests/score_roundtrip_test.cpp create mode 100644 vendor/hachidori/wasm/CMakeLists.txt create mode 100644 vendor/hachidori/wasm/avif/CMakeLists.txt create mode 100644 vendor/hachidori/wasm/avif/THIRD_PARTY_NOTICES create mode 100755 vendor/hachidori/wasm/avif/build.sh create mode 100644 vendor/hachidori/wasm/avif/encoder.c create mode 100644 vendor/hachidori/wasm/bindings.cpp create mode 100755 vendor/hachidori/wasm/build.sh create mode 100755 vendor/hachidori/wasm/env.sh diff --git a/.gitignore b/.gitignore index fbba9131..af0fe6c8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ coverage/ /main-entry.js /main-entry.js.map +/build/hachidori/ + # Logs *.log npm-debug.log* diff --git a/README.md b/README.md index 3437e354..597b74ef 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # SubMiner -Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player +Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player [Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe) @@ -25,6 +25,8 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv. +Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki. +
Yomitan dictionary popup over annotated subtitles in mpv
@@ -275,7 +277,8 @@ SubMiner builds on the work of these open-source projects: | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | -| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser | +| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser | +| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts | | [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | ## License diff --git a/changes/hachidori-backend.md b/changes/hachidori-backend.md new file mode 100644 index 00000000..2a386fa6 --- /dev/null +++ b/changes/hachidori-backend.md @@ -0,0 +1,13 @@ +type: added +area: dictionary + +- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner. +- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` continues to open Yomitan settings. +- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. +- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates. Anki discovery retries after an unavailable connection. +- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay. +- Hachidori reloads its background code on startup so extension updates take effect while preserving installed dictionaries and settings. +- First-run setup remembers each backend that finished it, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. +- 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. diff --git a/changes/hachidori-setup-docs.md b/changes/hachidori-setup-docs.md new file mode 100644 index 00000000..30697ac0 --- /dev/null +++ b/changes/hachidori-setup-docs.md @@ -0,0 +1,4 @@ +type: docs +area: dictionary + +- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type. diff --git a/changes/overlay-modal-first-open.md b/changes/overlay-modal-first-open.md new file mode 100644 index 00000000..88adb937 --- /dev/null +++ b/changes/overlay-modal-first-open.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry. diff --git a/config.example.jsonc b/config.example.jsonc index 8e9a3cd6..fd3ad54e 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -6,6 +6,13 @@ */ { + // ========================================== + // Dictionary Backend + // Select the dictionary lookup backend: yomitan or hachidori. + // Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries. + // ========================================== + "dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori + // ========================================== // Japanese Subtitle Generation // Generate timed Japanese subtitles from local audio using whisper.cpp. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index d6812179..c99b5fe4 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -21,6 +21,14 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`. +### Reusing SubMiner settings in Hachidori + +When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It copies the configured deck and tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set. + +If the note type is unset, SubMiner looks for a unique match containing its configured word and sentence fields. Enabled Lapis, Kiku, or Senren integration narrows the search; Lapis uses its configured model name. A fresh mapping also receives Hachidori's matching preset for readings, definitions, and other recognized fields. If several note types match, choose one in Hachidori Settings. If Anki is closed, open Hachidori Settings again after starting Anki to retry. + +Existing custom decks, tags, field mappings, advanced templates, and additional templates stay intact. This fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior. + ## Auto-enrichment transport When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available: @@ -141,6 +149,8 @@ Field names are matched against your Anki note type case-insensitively (an exact `fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults. +When Hachidori mines through SubMiner's Anki proxy, it prepares downloadable word audio before saving the note so the animation delay can be measured on the first mine. Configure a downloadable pronunciation source in Hachidori's Audio settings; browser speech cannot be saved into Anki by the SubMiner overlay. If pronunciation is unavailable, Hachidori reports a warning and the card has no word-audio hold. + These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names. Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `
` newline). @@ -315,6 +325,8 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching). +Field grouping runs when a new note is added with known duplicates. With the Hachidori backend that is the popup's **Add anyway** choice; **Overwrite** updates the existing note in place and only receives media enrichment. + ```jsonc "ankiConnect": { "isKiku": { diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 89d2ab40..37ed57df 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -171,6 +171,12 @@ The configuration file includes several main sections: ## Core settings +### Dictionary backend + +`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection. + +Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation. + ### Logging Control the minimum log level for runtime output: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 8e9a3cd6..fd3ad54e 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -6,6 +6,13 @@ */ { + // ========================================== + // Dictionary Backend + // Select the dictionary lookup backend: yomitan or hachidori. + // Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries. + // ========================================== + "dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori + // ========================================== // Japanese Subtitle Generation // Generate timed Japanese subtitles from local audio using whisper.cpp. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 4dd5b6b0..b67ce0b4 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -15,7 +15,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi | Shortcut | Action | Scope | Configurable | | ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- | | `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` | -| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) | +| `Alt+Shift+Y` | Open active dictionary settings | OS-global (registered with the OS) | Fixed (not configurable) | ::: tip `Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed. @@ -124,7 +124,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix - press `y | `y-S` | Stop overlay | | `y-t` | Toggle visible overlay | | `v` | Cycle primary subtitle bar mode (hidden → visible → hover) | -| `y-o` | Open Yomitan settings | +| `y-o` | Open active dictionary settings | | `y-r` | Restart overlay | | `y-c` | Check overlay status | | `y-h` | Open session help | diff --git a/docs-site/usage.md b/docs-site/usage.md index b613d157..87c449f4 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -15,8 +15,8 @@ That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socke Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine. > [!IMPORTANT] -> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work. -> See [Yomitan setup](#yomitan-setup) for details. +> SubMiner requires at least one dictionary in the selected lookup backend. +> See [Yomitan setup](#yomitan-setup) or [Hachidori setup](#hachidori-setup). ::: tip Anki card enrichment If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config: @@ -174,6 +174,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle b SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search SubMiner.AppImage --yomitan # Open Yomitan settings +SubMiner.AppImage --hachidori # Open Hachidori settings SubMiner.AppImage --settings # Open the SubMiner settings window SubMiner.AppImage --jellyfin # Open the Jellyfin setup window SubMiner.AppImage --dictionary # Generate a character dictionary ZIP @@ -208,7 +209,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m ### Windows mpv shortcut -First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. +First-run setup creates the config file, then requires dictionaries in the selected backend before it can finish. If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly. After setup completes, the shortcut is the normal Windows playback entry point. @@ -314,6 +315,26 @@ For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yom If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance. +### Hachidori setup + +Set `dictionaryBackend` to `"hachidori"` in SubMiner settings or `config.jsonc`, then restart SubMiner. The tray's dictionary settings entry changes to **Open Hachidori Settings**. Switching to `"yomitan"` restores the Yomitan entry after restarting. + +Open Hachidori settings with `subminer app --hachidori` or `SubMiner.AppImage --hachidori`. Import your dictionary ZIPs or use Hachidori's recommended dictionary installer, then configure its Anki templates. Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not transfer to Hachidori. + +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. + +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. + +Hachidori uses SubMiner's subtitle scanning, lookup counter, popup pause behavior, controller commands, character dictionaries, and Anki media enrichment. Keep SubMiner's AnkiConnect proxy enabled for screenshots and sentence audio. SubMiner routes Hachidori to that proxy when it is active; Hachidori's own screen recorder and screenshot capture are disabled in the embedded app. + +Existing controls such as `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to the selected backend. Hachidori has one dictionary configuration, so character-dictionary profile scope applies to that configuration. + +First-run setup remembers each backend that finished it. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. + +Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well. + ### YouTube playback `subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets. @@ -408,7 +429,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh | Keybind | Action | Scope | | ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | | `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) | -| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window | +| `Alt+Shift+Y` | Open active dictionary settings | OS-global - registered with the system, works from any window | `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1f65d433..4326a1ec 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -44,6 +44,12 @@ Update checks and startup launcher migration share a serialized update-state sto ## Architecture Intent +The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend. + +`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. + - Small units, explicit boundaries - Composition over monoliths - Pure helpers where possible diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index f39191ea..16ad4fc2 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -63,6 +63,7 @@ bun run docs:build Content checks and informational size reporting run inside electron-builder hooks. See the [release guide](../RELEASING.md#package-contents-and-size-checks) for size reports and the installed-app verification checklist. +- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux. It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser. - Coverage for the maintained source lane: `bun run test:coverage:src` - Deep/local full gate: default handoff gate above diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 78ae0b49..eb958975 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -26,7 +26,10 @@ import { readSetupState, } from '../../src/shared/setup-state.js'; import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js'; -import { hasLauncherExternalYomitanProfileConfig } from '../config.js'; +import { + hasLauncherExternalYomitanProfileConfig, + loadLauncherDictionaryBackend, +} from '../config.js'; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_POLL_INTERVAL_MS = 500; @@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis const configDir = getLauncherConfigDir(); const statePath = getSetupStatePath(configDir); const ready = await ensureLauncherSetupReady({ + dictionaryBackend: loadLauncherDictionaryBackend(), + isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir), + warn: (message) => log('warn', args.logLevel, message), readSetupState: () => readSetupState(statePath), isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(), hasLegacyMpvPlugin: () => diff --git a/launcher/config.ts b/launcher/config.ts index 57a7f9a7..96f0e867 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -1,4 +1,5 @@ import { fail } from './log.js'; +import type { DictionaryBackend } from '../src/types/config.js'; import type { Args, LauncherLoggingConfig, @@ -100,8 +101,17 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig { }; } +export function loadLauncherDictionaryBackend(): DictionaryBackend { + return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori' + ? 'hachidori' + : 'yomitan'; +} + export function hasLauncherExternalYomitanProfileConfig(): boolean { - return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; + const config = readLauncherMainConfigObject(); + return ( + config?.dictionaryBackend !== 'hachidori' && readExternalYomitanProfilePath(config) !== null + ); } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 3e3c3845..7d349cff 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -241,6 +241,10 @@ export function applyRootOptionsToArgs( if (options.update === true) parsed.update = true; if (options.version === true) parsed.version = true; if (options.settings === true) parsed.settings = true; + if (options.yomitan === true || options.hachidori === true) { + parsed.appPassthrough = true; + parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori']; + } if (options.startOverlay === true) parsed.autoStartOverlay = true; if (options.texthooker === false) parsed.useTexthooker = false; if (typeof options.args === 'string') parsed.mpvArgs = options.args; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 82eb46f4..07be0606 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -86,6 +86,8 @@ function applyRootOptions(program: Command): void { .option('--log-level ', 'Log level') .option('-v, --version', 'Show SubMiner version') .option('--settings', 'Open settings window') + .option('--yomitan', 'Open Yomitan settings window') + .option('--hachidori', 'Open Hachidori settings window') .option('-u, --update', 'Check for updates') .option('-R, --rofi', 'Use rofi picker') .option('-H, --history', 'Browse local watch history') diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 463420f1..1c9805db 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -282,29 +282,31 @@ test('doctor refresh-known-words forwards app refresh command without requiring }); }); -test('launcher settings option forwards app settings window command', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - const capturePath = path.join(root, 'captured-args.txt'); - fs.writeFileSync( - appPath, - `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, - ); - fs.chmodSync(appPath, 0o755); +for (const flag of ['--settings', '--yomitan', '--hachidori']) { + test(`launcher ${flag} forwards the matching app settings command`, () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const appPath = path.join(root, 'fake-subminer.sh'); + const capturePath = path.join(root, 'captured-args.txt'); + fs.writeFileSync( + appPath, + `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`, + ); + fs.chmodSync(appPath, 0o755); - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_CAPTURE: capturePath, - }; - const result = runLauncher(['--settings'], env); + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_CAPTURE: capturePath, + }; + const result = runLauncher([flag], env); - assert.equal(result.status, 0); - assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n'); + assert.equal(result.status, 0); + assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`); + }); }); -}); +} test('launcher settings command forwards app settings window command', () => { withTempDir((root) => { diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 9dfb5660..e4232de0 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -344,3 +344,12 @@ test('parseArgs requires an explicit logs action', () => { assert.equal(exit.code, 1); assert.match(exit.stderr, /Logs command requires -e or --export/); }); + +for (const flag of ['--yomitan', '--hachidori']) { + test(`parseArgs forwards ${flag} to the app`, () => { + const parsed = parseArgs([flag], 'subminer', {}); + assert.equal(parsed.appPassthrough, true); + assert.deepEqual(parsed.appArgs, [flag]); + assert.equal(parsed.settings, false); + }); +} diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index d61e8e67..d8ccdf84 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate'; -import type { SetupState } from '../src/shared/setup-state'; +import { + ensureLauncherSetupReady, + resolveLauncherGateBackend, + waitForSetupCompletion, +} from './setup-gate'; +import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state'; const commandLineSetupDefaults = { bunInstallStatus: 'unknown', @@ -295,3 +299,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set assert.equal(result, true); }); + +test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => { + let state: SetupState = { ...createDefaultSetupState(), status: 'completed' }; + let launched = 0; + let polls = 0; + const ready = await ensureLauncherSetupReady({ + dictionaryBackend: 'hachidori', + readSetupState: () => state, + isExternalYomitanConfigured: () => true, + launchSetupApp: () => { + launched += 1; + }, + sleep: async () => { + polls += 1; + state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 }; + }, + now: () => polls, + timeoutMs: 5, + pollIntervalMs: 1, + }); + assert.equal(ready, true); + assert.equal(launched, 1); + assert.equal(polls, 1); +}); + +test('matching Hachidori completion resumes playback without launching setup', async () => { + const ready = await ensureLauncherSetupReady({ + dictionaryBackend: 'hachidori', + readSetupState: () => ({ + ...createDefaultSetupState(), + dictionaryBackend: 'hachidori', + status: 'completed', + lastSeenYomitanDictionaryCount: 1, + }), + launchSetupApp: () => assert.fail('setup should not open'), + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5, + pollIntervalMs: 1, + }); + assert.equal(ready, true); +}); + +test('a backend that finished setup earlier passes the gate after switching back', async () => { + const ready = await ensureLauncherSetupReady({ + dictionaryBackend: 'yomitan', + readSetupState: () => ({ + ...createDefaultSetupState(), + dictionaryBackend: 'hachidori', + status: 'incomplete', + completedDictionaryBackends: ['yomitan'], + }), + launchSetupApp: () => assert.fail('setup should not open'), + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5, + pollIntervalMs: 1, + }); + assert.equal(ready, true); +}); + +test('gate follows the running app backend until it restarts into the configured one', async () => { + const warnings: string[] = []; + const state = { + ...createDefaultSetupState(), + dictionaryBackend: 'yomitan' as const, + status: 'completed' as const, + }; + assert.equal( + await resolveLauncherGateBackend({ + configuredBackend: 'hachidori', + state, + isAppRunning: async () => true, + warn: (message) => warnings.push(message), + }), + 'yomitan', + ); + assert.match(warnings[0] ?? '', /restart it to switch to hachidori/); + assert.equal( + await resolveLauncherGateBackend({ + configuredBackend: 'hachidori', + state, + isAppRunning: async () => false, + }), + 'hachidori', + ); + const ready = await ensureLauncherSetupReady({ + dictionaryBackend: 'hachidori', + isAppRunning: async () => true, + readSetupState: () => state, + launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'), + sleep: async () => undefined, + now: () => 0, + timeoutMs: 5, + pollIntervalMs: 1, + }); + assert.equal(ready, true); +}); diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts index c82ee39f..f862fd37 100644 --- a/launcher/setup-gate.ts +++ b/launcher/setup-gate.ts @@ -1,7 +1,13 @@ -import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js'; +import type { DictionaryBackend } from '../src/types/config.js'; +import { + getSetupStateDictionaryBackend, + isSetupCompleted, + type SetupState, +} from '../src/shared/setup-state.js'; export async function waitForSetupCompletion(deps: { readSetupState: () => SetupState | null; + dictionaryBackend?: DictionaryBackend; sleep: (ms: number) => Promise; now: () => number; timeoutMs: number; @@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: { while (deps.now() <= deadline) { const state = deps.readSetupState(); - if (isSetupCompleted(state)) { + if (isSetupCompleted(state, deps.dictionaryBackend)) { return 'completed'; } if (ignoringCancelled && state != null && state.status !== 'cancelled') { @@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: { export async function waitForLegacyMpvPluginPromptResolution(deps: { readSetupState: () => SetupState | null; + dictionaryBackend?: DictionaryBackend; sleep: (ms: number) => Promise; now: () => number; timeoutMs: number; @@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: { initialState?: SetupState | null; }): Promise<'acknowledged' | 'cancelled' | 'timeout'> { const deadline = deps.now() + deps.timeoutMs; - const initialCompleted = isSetupCompleted(deps.initialState); + const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend); const initialCompletedAt = deps.initialState?.completedAt ?? null; while (deps.now() <= deadline) { const state = deps.readSetupState(); if ( - isSetupCompleted(state) && + isSetupCompleted(state, deps.dictionaryBackend) && (!initialCompleted || state?.completedAt !== initialCompletedAt) ) { return 'acknowledged'; @@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: { return 'timeout'; } +/** + * The app pins its dictionary backend at startup while the config file can change + * underneath it. When an app is already running, gate on the backend it recorded + * in the setup state rather than the config value it has not restarted into. + */ +export async function resolveLauncherGateBackend(deps: { + configuredBackend: DictionaryBackend; + state: SetupState | null; + isAppRunning?: () => Promise; + warn?: (message: string) => void; +}): Promise { + const runningBackend = deps.state + ? getSetupStateDictionaryBackend(deps.state) + : deps.configuredBackend; + if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) { + return deps.configuredBackend; + } + deps.warn?.( + `SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`, + ); + return runningBackend; +} + export async function ensureLauncherSetupReady(deps: { readSetupState: () => SetupState | null; + dictionaryBackend?: DictionaryBackend; + isAppRunning?: () => Promise; + warn?: (message: string) => void; isExternalYomitanConfigured?: () => boolean; hasLegacyMpvPlugin?: () => boolean; launchSetupApp: () => void; @@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: { pollIntervalMs: number; }): Promise { const initialState = deps.readSetupState(); + const dictionaryBackend = await resolveLauncherGateBackend({ + configuredBackend: deps.dictionaryBackend ?? 'yomitan', + state: initialState, + isAppRunning: deps.isAppRunning, + warn: deps.warn, + }); let setupLaunched = false; const launchSetupApp = () => { if (setupLaunched) return; @@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: { launchSetupApp(); const result = await waitForLegacyMpvPluginPromptResolution({ readSetupState: deps.readSetupState, + dictionaryBackend, sleep: deps.sleep, now: deps.now, timeoutMs: deps.timeoutMs, @@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: { } } - if (deps.isExternalYomitanConfigured?.()) { + if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) { return true; } const stateAfterLegacyPrompt = deps.readSetupState(); - if (isSetupCompleted(stateAfterLegacyPrompt)) { + if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) { return true; } launchSetupApp(); const result = await waitForSetupCompletion({ ...deps, + dictionaryBackend, ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled', }); return result === 'completed'; diff --git a/package.json b/package.json index 12e2a459..7b35782e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line", "test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts", + "test:dictionary:electron": "env -u ELECTRON_RUN_AS_NODE electron --no-sandbox scripts/check-dictionary-backends.cjs && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox scripts/check-dictionary-backends.cjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs", "test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js", "verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js", "record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js", @@ -21,7 +22,7 @@ "build:launcher": "bun run scripts/build-launcher.ts", "build:stats": "cd stats && bun run build", "dev:stats": "cd stats && bun run dev", - "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets", + "build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap", "build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap", @@ -81,7 +82,8 @@ "build:mac:zip": "bun run build && electron-builder --mac zip --publish never", "build:win": "bun run build && electron-builder --win nsis zip --publish never", "build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs", - "test:package": "bun scripts/run-package-smoke.mjs" + "test:package": "bun scripts/run-package-smoke.mjs", + "build:hachidori": "bun scripts/build-hachidori.mjs" }, "overrides": { "@xmldom/xmldom": "0.8.15", @@ -232,6 +234,10 @@ "from": "build/yomitan", "to": "yomitan" }, + { + "from": "build/hachidori", + "to": "hachidori" + }, { "from": "vendor/yomitan-jlpt-vocab", "to": "yomitan-jlpt-vocab" diff --git a/scripts/build-hachidori.mjs b/scripts/build-hachidori.mjs new file mode 100644 index 00000000..8a004ea1 --- /dev/null +++ b/scripts/build-hachidori.mjs @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = path.join(repoRoot, 'vendor', 'hachidori'); +const output = path.join(repoRoot, 'build', 'hachidori'); +const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8')); + +// Upstream commits the engine binaries. Verify the pinned bytes before staging +// so ordinary app builds need neither Emscripten nor network access. +for (const [file, expected] of Object.entries(provenance.artifacts)) { + const actual = createHash('sha256') + .update(fs.readFileSync(path.join(source, file))) + .digest('hex'); + if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`); +} +const extension = path.join(source, 'extension'); +const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8')); +for (const file of [ + manifest.options_page, + manifest.background.service_worker, + 'offscreen.html', + ...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]), +]) { + if (!fs.existsSync(path.join(extension, file))) + throw new Error(`Missing Hachidori asset: ${file}`); +} +fs.rmSync(output, { recursive: true, force: true }); +fs.mkdirSync(output, { recursive: true }); +fs.cpSync(extension, output, { recursive: true }); +for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) { + fs.copyFileSync(path.join(source, file), path.join(output, file)); +} +process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`); diff --git a/scripts/check-dictionary-backends.cjs b/scripts/check-dictionary-backends.cjs new file mode 100644 index 00000000..f5d43862 --- /dev/null +++ b/scripts/check-dictionary-backends.cjs @@ -0,0 +1,110 @@ +// Exercises the real entry point with disposable Linux config and data directories. +const { app, BrowserWindow, session, shell } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +if (process.platform !== 'linux') + throw new Error('This app-entry smoke requires Linux XDG isolation.'); +const root = process.cwd(); +const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori'; +const profile = fs.mkdtempSync('/tmp/subminer-hachi-settings-'); +process.env.XDG_CONFIG_HOME = profile; +process.env.XDG_DATA_HOME = path.join(profile, 'data'); +fs.mkdirSync(path.join(profile, 'SubMiner')); +fs.writeFileSync( + path.join(profile, 'SubMiner', 'config.json'), + JSON.stringify({ + dictionaryBackend: backend, + mpv: { socketPath: path.join(profile, 'missing-mpv.sock') }, + ankiConnect: { enabled: false }, + startupWarmups: { lowPowerMode: true }, + discordPresence: { enabled: false }, + updates: { enabled: false }, + }), +); +const openedLinks = []; +shell.openExternal = async (url) => { + openedLinks.push(url); +}; +app.setAppPath(root); +app.getVersion = () => require(path.join(root, 'package.json')).version; +process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log'); +app.commandLine.appendSwitch('ozone-platform', 'x11'); +app.commandLine.appendSwitch('disable-gpu'); +app.commandLine.appendSwitch('disable-dev-shm-usage'); +process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug']; +require(path.join(root, 'dist/main-entry.js')); +const deadline = setTimeout(() => { + console.error( + 'FAIL timeout', + BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()), + ); + app.exit(1); +}, 60000); +(async () => { + await app.whenReady(); + let window; + for (let i = 0; i < 300; i++) { + window = BrowserWindow.getAllWindows().find( + (w) => w.getTitle().includes('Hachidori') && w.isVisible(), + ); + if (window) break; + await new Promise((r) => setTimeout(r, 100)); + } + assert.ok(window, 'Hachidori settings window opens from actual app flag'); + assert.equal(window.webContents.session, session.fromPartition('persist:hachidori')); + assert.match(window.webContents.getURL(), /settings.html/); + const status = await window.webContents.executeJavaScript( + `chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`, + ); + assert.equal(status.ok, true); + assert.equal(status.ready, true); + + console.log( + 'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready', + ); + app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root); + let yomi; + for (let i = 0; i < 200; i++) { + yomi = BrowserWindow.getAllWindows().find( + (w) => w.getTitle().includes('Yomitan') && w.isVisible(), + ); + if (yomi) break; + await new Promise((r) => setTimeout(r, 100)); + } + assert.ok(yomi, 'inactive --yomitan settings opens'); + assert.equal(yomi.webContents.session, session.defaultSession); + assert.equal(window.webContents.session, session.fromPartition('persist:hachidori')); + app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root); + let overlay; + for (let i = 0; i < 200; i++) { + overlay = BrowserWindow.getAllWindows().find((w) => + w.webContents.getURL().includes('/renderer/index.html?'), + ); + if (overlay && !overlay.webContents.isLoading()) break; + await new Promise((r) => setTimeout(r, 100)); + } + assert.ok(overlay, 'actual overlay initialized'); + assert.equal( + overlay.webContents.session, + backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession, + ); + const requestLink = (url) => + overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000); + window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true}); + window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}})); + })`); + assert.equal((await requestLink('file:///tmp/private')).ok, false); + assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori'); + assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []); + console.log( + `PASS ${backend} overlay session, independent settings windows, real preload external link bridge`, + ); + + clearTimeout(deadline); + app.exit(0); +})().catch((error) => { + console.error(error); + app.exit(1); +}); diff --git a/scripts/check-hachidori-parser.cjs b/scripts/check-hachidori-parser.cjs new file mode 100644 index 00000000..c1db6f03 --- /dev/null +++ b/scripts/check-hachidori-parser.cjs @@ -0,0 +1,248 @@ +// Run after bun run build. This uses a temporary profile and small local ZIPs. +const { app, BrowserWindow, protocol, session } = require('electron'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const root = path.resolve(__dirname, '..'); +const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js')); +const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js')); +const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-')); + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'chrome-extension', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + bypassCSP: true, + }, + }, +]); +app.setPath('userData', profile); +app.setAppPath(root); +app.disableHardwareAcceleration(); +app.on('window-all-closed', () => {}); +const deadline = setTimeout(() => { + console.error('Hachidori parser verification timed out', profile); + app.exit(1); +}, 120_000); + +function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') { + const zipPath = path.join(profile, name + '.zip'); + writeStoredZip(zipPath, [ + { + name: 'index.json', + data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })), + }, + { name: bankName, data: Buffer.from(JSON.stringify(entries)) }, + ]); + return zipPath; +} + +app + .whenReady() + .then(async () => { + const targetSession = session.fromPartition('persist:hachidori-check'); + const extension = await targetSession.extensions.loadExtension( + path.join(root, 'build/hachidori'), + { allowFileAccess: true }, + ); + // Keep a host window alive while the dictionary importer opens/closes its + // temporary settings windows, matching the running app's window lifecycle. + const host = new BrowserWindow({ + show: false, + webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false }, + }); + await host.loadURL(`chrome-extension://${extension.id}/settings.html`); + let parserWindow = null; + let readyPromise = null; + let initPromise = null; + const deps = { + getYomitanExt: () => extension, + getYomitanSession: () => targetSession, + getYomitanParserWindow: () => parserWindow, + setYomitanParserWindow: (value) => { + parserWindow = value; + }, + getYomitanParserReadyPromise: () => readyPromise, + setYomitanParserReadyPromise: (value) => { + readyPromise = value; + }, + getYomitanParserInitPromise: () => initPromise, + setYomitanParserInitPromise: (value) => { + initPromise = value; + }, + }; + const errors = []; + const logger = { + error: (...args) => { + errors.push(args); + console.error(...args); + }, + }; + assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []); + assert.equal( + await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, { + forceOverride: true, + deck: 'Test Mining', + }), + true, + ); + // Exercise discovery and revisioned options writes in the real extension, + // without creating a note or changing the user's Anki collection. + const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image']; + const metadataServer = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + const message = JSON.parse(body); + assert.equal(message.action, 'multi'); + const result = message.params.actions.map(({ action }) => ({ + result: + action === 'deckNames' + ? ['Test Mining'] + : action === 'modelNames' + ? ['Custom Japanese'] + : fields, + error: null, + })); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ result, error: null })); + }); + }); + await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve)); + try { + const url = `http://127.0.0.1:${metadataServer.address().port}`; + const ankiConfig = { + tags: ['SubMiner', 'Autofill'], + fields: { + word: 'Term', + sentence: 'Context', + wordAudio: 'Pronunciation', + image: 'Image', + }, + }; + assert.equal( + await parser.syncYomitanDefaultAnkiServer(url, deps, logger, { + forceOverride: true, + deck: 'Test Mining', + ankiConfig, + }), + true, + ); + const anki = await parserWindow.webContents.executeJavaScript( + `(async () => (await chrome.storage.local.get('options')).options.anki)()`, + ); + assert.equal(anki.model, 'Custom Japanese'); + assert.equal(anki.deck, 'Test Mining'); + assert.deepEqual(anki.tags, ankiConfig.tags); + assert.equal(anki.fieldTemplates.Term.value, '{expression}'); + assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}'); + assert.equal(anki.fieldTemplates.Context.value, '{sentence}'); + assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates); + console.log('Hachidori Anki auto-population passed with an empty dictionary library'); + } finally { + await new Promise((resolve) => metadataServer.close(resolve)); + } + const archives = [ + fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [ + ['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''], + ]), + fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [ + ['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''], + ]), + fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [ + ['食べる', 'freq', { reading: 'たべる', frequency: 42 }], + ['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }], + ['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }], + ['頻度だけ', 'freq', 17], + ]), + fixture( + 'occurrences', + 'SubMiner Test Occurrences', + 'term_meta_bank_1.json', + [['頻度だけ', 'freq', 9000]], + 'occurrence-based', + ), + ]; + for (const archive of archives) + assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true); + const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger); + assert.equal(dictionaries.length, 4); + parser.clearYomitanParserCachesForWindow(parserWindow); + const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, { + includeNameMatchMetadata: true, + currentCharacterDictionaryMediaId: 1, + }); + assert.equal(tokens[0].isNameMatch, true); + assert.equal(tokens[1].headword, '食べる'); + assert.equal(tokens[1].reading, 'たべた'); + assert.equal(tokens[1].startPos, 4); + 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: 'ひんどだけ' }], + deps, + logger, + ); + assert.deepEqual( + exact.map((value) => value.frequency).sort((a, b) => a - b), + [17, 120], + ); + 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( + [{ term: '頻度だけ', reading: null }], + deps, + logger, + ); + assert.deepEqual( + allReadings.map((value) => value.frequency).sort((a, b) => a - b), + [17, 120, 250], + ); + assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining'); + assert.equal((await targetSession.extensions.getAllExtensions()).length, 1); + assert.equal( + await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), + true, + ); + const directSettings = await parser.getYomitanSettingsFull(deps, logger); + assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765'); + const proxyState = await host.webContents.executeJavaScript( + "chrome.storage.local.get('subminerAnkiProxyUrl')", + ); + assert.equal(proxyState.subminerAnkiProxyUrl, null); + for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) { + assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true); + } + 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', + ); + clearTimeout(deadline); + app.exit(0); + }) + .catch((error) => { + console.error(error); + clearTimeout(deadline); + app.exit(1); + }); diff --git a/scripts/hachidori-anki-audio.test.ts b/scripts/hachidori-anki-audio.test.ts new file mode 100644 index 00000000..f6def7ba --- /dev/null +++ b/scripts/hachidori-anki-audio.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import '../vendor/hachidori/extension/reader-options.js'; +import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js'; +import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js'; + +async function mine({ proxy = true, audioFails = false } = {}) { + const events: string[] = []; + const filename = `hachidori_${'a'.repeat(64)}.mp3`; + let fields: Record = {}; + let initialAudio = ''; + let stored = false; + const gateway = createAnkiGateway({ + readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null), + fetch: async (_url: string, options: RequestInit) => { + const { action, params } = JSON.parse(String(options.body)); + events.push(action); + let result: unknown; + switch (action) { + case 'multi': + result = [ + { result: ['Default'], error: null }, + { result: ['Basic'], error: null }, + { result: ['Expression', 'ExpressionAudio'], error: null }, + ]; + break; + case 'canAddNotes': + result = [true]; + break; + case 'canAddNotesWithErrorDetail': + result = [{ canAdd: true, error: null }]; + break; + case 'getMediaFilesNames': + result = stored ? [filename] : []; + break; + case 'storeMediaFile': + stored = true; + result = filename; + break; + case 'addNote': + fields = { ...params.note.fields }; + initialAudio = fields.ExpressionAudio ?? ''; + if (initialAudio) assert.ok(stored, 'audio must exist before the note references it'); + result = 123; + break; + case 'notesInfo': + result = [ + { + noteId: 123, + fields: Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, { value }]), + ), + }, + ]; + break; + case 'updateNoteFields': + Object.assign(fields, params.note.fields); + result = null; + break; + default: + throw new Error(`Unexpected action: ${action}`); + } + return Response.json({ result, error: null }); + }, + }); + const service = createAnkiWorkerService({ + gateway, + readOptions: async () => ({ + anki: { + url: 'http://127.0.0.1:8766', + apiKey: '', + templates: [ + { + id: 'default', + name: 'Default', + deck: 'Default', + model: 'Basic', + tags: [], + fields: {}, + duplicateScope: 'model', + duplicateBehavior: 'prevent', + captureScreenshot: false, + fieldTemplates: { + Expression: { value: '{expression}', overwriteMode: 'overwrite' }, + ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' }, + }, + }, + ], + }, + audioSources: [ + { id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' }, + ], + mediaCapture: { enabled: false }, + }), + readDictionaries: async () => [], + engine: async () => ({ ready: true, loading: false, generation: 1 }), + offscreen: async (message: { type: string; audio?: string }) => { + if (message.type === 'hd_anki_audio') { + events.push('pronunciation'); + if (audioFails) throw new Error('No pronunciation available'); + return { filename, data: 'YXVkaW8=' }; + } + return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] }; + }, + duplicateIndex: { source: async () => null, recordWrite: async () => {} }, + }); + const status = await service.status(); + assert.equal(status.available, true, status.error); + const result = await service.submit({ + configKey: status.configKey, + generation: 1, + term: { expression: '猫', reading: 'ねこ' }, + }); + return { result, initialAudio, fields, events, filename }; +} + +test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => { + const value = await mine(); + assert.equal(value.result.state, 'added'); + assert.equal(value.initialAudio, `[sound:${value.filename}]`); + assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1); +}); + +test('direct Hachidori keeps deferred pronunciation', async () => { + const value = await mine({ proxy: false }); + assert.equal(value.initialAudio, ''); + assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`); +}); + +test('unavailable pronunciation remains a warning without a late audio write', async () => { + const value = await mine({ audioFails: true }); + assert.equal(value.result.state, 'added'); + assert.match(value.result.warnings.join(' '), /No pronunciation available/); + assert.equal(value.initialAudio, ''); + assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1); + assert.equal(value.events.includes('updateNoteFields'), false); +}); diff --git a/scripts/hachidori-host.test.ts b/scripts/hachidori-host.test.ts new file mode 100644 index 00000000..567fc3fc --- /dev/null +++ b/scripts/hachidori-host.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import test from 'node:test'; +import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title'; +import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js'; + +const bridge = readFileSync( + new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url), + 'utf8', +); + +function run(code: string) { + runInNewContext(`${bridge}\n${code}`, { + window: new EventTarget(), + EventTarget, + CustomEvent, + assert, + characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX, + KeyboardEvent: class { + constructor(_type: string, options: KeyboardEventInit = {}) { + Object.assign(this, options); + } + }, + }); +} + +test('Hachidori publishes popup state independently from successful lookups', () => { + run(` + const events = []; + for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) { + window.addEventListener(name, () => events.push(name)); + } + const attributes = new Map(); + const host = { setAttribute: (name, value) => attributes.set(name, value) }; + SubMinerHachidori.attention(host, true); + assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true'); + assert.equal(events.join(','), 'yomitan-popup-shown'); + SubMinerHachidori.lookup(); + SubMinerHachidori.attention(host, false); + assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false'); + assert.equal(events.join(','), 'yomitan-popup-shown,subminer-yomitan-lookup,yomitan-popup-hidden'); + `); +}); + +test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => { + run(` + const result = (id, matched, dictionary, options = {}) => ({ + matched, deinflected: matched, trace: [], preprocessorSteps: 0, + term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id, + }); + const character = characterPrefix + ' - Current show'; + const longer = result('longer', '花子さん', 'General'); + const normal = result('normal', '花子', 'General'); + const person = result('person', '花子', character); + const shorter = result('shorter', '花', character); + const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]); + assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter'); + const merged = result('merged', '花子', 'General'); + merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' }); + const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]); + assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general'); + assert.equal(merged.term.glossaries[0].dictionary, 'General'); + for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) { + const transformed = { ...person, ...change }; + assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal'); + } + const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } }; + assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal'); + const aliased = result('aliased', '花子', 'Imported character data'); + assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased'); + `); +}); + +test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => { + const requests: Array<{ url: string; body: string }> = []; + const proxy = 'http://127.0.0.1:8766'; + const gateway = createAnkiGateway({ + readSubminerProxyUrl: async () => proxy, + fetch: async (url: string, options: RequestInit) => { + assert.equal(typeof options.body, 'string'); + requests.push({ url, body: String(options.body) }); + return Response.json({ result: 123, error: null }); + }, + }); + const params = { + note: { fields: { Expression: '花子' } }, + subminerDuplicateNoteIds: [456], + subminerEnrich: true, + }; + await gateway.invoke('addNote', params, '', 1000, proxy); + await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765'); + assert.deepEqual( + requests.map((request) => JSON.parse(request.body).params), + [params, { note: params.note }], + ); + assert.equal(params.subminerEnrich, true); + const direct = createAnkiGateway({ + readSubminerProxyUrl: async () => null, + fetch: async (_url: string, options: RequestInit) => { + assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note }); + return Response.json({ result: null, error: null }); + }, + }); + await direct.invoke('updateNoteFields', params, '', 1000, proxy); +}); + +test('Hachidori routes host commands, validates keyboard input and disconnects', () => { + run(` + const calls = []; + const disconnect = SubMinerHachidori.connect({ + hide: () => calls.push('hide'), clear: () => calls.push('clear'), + action: name => calls.push(name), cycleAudio: direction => calls.push(direction), + scroll: (x, y) => calls.push(x + ':' + y), + keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey), + }); + const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail})); + send(null); + send({ type: 'forwardKeyDown', key: 12, modifiers: [] }); + send({ type: 'mineSelected' }); + send({ type: 'playCurrentAudio' }); + send({ type: 'scanSelectedText' }); + send({ type: 'cycleAudioSource', direction: -1 }); + send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 }); + send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] }); + send({ type: 'setVisible', visible: false }); + send({ type: 'clearActiveTextSource' }); + disconnect(); + send({ type: 'mineSelected' }); + assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear'); + `); +}); diff --git a/scripts/package-audit.cjs b/scripts/package-audit.cjs index 9dc15757..c6d693cd 100644 --- a/scripts/package-audit.cjs +++ b/scripts/package-audit.cjs @@ -27,6 +27,14 @@ const REQUIRED_APP_FILES = [ ]), ]; const REQUIRED_RESOURCES = [ + 'hachidori/manifest.json', + 'hachidori/settings.html', + 'hachidori/subminer-host.js', + 'hachidori/vendor/hoshidicts.wasm', + 'hachidori/vendor/hoshidicts-threaded.wasm', + 'hachidori/vendor/hoshidicts-threaded-idbfs.wasm', + 'hachidori/LICENSE', + 'hachidori/SOURCE.json', 'yomitan/manifest.json', 'yomitan/data/fonts/kanji-stroke-orders.ttf', 'yomitan/fonts/NotoSansJP-Regular.ttf', diff --git a/scripts/smoke-package.cjs b/scripts/smoke-package.cjs index e0d39a67..4fd0476d 100644 --- a/scripts/smoke-package.cjs +++ b/scripts/smoke-package.cjs @@ -5,6 +5,7 @@ const path = require('node:path'); const { createRequire } = require('node:module'); const assert = require('node:assert/strict'); const { once } = require('node:events'); +const http = require('node:http'); const resources = path.resolve(process.argv[2]); const archive = path.join(resources, 'app.asar'); @@ -49,12 +50,16 @@ async function smoke() { { allowFileAccess: true }, ); assert(extension.id, 'Yomitan extension failed to load'); + const hachidori = await session + .fromPartition('persist:hachidori') + .extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true }); + assert(hachidori.id, 'Hachidori extension failed to load'); const failedRequests = []; session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => { if (details.error !== 'net::ERR_ABORTED') failedRequests.push(`${details.url}: ${details.error}`); }); - for (const ui of ['renderer', 'settings', 'syncui', 'stats']) { + for (const ui of ['renderer', 'settings', 'syncui']) { const win = new BrowserWindow({ show: false, webPreferences: { @@ -63,22 +68,46 @@ async function smoke() { }, }); try { - await win.loadFile( - path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`), + await win.loadFile(path.join(archive, `dist/${ui}/index.html`)); + const loaded = await win.webContents.executeJavaScript( + `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, ); - if (ui !== 'stats') { - const loaded = await win.webContents.executeJavaScript( - `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, - ); - assert(loaded, `${ui}: shared Japanese font failed to load`); - } + assert(loaded, `${ui}: shared Japanese font failed to load`); } finally { win.destroy(); } } + // The stats dashboard uses HTTP for both assets and API requests in the app. + const { ImmersionTrackerService } = packagedRequire( + './dist/core/services/immersion-tracker-service.js', + ); + const { createStatsApp, startNodeHttpServer } = packagedRequire( + './dist/core/services/stats-server.js', + ); + const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') }); + const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker }; + let statsHttp; + const statsServer = await startNodeHttpServer( + createStatsApp(tracker, statsConfig), + statsConfig, + (listener) => (statsHttp = http.createServer(listener)), + ); + const statsWindow = new BrowserWindow({ show: false }); + try { + const url = `http://127.0.0.1:${statsHttp.address().port}`; + session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => { + if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`); + }); + await statsWindow.loadURL(url); + assert.equal((await fetch(`${url}/api/stats/overview`)).status, 200); + } finally { + statsWindow.destroy(); + await statsServer.close(); + tracker.destroy(); + } assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load'); console.log( - 'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.', + 'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.', ); } diff --git a/src/anki-integration.ts b/src/anki-integration.ts index b2efb88c..354ee53f 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -1793,6 +1793,9 @@ export class AnkiIntegration { request: Omit, ): Promise { if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) { + log.debug( + `[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`, + ); return { action: 'use-original' }; } return await this.mediaTimingReviewCallback({ diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index c10b1e9c..6721c884 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -441,6 +441,70 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot } }); +test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => { + const received: unknown[] = []; + let upstreamError: string | null = null; + const upstream = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + received.push(JSON.parse(Buffer.concat(chunks).toString())); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ result: null, error: upstreamError })); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const address = upstream.address(); + assert.ok(address && typeof address === 'object'); + const processed: number[] = []; + const added: number[] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => true, + processNewCard: async (id) => { + processed.push(id); + }, + recordCardsAdded: (count) => { + added.push(count); + }, + logInfo: () => {}, + logWarn: () => {}, + logError: () => {}, + }); + try { + proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` }); + await proxy.waitUntilReady(); + const server: unknown = Reflect.get(proxy, 'server'); + assert.ok(server instanceof http.Server); + const bound = server.address(); + assert.ok(bound && typeof bound === 'object'); + for (const [id, marked, error] of [ + [51, true, null], + [52, false, null], + [53, true, 'failed'], + ] satisfies Array<[number, boolean, string | null]>) { + upstreamError = error; + await fetch(`http://127.0.0.1:${bound.port}`, { + method: 'POST', + body: JSON.stringify({ + action: 'updateNoteFields', + version: 6, + params: { + note: { id, fields: { Expression: '猫' } }, + ...(marked ? { subminerEnrich: true } : {}), + }, + }), + }); + } + await waitForCondition(() => processed.length > 0); + assert.deepEqual(processed, [51]); + assert.deepEqual(added, []); + assert.equal(JSON.stringify(received).includes('subminerEnrich'), false); + } finally { + proxy.stop(); + upstream.close(); + await once(upstream, 'close'); + } +}); + test('proxy returns addNote response without waiting for background enrichment', async () => { const processed: number[] = []; let releaseProcessing: (() => void) | undefined; diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 1cae5f3a..60b02bee 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -224,6 +224,31 @@ export class AnkiConnectProxyServer { typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); + if (action === 'updateNoteFields') { + const params = requestJson.params; + if ( + !params || + typeof params !== 'object' || + !('subminerEnrich' in params) || + params.subminerEnrich !== true + ) + return; + const note = 'note' in params ? params.note : null; + if (!note || typeof note !== 'object' || !('id' in note)) return; + const response = this.tryParseJsonValue(responseBody); + // AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich. + if ( + !response || + typeof response !== 'object' || + !('error' in response) || + response.error !== null || + !('result' in response) || + response.result !== null + ) + return; + this.enqueueNotes(this.collectSingleResultId(note.id), false); + return; + } if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') { return; } @@ -293,7 +318,7 @@ export class AnkiConnectProxyServer { typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); - if (action !== 'addNote') { + if (action !== 'addNote' && action !== 'updateNoteFields') { return requestJson; } @@ -301,12 +326,17 @@ export class AnkiConnectProxyServer { requestJson.params && typeof requestJson.params === 'object' ? (requestJson.params as Record) : null; - if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) { + if ( + !params || + (!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') && + !Object.prototype.hasOwnProperty.call(params, 'subminerEnrich')) + ) { return requestJson; } const nextParams = { ...params }; delete nextParams.subminerDuplicateNoteIds; + delete nextParams.subminerEnrich; return { ...requestJson, params: nextParams, @@ -455,7 +485,7 @@ export class AnkiConnectProxyServer { }); } - private enqueueNotes(noteIds: number[]): void { + private enqueueNotes(noteIds: number[], recordAdded = true): void { let enqueuedCount = 0; const acceptedIds: number[] = []; for (const noteId of noteIds) { @@ -472,7 +502,7 @@ export class AnkiConnectProxyServer { return; } - this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds); + if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds); this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`); this.processQueue(); } diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index bb0ce052..ce23d71d 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -8,6 +8,7 @@ import { isStandaloneTexthookerCommand, parseArgs, shouldRunYomitanOnlyStartup, + shouldRunDictionarySettingsOnlyStartup, shouldStartApp, } from './args'; @@ -475,3 +476,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(setup), true); assert.equal(shouldStartApp(setup), true); }); + +test('Hachidori settings starts the app without overlay or mpv prerequisites', () => { + const args = parseArgs(['--hachidori']); + assert.equal(args.hachidori, true); + assert.equal(args.yomitan, false); + assert.equal(hasExplicitCommand(args), true); + assert.equal(shouldStartApp(args), true); + assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true); + assert.equal(shouldRunYomitanOnlyStartup(args), false); + assert.equal(commandNeedsOverlayRuntime(args), false); + assert.equal(commandNeedsOverlayStartupPrereqs(args), false); + assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false); + assert.equal( + shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])), + false, + ); + assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts index a16fa8d9..094c6795 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -13,6 +13,7 @@ export interface CliArgs { toggleVisibleOverlay: boolean; togglePrimarySubtitleBar: boolean; yomitan: boolean; + hachidori: boolean; settings: boolean; syncWindow: boolean; setup: boolean; @@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, setup: false, @@ -285,6 +287,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true; else if (arg === '--yomitan') args.yomitan = true; + else if (arg === '--hachidori') args.hachidori = true; else if (arg === '--settings') args.settings = true; else if (arg === '--sync-window') args.syncWindow = true; else if (arg === '--setup') args.setup = true; @@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.yomitan || + args.hachidori || args.settings || args.syncWindow || args.setup || @@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleVisibleOverlay && !args.togglePrimarySubtitleBar && !args.yomitan && + !args.hachidori && !args.settings && !args.syncWindow && !args.setup && @@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.yomitan || + args.hachidori || args.settings || args.syncWindow || args.setup || @@ -769,8 +775,12 @@ export function shouldStartApp(args: CliArgs): boolean { } export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { + return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args); +} + +export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean { return ( - args.yomitan && + (args.yomitan || args.hachidori) && !args.background && !args.start && !args.stop && diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 7e46f330..36b1a9a8 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => { assert.match(output, /--setup\s+Open first-run setup window/); assert.match(output, /--settings\s+Open SubMiner settings window/); assert.match(output, /--yomitan\s+Open Yomitan settings window/); + assert.match(output, /--hachidori\s+Open Hachidori settings window/); assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/); assert.match(output, /--anilist-status/); assert.match(output, /--anilist-retry-queue/); diff --git a/src/cli/help.ts b/src/cli/help.ts index cb0e1e47..d1c89ec4 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -25,6 +25,7 @@ ${B}Overlay${R} --show-visible-overlay Show subtitle overlay --hide-visible-overlay Hide subtitle overlay --yomitan Open Yomitan settings window + --hachidori Open Hachidori settings window --settings Open SubMiner settings window --setup Open first-run setup window --auto-start-overlay Auto-hide mpv subs, show overlay on connect diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 122119c0..c76c962f 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -22,6 +22,7 @@ export type { } from './definitions/shared'; const { + dictionaryBackend, subtitlePosition, keybindings, websocket, @@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG; export const DEFAULT_CONFIG: ResolvedConfig = { subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG }, + dictionaryBackend, subtitlePosition, keybindings, websocket, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 2c2d8c2b..15229e37 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config'; export const CORE_DEFAULT_CONFIG: Pick< ResolvedConfig, + | 'dictionaryBackend' | 'subtitlePosition' | 'keybindings' | 'websocket' @@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick< | 'notifications' | 'auto_start_overlay' > = { + dictionaryBackend: 'yomitan', subtitlePosition: { yPercent: 10 }, keybindings: [], websocket: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 8803b386..5d091676 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry( ] as const; return [ + { + path: 'dictionaryBackend', + kind: 'enum', + enumValues: ['yomitan', 'hachidori'], + defaultValue: defaultConfig.dictionaryBackend, + description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.', + }, { path: 'logging.level', kind: 'enum', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index d7aa3c89..c9322a11 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -1,6 +1,14 @@ import { ConfigTemplateSection } from './shared'; const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ + { + title: 'Dictionary Backend', + description: ['Select the dictionary lookup backend: yomitan or hachidori.'], + notes: [ + 'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.', + ], + key: 'dictionaryBackend', + }, { title: 'Japanese Subtitle Generation', description: [ diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 4218f668..51567497 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; + if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') { + resolved.dictionaryBackend = src.dictionaryBackend; + } else if (src.dictionaryBackend !== undefined) { + warn( + 'dictionaryBackend', + src.dictionaryBackend, + resolved.dictionaryBackend, + "Expected 'yomitan' or 'hachidori'.", + ); + } + if (isObject(src.texthooker)) { const launchAtStartup = asBoolean(src.texthooker.launchAtStartup); if (launchAtStartup !== undefined) { diff --git a/src/config/resolve/dictionary-backend.test.ts b/src/config/resolve/dictionary-backend.test.ts new file mode 100644 index 00000000..b23307dc --- /dev/null +++ b/src/config/resolve/dictionary-backend.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resolveConfig } from '../resolve'; +import { buildConfigSettingsRegistry } from '../settings/registry'; +import { createResolveContext } from './context'; +import { applyCoreDomainConfig } from './core-domains'; + +test('dictionary backend defaults to Yomitan and accepts Hachidori', () => { + assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan'); + const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' }); + assert.equal(resolved.dictionaryBackend, 'hachidori'); + assert.deepEqual(warnings, []); + const field = buildConfigSettingsRegistry(resolved).find( + (entry) => entry.configPath === 'dictionaryBackend', + ); + assert.equal(field?.restartBehavior, 'restart'); + assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']); + assert.equal(field?.category, 'integrations'); +}); + +test('unknown dictionary backend values warn and preserve the default', () => { + for (const dictionaryBackend of ['unknown', '', null, true, {}]) { + const { context, warnings } = createResolveContext({}); + context.src.dictionaryBackend = dictionaryBackend; + applyCoreDomainConfig(context); + assert.equal(context.resolved.dictionaryBackend, 'yomitan'); + assert.equal(warnings.length, 1); + assert.equal(warnings[0]?.path, 'dictionaryBackend'); + } +}); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 458f7468..ea5d8f7a 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -152,6 +152,7 @@ const SECTION_ORDER = new Map( 'Discord Rich Presence', 'Jellyfin', 'Texthooker', + 'Dictionary Lookup', 'Yomitan', 'Stats dashboard', 'Startup warmups', @@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record = { 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', - 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', - 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup', + 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup', + 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode', 'subtitleStyle.css': 'CSS Declarations', @@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record = { 'subtitleSidebar.css': 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', 'subtitleStyle.primaryVisibleOnYomitanPopup': - 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.', + 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.', 'websocket.enabled': 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', 'discordPresence.updateIntervalMs': @@ -340,6 +341,9 @@ function humanizePath(path: string): string { } function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } { + if (path === 'dictionaryBackend') { + return { category: 'integrations', section: 'Dictionary Lookup' }; + } if ( path === 'subtitleStyle.autoPauseVideoOnHover' || path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' || diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 5b17bfc5..558f4af8 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, setup: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index b9c5e422..b11a6688 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -20,6 +20,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, setup: false, @@ -133,6 +134,9 @@ function createDeps(overrides: Partial = {}) { togglePrimarySubtitleBar: () => { calls.push('togglePrimarySubtitleBar'); }, + openHachidoriSettingsDelayed: (delayMs) => { + calls.push(`openHachidoriSettingsDelayed:${delayMs}`); + }, openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, @@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis }, ui: { openFirstRunSetup: () => {}, + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, @@ -1133,3 +1138,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async ( assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom'))); assert.ok(calls.includes('stopApp')); }); + +for (const source of ['initial', 'second-instance'] as const) { + test(`Hachidori settings command opens its own settings on ${source} invocation`, () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ hachidori: true }), source, deps); + assert.ok(calls.includes('openHachidoriSettingsDelayed:1000')); + assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false); + assert.equal(calls.includes('initializeOverlayRuntime'), false); + assert.equal(calls.includes('connectMpvClient'), false); + }); +} diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index ea6b7a35..90fd1f88 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -43,6 +43,7 @@ export interface CliCommandServiceDeps { togglePrimarySubtitleBar: () => void; openFirstRunSetup: (force?: boolean) => void; openYomitanSettingsDelayed: (delayMs: number) => void; + openHachidoriSettingsDelayed: (delayMs: number) => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; setVisibleOverlayVisible: (visible: boolean) => void; @@ -170,6 +171,7 @@ interface MiningCliRuntime { interface UiCliRuntime { openFirstRunSetup: (force?: boolean) => void; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; @@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime( options.ui.openYomitanSettings(); }, delayMs); }, + openHachidoriSettingsDelayed: (delayMs) => { + options.schedule(() => { + options.ui.openHachidoriSettings(); + }, delayMs); + }, openConfigSettingsWindow: options.ui.openConfigSettingsWindow, openSyncUiWindow: options.ui.openSyncUiWindow, setVisibleOverlayVisible: options.overlay.setVisible, @@ -425,6 +432,8 @@ export function handleCliCommand( deps.logDebug('Opened first-run setup flow.'); } else if (args.yomitan) { deps.openYomitanSettingsDelayed(1000); + } else if (args.hachidori) { + deps.openHachidoriSettingsDelayed(1000); } else if (args.settings) { deps.openConfigSettingsWindow(); } else if (args.syncWindow) { diff --git a/src/core/services/hachidori-extension.test.ts b/src/core/services/hachidori-extension.test.ts new file mode 100644 index 00000000..10b71887 --- /dev/null +++ b/src/core/services/hachidori-extension.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveHachidoriExtensionPath } from './hachidori-extension'; +import { ensureExtensionCopyAsync } from './yomitan-extension-copy'; + +test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => { + const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' }; + assert.equal( + resolveHachidoriExtensionPath({ + ...options, + exists: (p) => p === '/app/build/hachidori/manifest.json', + }), + '/app/build/hachidori', + ); + assert.equal( + resolveHachidoriExtensionPath({ + ...options, + exists: (p) => p === '/resources/hachidori/manifest.json', + }), + '/resources/hachidori', + ); + assert.throws( + () => resolveHachidoriExtensionPath({ ...options, exists: () => false }), + /build:hachidori/, + ); +}); + +test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-')); + try { + const source = path.join(root, 'source'); + const profile = path.join(root, 'profile'); + fs.mkdirSync(source); + fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}'); + fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true }); + fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved'); + const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters< + typeof ensureExtensionCopyAsync + >[2]; + const copy = await ensureExtensionCopyAsync(source, profile, options); + assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori')); + assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false); + fs.writeFileSync(path.join(source, 'bridge.js'), 'updated'); + assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true); + assert.equal( + fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'), + 'preserved', + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/hachidori-extension.ts b/src/core/services/hachidori-extension.ts new file mode 100644 index 00000000..5fde9c7d --- /dev/null +++ b/src/core/services/hachidori-extension.ts @@ -0,0 +1,59 @@ +import electron from 'electron'; +import type { Extension, Session } from 'electron'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import { ensureExtensionCopyAsync } from './yomitan-extension-copy'; +import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge'; + +export function getHachidoriSession(): Session { + return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION); +} + +export function resolveHachidoriExtensionPath(options: { + moduleDir: string; + resourcesPath: string; + exists?: (candidate: string) => boolean; +}): string { + const candidates = [ + path.resolve(options.moduleDir, '../../../build/hachidori'), + path.join(options.resourcesPath, 'hachidori'), + ]; + const found = candidates.find((candidate) => + (options.exists ?? existsSync)(path.join(candidate, 'manifest.json')), + ); + if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.'); + return found; +} + +/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */ +export function createHachidoriExtensionRuntime(userDataPath: string) { + let extension: Extension | null = null; + let loading: Promise | null = null; + return { + getSession: getHachidoriSession, + ensureLoaded(): Promise { + if (extension) return Promise.resolve(extension); + if (loading) return loading; + loading = (async () => { + const source = resolveHachidoriExtensionPath({ + moduleDir: __dirname, + resourcesPath: process.resourcesPath, + }); + const copy = await ensureExtensionCopyAsync(source, userDataPath, { + extensionName: 'hachidori', + }); + const session = getHachidoriSession(); + // Electron can reuse an old extension worker after its files change. + // Drop worker registrations before loading, preserving dictionaries and settings. + await session.clearStorageData({ storages: ['serviceworkers'] }); + extension = await session.extensions.loadExtension(copy.targetDir, { + allowFileAccess: true, + }); + return extension; + })().finally(() => { + loading = null; + }); + return loading; + }, + }; +} diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 787a15fe..db027193 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, setup: false, diff --git a/src/core/services/tokenizer/hachidori-anki-settings.test.ts b/src/core/services/tokenizer/hachidori-anki-settings.test.ts new file mode 100644 index 00000000..119c0e41 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-anki-settings.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import vm from 'node:vm'; +import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings'; + +const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension'); +const config = { + tags: ['SubMiner', 'Japanese'], + fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' }, +}; +const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image']; + +async function harness( + anki: unknown = {}, + models: Record = { Japanese: modelFields }, +) { + const templates: unknown = await import( + pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href + ); + const setup: unknown = await import( + pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href + ); + const context = vm.createContext({ + __templates: templates, + __setup: setup, + __initialAnki: anki, + __models: models, + structuredClone, + URL, + }); + vm.runInContext('globalThis.window = globalThis', context); + vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context); + vm.runInContext( + ` + let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 }; + let writes = 0, online = true, race = false, proxy = null; + const readOptions = async () => structuredClone(options); + globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; }; + const send = async (type, request, target) => { + if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request'); + if (race) { + race = false; + options.anki.templates[0].deck = options.anki.deck = 'User edit'; + options.revision++; + } + if (request.baseRevision !== options.revision) throw Error('conflict'); + options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 }; + writes++; + }; + const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({ + connected: online, model, models: Object.keys(__models), decks: ['Mining'], + fields: __models[model] || [], errors: online ? [] : ['offline'], + }) }) }; + `, + context, + ); + await vm.runInContext( + HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates') + .replace("await import('./anki-setup.js')", '__setup') + .replace("await import('./anki.js')", '__gateway'), + context, + ); + const run = async (script: string): Promise => + structuredClone(await vm.runInContext(script, context)); + const sync = () => + run( + `__subminerSyncAnkiSettings(${JSON.stringify({ + server: 'http://127.0.0.1:8766', + deck: 'Mining', + forceOverride: true, + hints: buildHachidoriAnkiHints(config), + })})`, + ); + return { run, sync }; +} + +test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => { + const h = await harness(); + assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false }); + assert.deepEqual( + await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'), + ['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags], + ); + assert.deepEqual( + await h.run( + 'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))', + ), + { + Term: '{expression}', + Reading: '{reading}', + Definition: '{definition}', + Context: '{sentence}', + Pronunciation: '{audio}', + Image: '{screenshot}', + }, + ); + await h.sync(); + assert.equal(await h.run('writes'), 1); +}); + +test('preserves custom templates, tags, intentional blank fields and additional templates', async () => { + const h = await harness({ + templates: [ + { + id: 'default', + name: 'Custom', + deck: 'Own deck', + model: 'Japanese', + tags: ['own'], + fieldTemplates: { + Term: { value: '{reading}', overwriteMode: 'overwrite' }, + Context: { value: '', overwriteMode: 'coalesce' }, + }, + }, + { id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] }, + ], + }); + const before = await h.run('options.anki.templates'); + await h.sync(); + assert.deepEqual(await h.run('options.anki.templates'), before); +}); + +test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => { + const h = await harness({}, { Japanese: modelFields, Second: modelFields }); + await h.run('online = false'); + assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true }); + assert.equal(await h.run('options.anki.deck'), 'Mining'); + await h.run('online = true'); + await h.sync(); + assert.equal(await h.run('options.anki.model'), ''); + await h.run('delete __models.Second'); + await h.sync(); + assert.equal(await h.run('options.anki.model'), 'Japanese'); +}); + +test('fills missing basic mappings only with fields belonging to the selected model', async () => { + const h = await harness( + { model: 'Japanese', fields: { expression: 'Reading' } }, + { Japanese: ['Term', 'Reading', 'Context'] }, + ); + await h.sync(); + assert.deepEqual( + await h.run( + '[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]', + ), + ['Reading', 'Context', '', null], + ); +}); + +test('re-reads concurrent settings edits before retrying its revisioned write', async () => { + const h = await harness(); + await h.run('race = true'); + await h.sync(); + assert.equal(await h.run('options.anki.deck'), 'User edit'); + assert.equal(await h.run('options.anki.model'), 'Japanese'); +}); + +test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => { + const hints = buildHachidoriAnkiHints({ + fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' }, + }); + assert.equal(hints.fields.audio, 'WordAudio'); + assert.equal('captureAudio' in hints.fields, false); +}); diff --git a/src/core/services/tokenizer/hachidori-anki-settings.ts b/src/core/services/tokenizer/hachidori-anki-settings.ts new file mode 100644 index 00000000..a0b84bb2 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-anki-settings.ts @@ -0,0 +1,120 @@ +import type { AnkiConnectConfig } from '../../../types'; + +// Only settings with the same meaning in both apps cross this boundary. +export function buildHachidoriAnkiHints(config: AnkiConnectConfig) { + return { + tags: config.tags, + fields: { + expression: config.fields?.word?.trim(), + audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(), + sentence: config.fields?.sentence?.trim(), + screenshot: config.fields?.image?.trim(), + }, + model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined, + family: config.isLapis?.enabled + ? 'lapis' + : config.isKiku?.enabled + ? 'kiku' + : config.isSenren?.enabled + ? 'senren' + : undefined, + }; +} + +// Installed inside the hidden Hachidori settings page. Uses local option writes +// even when dictionaries are remote or their host is offline. +export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw` + globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => { + const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null); + const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js'); + const { ankiSetupFamily } = await import('./anki-setup.js'); + const { createAnkiGateway } = await import('./anki.js'); + const gateway = createAnkiGateway({ timeoutMs: 2000 }); + const discoveries = new Map(); + const discover = async (anki, model) => { + const key = JSON.stringify([anki.url, anki.apiKey, model]); + if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model })); + return discoveries.get(key); + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const options = await readOptions(); + let anki = options.anki; + const canReplaceServer = forceOverride || !anki.url || anki.url === server + || anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy; + if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' }; + anki = { ...anki, url: server }; + const first = anki.templates[0]; + if (!first) return { updated: false, matched: false, reason: 'no-templates' }; + let template = { ...first, fields: { ...first.fields } }; + const pristine = !first.model && first.fieldTemplates === null + && Object.values(first.fields).every(value => !value); + if (deck && (!first.deck || (pristine && first.deck === 'Default'))) template.deck = deck; + if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) { + template.tags = [...hints.tags]; + } + let pending = false; + // Advanced templates include intentionally blank fields. Preserve them + // as a unit rather than replacing them with inferred mappings. + if (hints && first.fieldTemplates === null) { + let discovery = await discover(anki, template.model || hints.model || ''); + if (!discovery.connected) { + pending = true; + } else { + if (!template.model) { + const candidates = hints.model + ? discovery.models.filter(model => model === hints.model) + : discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family); + const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean); + const matches = []; + // Require a configured word and sentence field for an inferred + // model. Never choose the first of several compatible note types. + if (anchors.length === 2) for (const model of candidates) { + const result = await discover(anki, model); + if (!result.connected || result.errors.length) { pending = true; break; } + if (anchors.every(field => result.fields.includes(field))) matches.push(result); + } + if (!pending && matches.length === 1) { + discovery = matches[0]; + template.model = discovery.model; + } + } + if (template.model && discovery.model === template.model && discovery.fields.length) { + const fields = discovery.fields; + for (const [semantic, field] of Object.entries(hints.fields)) { + if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field; + } + if (Object.values(first.fields).every(value => !value)) { + const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic'); + const configured = resolveAnkiTemplates(template, fields).templates; + const markers = new Set(Object.entries(template.fields) + .filter(([, value]) => value).map(([key]) => '{' + key + '}')); + for (const row of Object.values(preset.fieldTemplates)) { + for (const marker of markers) row.value = row.value.replaceAll(marker, ''); + } + for (const [field, row] of Object.entries(configured)) { + if (row.value) preset.fieldTemplates[field] = row; + } + template = preset; + } + } + } + } + // Hachidori retains a compatibility projection of its first template. + // Updating both prevents its normalizer from restoring stale values. + const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS + .map(key => [key, template[key]])); + anki = { ...anki, ...templateConfig, + templates: [template, ...anki.templates.slice(1)] }; + const changed = JSON.stringify(anki) !== JSON.stringify(options.anki); + try { + if (changed) await send('hd_options_write', { + baseRevision: options.revision, options: { anki }, + }, 'hoshidicts-worker'); + return { updated: changed, matched: !pending, pending }; + } catch (error) { + if (attempt > 0) throw error; + // Re-read after a concurrent settings save before filling anything. + } + } + }; +`; diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.test.ts b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts new file mode 100644 index 00000000..f1367b04 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts @@ -0,0 +1,446 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import * as vm from 'node:vm'; +import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge'; +import { + requestYomitanScanTokens, + requestYomitanParseResults, + requestYomitanTermFrequencies, + syncYomitanDefaultAnkiServer, + addYomitanNoteViaSearch, +} from './yomitan-parser-runtime'; +import { createDeps } from './yomitan-scan-test-harness'; +import { selectYomitanParseTokens } from './parser-selection-stage'; + +const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension'); +const characterDictionary = 'SubMiner Character Dictionary (AniList 1)'; + +function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') { + return { + matched, + deinflected: expression, + trace: [], + term: { + expression, + reading, + score: 0, + rules: 'v1', + glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }], + pitches: [], + frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }], + }, + }; +} + +async function createHarness(emptyLibrary = false) { + const messages: Array> = []; + let dictionaryRevision = 2; + let optionRevision = 3; + let anki = { + url: 'http://127.0.0.1:8765', + templates: [ + { id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} }, + { id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} }, + ], + }; + let dictionaries = [ + { id: 'terms', title: 'JMdict', enabled: true, revision: '1' }, + { id: 'names', title: characterDictionary, enabled: true, revision: '1' }, + { + id: 'frequency', + title: 'Frequency', + enabled: true, + revision: '1', + frequencyMode: 'rank-based', + }, + ]; + let duplicate = false; + let proxyUrl: unknown; + let loadingStatusReplies = 0; + let busyEngineReplies = 0; + const apiModule: unknown = await import( + pathToFileURL(path.join(extensionPath, 'api-host.js')).href + ); + const context = vm.createContext({ + __testApiModule: apiModule, + setTimeout, + crypto, + URL, + Blob, + Uint8Array, + atob, + chrome: { + storage: { + local: { + set: async (value: Record) => { + proxyUrl = value.subminerAnkiProxyUrl; + }, + get: async () => ({ + options: { anki, scanLength: 40, revision: optionRevision }, + subminerAnkiProxyUrl: proxyUrl, + }), + }, + }, + runtime: { + getManifest: () => ({ version: 'test' }), + sendMessage: async (message: Record) => { + messages.push(structuredClone(message)); + if (message.type === 'hd_status') { + const loading = loadingStatusReplies > 0; + if (loading) loadingStatusReplies -= 1; + return { ok: true, ready: !loading, loading }; + } + if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') { + busyEngineReplies -= 1; + return { ok: false, error: 'the dictionary engine is busy mutating' }; + } + switch (message.type) { + case 'hd_state_read': + return { + ok: true, + state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries }, + }; + case 'hd_lookup': { + const text = String(message.text); + const candidates = [ + lookupResult('食べた', '食べる', 'たべる'), + lookupResult('食べる', '食べる', 'たべる'), + lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary), + ]; + return { + ok: true, + generation: 1, + 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; + assert.ok(update && typeof update === 'object' && 'anki' in update); + const value = update.anki; + assert.ok( + value && + typeof value === 'object' && + 'url' in value && + typeof value.url === 'string', + ); + assert.ok('templates' in value && Array.isArray(value.templates)); + anki = { url: value.url, templates: value.templates }; + optionRevision += 1; + return { ok: true }; + } + case 'hd_apply_state': { + assert.equal(message.baseRevision, dictionaryRevision); + assert.ok(Array.isArray(message.dictionaries)); + dictionaries = message.dictionaries; + dictionaryRevision += 1; + return { ok: true }; + } + case 'hd_anki_status': + return { ok: true, configKey: 'configuration' }; + case 'hd_anki_preflight': + return { + ok: true, + canAdd: !duplicate, + state: duplicate ? 'duplicate' : 'addable', + noteIds: duplicate ? [15] : [], + }; + case 'hd_anki_submit': + return { ok: true, state: 'added', noteId: 19 }; + case 'hd_import': + return { ok: true, report: { success: true } }; + case 'hd_remove': + return { ok: true }; + default: + throw new Error('Unexpected native request: ' + String(message.type)); + } + }, + }, + }, + }); + vm.runInContext('globalThis.window = globalThis', context); + vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context); + const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace( + "await import('./api-host.js')", + '__testApiModule', + ).replace("await import('./reader-options.js')", 'Promise.resolve()'); + await vm.runInContext(script, context); + const run = async (code: string): Promise => + structuredClone(await vm.runInContext(code, context)); + const invoke = (action: string, params?: unknown) => + run(`new Promise((resolve, reject) => { + __subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => { + if (response.error) reject(new Error(response.error.message)); else resolve(response.result); + }); + })`); + return { + deps: createDeps(run), + run, + invoke, + messages, + anki: () => anki, + proxyUrl: () => proxyUrl, + setAnkiServer: (url: string) => { + anki = { ...anki, url }; + }, + setAnkiTemplates: (templates: typeof anki.templates) => { + anki = { ...anki, templates }; + }, + dictionaries: () => dictionaries, + disableDictionary: (id: string) => { + dictionaries = dictionaries.map((entry) => + entry.id === id ? { ...entry, enabled: false } : entry, + ); + dictionaryRevision += 1; + }, + setDuplicate: () => { + duplicate = true; + }, + changeRevision: () => { + optionRevision += 1; + }, + setLoadingStatusReplies: (count: number) => { + loadingStatusReplies = count; + }, + setBusyEngineReplies: (count: number) => { + busyEngineReplies = count; + }, + }; +} + +test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => { + const harness = await createHarness(); + const tokens = await requestYomitanScanTokens( + 'ミナト 食べた', + harness.deps, + { error: assert.fail }, + { + includeNameMatchMetadata: true, + currentCharacterDictionaryMediaId: 1, + }, + ); + assert.ok(tokens); + assert.equal(tokens[0]?.surface, 'ミナト'); + assert.equal(tokens[0]?.isNameMatch, true); + assert.equal(tokens[1]?.surface, '食べた'); + assert.equal(tokens[1]?.headword, '食べる'); + assert.equal(tokens[1]?.startPos, 4); + assert.equal(tokens[1]?.endPos, 7); + assert.equal(tokens[1]?.frequencyRank, 42); + assert.deepEqual(tokens[1]?.wordClasses, ['v1']); + const frequencies = await requestYomitanTermFrequencies( + [{ term: '食べる', reading: 'たべる' }], + harness.deps, + { error: assert.fail }, + ); + assert.equal(frequencies[0]?.frequency, 42); + assert.equal(frequencies[0]?.dictionary, 'Frequency'); +}); + +test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => { + const harness = await createHarness(); + const synced = await syncYomitanDefaultAnkiServer( + 'http://127.0.0.1:8766', + harness.deps, + { error: assert.fail }, + { deck: 'Mining' }, + ); + assert.equal(synced, true); + assert.equal(harness.anki().url, 'http://127.0.0.1:8766'); + assert.deepEqual( + harness.anki().templates.map((template) => template.deck), + ['Mining', 'Mining'], + ); + assert.equal(harness.proxyUrl(), null); + assert.equal( + await syncYomitanDefaultAnkiServer( + 'http://127.0.0.1:8766', + harness.deps, + { error: assert.fail }, + { forceOverride: true }, + ), + true, + ); + assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766'); + await assert.rejects( + harness.invoke('setAllSettings', { + value: { + hachidoriRevisions: { dictionaries: 99, options: 99 }, + profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }], + }, + }), + /settings changed/, + ); +}); + +test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => { + const harness = await createHarness(); + const projected = (await harness.invoke('optionsGetFull')) as { + profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>; + }; + const jmdict = projected.profiles[0]!.options.dictionaries.find( + (entry) => entry.name === 'JMdict', + ); + jmdict!.enabled = false; + // Hachidori changed both revisions after SubMiner read them. + harness.disableDictionary('names'); + harness.setAnkiServer('http://192.168.1.10:8765'); + harness.changeRevision(); + assert.equal(await harness.invoke('setAllSettings', { value: projected }), true); + assert.deepEqual( + harness.dictionaries().map((entry) => [entry.id, entry.enabled]), + [ + ['terms', false], + ['names', false], + ['frequency', true], + ], + ); + assert.equal(harness.anki().url, 'http://192.168.1.10:8765'); + assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0); +}); + +test('Hachidori settings writes survive an empty template list', async () => { + const harness = await createHarness(); + harness.setAnkiTemplates([]); + assert.equal( + await syncYomitanDefaultAnkiServer( + 'http://127.0.0.1:8766', + harness.deps, + { error: assert.fail }, + { + forceOverride: true, + }, + ), + true, + ); + assert.equal(harness.anki().url, 'http://127.0.0.1:8766'); +}); + +test('Hachidori initializes an empty library before the first dictionary import', async () => { + const harness = await createHarness(true); + assert.deepEqual(await harness.invoke('getDictionaryInfo'), []); + assert.equal( + await syncYomitanDefaultAnkiServer( + 'http://127.0.0.1:8766', + harness.deps, + { error: assert.fail }, + { forceOverride: true }, + ), + true, + ); +}); + +test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => { + const harness = await createHarness(); + const logger = { error: assert.fail }; + assert.equal( + await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, { + forceOverride: true, + }), + true, + ); + assert.equal(harness.anki().url, 'http://127.0.0.1:8766'); + assert.equal( + await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger), + true, + ); + assert.equal(harness.anki().url, 'http://127.0.0.1:8765'); + assert.equal(harness.proxyUrl(), null); +}); + +test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => { + const harness = await createHarness(); + const logger = { error: assert.fail }; + assert.equal( + await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, { + forceOverride: true, + }), + true, + ); + harness.setAnkiServer('http://192.168.1.10:8765'); + assert.equal( + await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger), + false, + ); + assert.equal(harness.anki().url, 'http://192.168.1.10:8765'); + assert.equal(harness.proxyUrl(), null); +}); + +test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => { + const harness = await createHarness(); + const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, { + error: assert.fail, + }); + const tokens = selectYomitanParseTokens(parsed, () => false, 'headword'); + assert.deepEqual( + tokens?.map((token) => ({ + surface: token.surface, + headword: token.headword, + reading: token.reading, + start: token.startPos, + })), + [ + { surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 }, + { surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 }, + ], + ); +}); + +test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => { + const harness = await createHarness(); + assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), { + noteId: 19, + duplicateNoteIds: [], + }); + harness.setDuplicate(); + assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), { + noteId: null, + duplicateNoteIds: [15], + }); + assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1); +}); + +test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => { + const harness = await createHarness(); + await harness.run( + "__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')", + ); + await harness.run( + `__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`, + ); + const imported = harness.messages.find((message) => message.type === 'hd_import'); + assert.equal(imported?.fileName, 'characters.zip'); + assert.match(String(imported?.blobUrl), /^blob:/); + const removed = harness.messages.find((message) => message.type === 'hd_remove'); + assert.equal(removed?.id, 'names'); +}); + +test('Hachidori waits for a loading engine and retries busy requests before answering', async () => { + const harness = await createHarness(); + harness.setLoadingStatusReplies(2); + harness.setBusyEngineReplies(1); + const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>; + assert.equal(dictionaries.length, 3); + const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length; + assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`); + const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail }); + assert.equal(tokens?.[0]?.headword, '食べる'); + assert.equal(tokens?.[0]?.frequencyRank, 42); +}); diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.ts b/src/core/services/tokenizer/hachidori-parser-bridge.ts new file mode 100644 index 00000000..20e78cbe --- /dev/null +++ b/src/core/services/tokenizer/hachidori-parser-bridge.ts @@ -0,0 +1,262 @@ +// Hachidori exposes its own storage and engine protocol. Adapt it only inside +// SubMiner's hidden extension windows so the shared scanner keeps its matching, +// character-name and frequency semantics without changing Hachidori's pages. +import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings'; + +export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori'; + +export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw` + (async () => { + if (globalThis.__subminerDictionarySendMessage) return; + const { createApiHost } = await import('./api-host.js'); + await import('./reader-options.js'); + const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => { + const reply = await chrome.runtime.sendMessage({ + target, type, requestId: crypto.randomUUID(), ...fields, + }); + if (!reply || reply.ok !== true) { + throw new Error(reply?.error || 'Hachidori returned an invalid response'); + } + return reply; + }; + // Hachidori reports an empty library while its dictionaries load and rejects + // engine requests during imports. Wait for a settled engine so SubMiner never + // caches an empty dictionary list or a token-less scan. + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + const waitForEngineReady = async () => { + const deadline = Date.now() + 120000; + for (;;) { + const reply = await send('hd_status').catch(() => null); + if (reply?.ready === true && reply.loading !== true) return; + if (Date.now() >= deadline) { + throw new Error(reply?.ready === true + ? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready'); + } + await sleep(250); + } + }; + const engine = async (type, fields = {}, target) => { + for (let attempt = 0; ; attempt += 1) { + await waitForEngineReady(); + try { + return await send(type, fields, target); + } catch (error) { + if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error; + await sleep(250); + } + } + }; + const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state + ?? { revision: 0, dictionaries: [] }; + const readOptions = async () => { + const stored = (await chrome.storage.local.get('options')).options; + return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 }; + }; + globalThis.__subminerSetAnkiProxyUrl = async url => { + const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl; + await chrome.storage.local.set({ subminerAnkiProxyUrl: url }); + return typeof previous === 'string' ? previous : null; + }; + ${HACHIDORI_ANKI_SETTINGS_SCRIPT} + const api = createApiHost({ + engine: fields => engine(fields.type, fields), + render: fields => send(fields.type, fields, 'hachidori-anki-render'), + readDictionaries: async () => (await readState()).dictionaries, + readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled), + version: chrome.runtime.getManifest().version, + }); + // Snapshot of the last projection SubMiner received, so a later write can + // be reduced to the fields SubMiner actually changed. + let lastProjection = null; + async function optionsGetFull() { + const [state, options] = await Promise.all([readState(), readOptions()]); + const revisions = { dictionaries: state.revision, options: options.revision }; + lastProjection = { + revisions, + enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])), + server: options.anki.url, + deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])), + }; + return { + profileCurrent: 0, + hachidoriRevisions: revisions, + profiles: [{ name: 'Hachidori', options: { + scanning: { length: options.scanLength }, + dictionaries: state.dictionaries.map((entry, index) => ({ + name: entry.title, alias: entry.displayName || entry.title, + id: index, enabled: entry.enabled !== false, + })), + anki: { + server: options.anki.url, + cardFormats: options.anki.templates.map(template => ({ + id: template.id, type: 'term', enabled: true, deck: template.deck, + })), + }, + } }], + }; + } + async function setAllSettings(value) { + const projected = value.profiles?.[0]?.options; + const revisions = value.hachidoriRevisions; + if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection'); + const base = lastProjection + && lastProjection.revisions.dictionaries === revisions.dictionaries + && lastProjection.revisions.options === revisions.options ? lastProjection : null; + const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true])); + const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck])); + for (let attempt = 0; ; attempt += 1) { + const [state, options] = await Promise.all([readState(), readOptions()]); + const current = state.revision === revisions.dictionaries && options.revision === revisions.options; + if (!current && !base) { + throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action'); + } + // Once Hachidori has moved on, apply only SubMiner's own changes on top + // of the newer settings instead of replaying the stale projection. + const enabledFor = title => enabledByTitle[title] === true; + const dictionaries = state.dictionaries.map(entry => + current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title) + ? { ...entry, enabled: enabledFor(entry.title) } : entry); + const templates = options.anki.templates.map(template => { + const deck = deckByFormat[template.id]; + return deck !== undefined && (current || base.deckByFormat[template.id] !== deck) + ? { ...template, deck } : template; + }); + const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url; + const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck }; + try { + if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) { + await engine('hd_apply_state', { baseRevision: state.revision, dictionaries }); + } + if (JSON.stringify(anki) !== JSON.stringify(options.anki)) { + await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker'); + } + return true; + } catch (error) { + // A write raced another Hachidori change; re-read once and reapply the delta. + if (attempt > 0 || !base) throw error; + } + } + } + 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 }); + } + } + } + } + } + return frequencies.filter(frequency => dictionaries.includes(frequency.dictionary)); + } + // Hachidori's public tokenize API emits display furigana without headwords. + // SubMiner's fallback requires one group per token and a dictionary form. + async function parseText({ text, scanLength }) { + const content = []; + let position = 0; + while (position < text.length) { + const rest = text.slice(position); + const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength }); + const result = reply.results[0]; + if (!result?.matched || !rest.startsWith(result.matched)) { + const character = String.fromCodePoint(rest.codePointAt(0)); + content.push([{ text: character, reading: '' }]); + position += character.length; + continue; + } + const term = result.term; + // Keep the inflected ending in the reading, just as the main scanner does. + let stem = 0; + while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1; + const ending = term.expression.slice(stem); + const reading = stem > 0 && term.reading.endsWith(ending) + ? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem) + : term.reading; + content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]); + position += result.matched.length; + } + return [{ source: 'scanning-parser', index: 0, content }]; + } + async function invoke(action, params) { + switch (action) { + case 'optionsGetFull': return optionsGetFull(); + case 'setAllSettings': return setAllSettings(params.value); + case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({ + title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode, + })); + case 'termsFind': { + const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] }); + return reply.results[0]; + } + case 'parseText': return parseText(params); + case 'getTermFrequencies': return getTermFrequencies(params); + default: throw new Error('Unsupported Hachidori parser action: ' + action); + } + } + globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => { + void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } })); + }; + async function importArchive(blob, fileName) { + const blobUrl = URL.createObjectURL(blob); + try { + const reply = await engine('hd_import', { blobUrl, fileName }); + if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed'); + } finally { + URL.revokeObjectURL(blobUrl); + } + } + globalThis.__subminerYomitanSettingsAutomation = { + ready: true, + async importDictionaryArchiveUrl(url) { + const response = await fetch(url); + if (!response.ok) throw new Error('Could not read the dictionary archive'); + await importArchive(await response.blob(), 'subminer-dictionary.zip'); + }, + async importDictionaryArchiveBase64(base64, fileName) { + const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0)); + await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName); + }, + async deleteDictionary(title) { + const dictionary = (await readState()).dictionaries.find(entry => entry.title === title); + if (dictionary) await engine('hd_remove', { id: dictionary.id, title }); + }, + }; + globalThis.__subminerAddNote = async word => { + const lookup = await engine('hd_lookup', { text: word, maxResults: 1 }); + const result = lookup.results[0]; + if (!result) return { noteId: null, duplicateNoteIds: [] }; + const status = await send('hd_anki_status', {}, 'hachidori-anki'); + const request = { + ...result, generation: lookup.generation, sentence: word, searchQuery: word, + matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '', + configKey: status.configKey, + captureUnavailable: ['screenshot', 'animation', 'audio'], + }; + const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki'); + if (preflight.canAdd !== true) { + if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable'); + return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] }; + } + const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki'); + if (submitted.state === 'added' || submitted.state === 'updated') { + return { noteId: submitted.noteId, duplicateNoteIds: [] }; + } + if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] }; + throw new Error(submitted.error || 'Hachidori could not confirm the note write'); + }; + })(); +`; diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index f1bd3d5f..55b9e81f 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,7 +1,18 @@ import type { BrowserWindow, Extension, Session } from 'electron'; +import type { AnkiConnectConfig } from '../../../types'; +import { buildHachidoriAnkiHints } from './hachidori-anki-settings'; +import { + buildHachidoriSharingScript, + parseHachidoriHostStatus, + type HachidoriSharingRequest, +} from '../../../shared/hachidori-sharing'; import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +import { + HACHIDORI_PARSER_BRIDGE_SCRIPT, + HACHIDORI_SESSION_PARTITION, +} from './hachidori-parser-bridge'; import { selectYomitanParseTokens } from './parser-selection-stage'; import { buildYomitanScanCallScript, @@ -477,7 +488,7 @@ async function requestYomitanProfileMetadata( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -596,6 +607,18 @@ function logYomitanProfileDiagnostics( logger.info?.('Yomitan active profile dictionaries loaded.', details); } +function isHachidoriExtension(extension: Extension): boolean { + return extension.name === 'Hachidori'; +} + +// Without an explicit session, use the one the extension was loaded into: +// Hachidori lives in its own partition, Yomitan in the default session. +function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session { + return isHachidoriExtension(extension) + ? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION) + : electron.session.defaultSession; +} + async function ensureYomitanParserWindow( deps: YomitanParserRuntimeDeps, logger: LoggerLike, @@ -606,19 +629,20 @@ async function ensureYomitanParserWindow( return false; } - const currentWindow = deps.getYomitanParserWindow(); - if (currentWindow && !currentWindow.isDestroyed()) { - return true; - } - const existingInitPromise = deps.getYomitanParserInitPromise(); if (existingInitPromise) { return existingInitPromise; } + const currentWindow = deps.getYomitanParserWindow(); + if (currentWindow && !currentWindow.isDestroyed()) { + return true; + } + const initPromise = (async () => { - const { BrowserWindow, session } = electron; - const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; + const { BrowserWindow } = electron; + const yomitanSession = + deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt); const parserWindow = new BrowserWindow({ show: false, width: 800, @@ -649,11 +673,15 @@ async function ensureYomitanParserWindow( }); try { - await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`); + const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html'; + await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`); const readyPromise = deps.getYomitanParserReadyPromise(); if (readyPromise) { await readyPromise; } + if (isHachidoriExtension(yomitanExt)) { + await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true); + } // Eagerly install the scan runtime so the first subtitle line does not // pay the install round trip; failures fall back to the per-request // install-and-retry path. @@ -696,8 +724,8 @@ async function createYomitanExtensionWindow( return null; } - const { BrowserWindow, session } = electron; - const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession; + const { BrowserWindow } = electron; + const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt); const window = new BrowserWindow({ show: false, width: 1200, @@ -740,6 +768,10 @@ async function invokeYomitanSettingsAutomation( } try { + const extension = deps.getYomitanExt(); + if (extension && isHachidoriExtension(extension)) { + await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true); + } await settingsWindow.webContents.executeJavaScript( ` (async () => { @@ -892,7 +924,7 @@ export async function requestYomitanParseResults( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -922,7 +954,7 @@ export async function requestYomitanParseResults( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -1079,7 +1111,7 @@ async function fetchYomitanTermFrequencies( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -1122,7 +1154,7 @@ async function fetchYomitanTermFrequencies( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -1206,7 +1238,17 @@ function cacheFrequencyEntriesForPairs( const key = makeTermReadingCacheKey(pair.term, pair.reading); const exactEntries = groupedByPair.get(key); const termEntries = groupedByTerm.get(pair.term) ?? []; - frequencyCache.set(key, exactEntries ?? termEntries); + // Untagged frequency rows apply to every reading. A term-only query must + // retain all readings, rather than selecting only its untagged rows. + const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? []; + frequencyCache.set( + key, + pair.reading === null + ? termEntries + : exactEntries + ? [...exactEntries, ...untaggedEntries] + : termEntries, + ); } } @@ -1340,6 +1382,7 @@ export async function syncYomitanDefaultAnkiServer( options?: { forceOverride?: boolean; deck?: string; + ankiConfig?: AnkiConnectConfig; }, ): Promise { const normalizedTargetServer = serverUrl.trim(); @@ -1359,7 +1402,7 @@ export async function syncYomitanDefaultAnkiServer( (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -1379,6 +1422,16 @@ export async function syncYomitanDefaultAnkiServer( const targetServer = ${JSON.stringify(normalizedTargetServer)}; const targetDeck = ${JSON.stringify(normalizedTargetDeck)}; const forceOverride = ${forceOverride ? 'true' : 'false'}; + const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)}; + if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') { + return globalThis.__subminerSyncAnkiSettings({ + server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints, + }); + } + let previousManagedProxy = null; + if (typeof globalThis.__subminerSetAnkiProxyUrl === 'function') { + previousManagedProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? targetServer : null); + } const optionsFull = await invoke("optionsGetFull", undefined); const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : []; if (profiles.length === 0) { @@ -1405,7 +1458,8 @@ export async function syncYomitanDefaultAnkiServer( let changed = false; if (currentServer !== targetServer) { const canReplaceCurrent = - forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765"; + forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" || + (typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy); if (!canReplaceCurrent) { return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer }; } @@ -1455,6 +1509,12 @@ export async function syncYomitanDefaultAnkiServer( try { const result = await parserWindow.webContents.executeJavaScript(script, true); + if (isObject(result) && result.pending === true) { + logger.info?.( + 'Anki is unavailable; Hachidori field auto-population will retry when opened again', + ); + return false; + } const updated = typeof result === 'object' && result !== null && @@ -1564,7 +1624,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): (async () => { const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -1586,6 +1646,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): `; } +export async function requestHachidoriSharing( + request: HachidoriSharingRequest, + deps: YomitanParserRuntimeDeps, + logger: LoggerLike, +) { + const extension = deps.getYomitanExt(); + if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.'); + const ready = await ensureYomitanParserWindow(deps, logger); + const window = deps.getYomitanParserWindow(); + if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.'); + const reply: unknown = await window.webContents.executeJavaScript( + buildHachidoriSharingScript(request), + true, + ); + const status = parseHachidoriHostStatus(reply); + if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window); + return status; +} + async function invokeYomitanBackendAction( action: string, params: unknown, diff --git a/src/core/services/tokenizer/yomitan-scan-runtime-script.ts b/src/core/services/tokenizer/yomitan-scan-runtime-script.ts index f6c9ffeb..018e341a 100644 --- a/src/core/services/tokenizer/yomitan-scan-runtime-script.ts +++ b/src/core/services/tokenizer/yomitan-scan-runtime-script.ts @@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based'; // Bump whenever the install script below changes so already-loaded parser // windows re-install the new scan runtime instead of running the stale one. -export const YOMITAN_SCAN_RUNTIME_VERSION = 12; +export const YOMITAN_SCAN_RUNTIME_VERSION = 13; export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__'; export interface YomitanScanRequestParams { @@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw` } const invoke = (action, params) => new Promise((resolve, reject) => { - chrome.runtime.sendMessage({ action, params }, (response) => { + (globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; diff --git a/src/core/services/yomitan-extension-copy.ts b/src/core/services/yomitan-extension-copy.ts index aade8025..88be781d 100644 --- a/src/core/services/yomitan-extension-copy.ts +++ b/src/core/services/yomitan-extension-copy.ts @@ -16,6 +16,7 @@ type ExtensionCopyResult = { type ExtensionCopyOptions = { platform?: NodeJS.Platform; + extensionName?: 'yomitan' | 'hachidori'; }; const asyncExtensionCopyInFlight = new Map>(); @@ -156,7 +157,7 @@ export function ensureExtensionCopy( } const extensionsRoot = path.join(userDataPath, 'extensions'); - const targetDir = path.join(extensionsRoot, 'yomitan'); + const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan'); let shouldCopy = !fs.existsSync(targetDir); if (!shouldCopy) { @@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync( } const extensionsRoot = path.join(userDataPath, 'extensions'); - const targetDir = path.join(extensionsRoot, 'yomitan'); + const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan'); const inFlightKey = path.resolve(targetDir); const inFlight = asyncExtensionCopyInFlight.get(inFlightKey); if (inFlight) { diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index 8153c7dc..b316b3c3 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el const logger = createLogger('main:yomitan-settings'); export interface OpenYomitanSettingsWindowOptions { + backend?: 'yomitan' | 'hachidori'; yomitanExt: Extension | null; getExistingWindow: () => BrowserWindow | null; setWindow: (window: BrowserWindow | null) => void; @@ -23,6 +24,7 @@ type HyprlandSessionEnv = { export interface InstallYomitanSettingsCloseButtonOptions { platform?: NodeJS.Platform; env?: HyprlandSessionEnv; + backend?: 'yomitan' | 'hachidori'; } export function shouldInstallYomitanSettingsCloseButton( @@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate( ]; } -export function buildYomitanSettingsCloseButtonScript(): string { +export function buildYomitanSettingsCloseButtonScript( + backend: 'yomitan' | 'hachidori' = 'yomitan', +): string { return ` (() => { const buttonId = 'subminer-yomitan-settings-close'; @@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string { button.id = buttonId; button.type = 'button'; button.title = 'Close'; - button.setAttribute('aria-label', 'Close Yomitan settings'); + button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings'); button.textContent = '\\u00d7'; button.addEventListener('click', () => { window.close(); @@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton( return; } settingsWindow.webContents - .executeJavaScript(buildYomitanSettingsCloseButtonScript()) + .executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend)) .catch((error: Error) => { logger.warn('Failed to install Yomitan settings close button:', error.message); }); @@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti logger.info('Creating new settings window for extension:', options.yomitanExt.id); const settingsWindow = new ElectronBrowserWindow({ - title: 'Yomitan Settings', + title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings', width: 1200, height: 800, show: false, @@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti settingsWindow.webContents.on('did-finish-load', () => { logger.info('Settings page loaded successfully'); - installYomitanSettingsCloseButton(settingsWindow); + installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend }); }); setTimeout(() => { diff --git a/src/main.ts b/src/main.ts index 3ba0a4ac..61aea9b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime'; /* SubMiner - All-in-one sentence mining overlay Copyright (C) 2026 sudacode @@ -32,6 +33,14 @@ import { screen, } from 'electron'; import { applyControllerConfigUpdate } from './main/controller-config-update.js'; +import { + createHachidoriExtensionRuntime, + getHachidoriSession, +} from './core/services/hachidori-extension'; +import { + DICTIONARY_EXTERNAL_LINK_CHANNEL, + parseDictionaryExternalUrl, +} from './shared/dictionary-external-link'; import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; import { readMpvInputBindings } from './main/runtime/mpv-input-bindings'; import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; @@ -906,14 +915,34 @@ const { appState, appLifecycleApp, } = bootServices; +// Backend changes take effect on restart; windows and parser must share one session. +const activeDictionaryBackend = configService.getConfig().dictionaryBackend; +const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH); +let hachidoriSettingsWindow: BrowserWindow | null = null; +let inactiveYomitanExtension: Extension | null = null; +let inactiveYomitanSettingsWindow: BrowserWindow | null = null; +let inactiveYomitanLoad: Promise | null = null; let pendingSubtitleMiningContext: SubtitleMiningContext | null = null; const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); +ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => { + if ( + activeDictionaryBackend !== 'hachidori' || + event.senderFrame !== event.sender.mainFrame || + !overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender) + ) { + throw new Error('Dictionary links are only available from the active overlay'); + } + await shell.openExternal(parseDictionaryExternalUrl(value)); +}); + function getOverlayForegroundSeparateWindows(): BrowserWindow[] { return [ appState.configSettingsWindow, appState.syncUiWindow, appState.yomitanSettingsWindow, + hachidoriSettingsWindow, + inactiveYomitanSettingsWindow, appState.anilistSetupWindow, appState.jellyfinSetupWindow, appState.firstRunSetupWindow, @@ -1011,6 +1040,8 @@ const { } = statsServerRuntime; function requestAppQuit(): void { + destroyYomitanSettingsWindow(hachidoriSettingsWindow); + destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow); destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); appState.yomitanSettingsWindow = null; destroyStatsWindow(); @@ -1455,6 +1486,15 @@ const createCommandLineLauncherRuntimeOptions = () => ({ : undefined, }); const firstRunSetupService = createFirstRunSetupService({ + getDictionaryBackend: () => activeDictionaryBackend, + getHachidoriHostStatus: async () => { + await ensureYomitanExtensionLoaded(); + return requestHachidoriSharing( + { type: 'hd_sharing_status' }, + getYomitanParserRuntimeDeps(), + logger, + ); + }, platform: process.platform, configDir: CONFIG_DIR, getYomitanDictionaryCount: async () => { @@ -2277,6 +2317,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp if (appState.ankiIntegration) { appState.ankiIntegration.applyRuntimeConfigPatch(patch); } + if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) { + void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) => + logger.error('Failed to auto-populate Hachidori Anki settings', error), + ); + } }, invalidateTokenizationCache: () => { subtitleProcessingController.invalidateTokenizationCache(); @@ -3450,6 +3495,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return { configReady: snapshot.configReady, dictionaryCount: snapshot.dictionaryCount, + dictionaryBackend: snapshot.dictionaryBackend, + hachidoriHost: snapshot.hachidoriHost, canFinish: snapshot.canFinish, externalYomitanConfigured: snapshot.externalYomitanConfigured, pluginStatus: snapshot.pluginStatus, @@ -3465,6 +3512,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ buildSetupHtml: (model) => buildFirstRunSetupHtml(model), parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl), handleAction: async (submission: FirstRunSetupSubmission) => { + if ( + submission.action === 'link-hachidori-host' || + submission.action === 'unlink-hachidori-host' + ) { + try { + if (activeDictionaryBackend !== 'hachidori') + throw new Error('Select Hachidori and restart SubMiner before linking a host.'); + await ensureYomitanExtensionLoaded(); + const status = await requestHachidoriSharing( + submission.action === 'link-hachidori-host' + ? { type: 'hd_sharing_client_link', address: submission.address } + : { type: 'hd_sharing_client_unlink' }, + getYomitanParserRuntimeDeps(), + logger, + ); + firstRunSetupMessage = + status.kind === 'local' + ? 'Using dictionaries installed in SubMiner.' + : status.kind === 'connected' + ? `Linked to ${status.name}. Anki mining stays in SubMiner.` + : status.message; + } catch (error) { + firstRunSetupMessage = + error instanceof Error ? error.message : 'Could not update the dictionary host.'; + } + return; + } if (submission.action === 'remove-legacy-plugin') { const snapshot = await firstRunSetupService.removeLegacyMpvPlugin(); firstRunSetupMessage = snapshot.message; @@ -3505,8 +3579,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return; } if (submission.action === 'open-yomitan-settings') { - firstRunSetupMessage = openYomitanSettings() - ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' + firstRunSetupMessage = openDictionarySettings() + ? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.` : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; return; } @@ -5157,6 +5231,41 @@ function initializeOverlayRuntime(): void { } function openYomitanSettings(): boolean { + if (activeDictionaryBackend === 'hachidori') { + if (configService.getConfig().yomitan.externalProfilePath.trim()) { + logger.warn('Yomitan settings unavailable while using read-only external-profile mode.'); + return false; + } + inactiveYomitanLoad ??= inactiveYomitanExtension + ? Promise.resolve(inactiveYomitanExtension) + : loadYomitanExtensionCore({ + userDataPath: USER_DATA_PATH, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + setYomitanParserReadyPromise: () => {}, + setYomitanParserInitPromise: () => {}, + setYomitanExtension: (extension) => { + inactiveYomitanExtension = extension; + }, + setYomitanSession: () => {}, + }).finally(() => { + inactiveYomitanLoad = null; + }); + void ( + inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad + ) + .then((extension) => + openYomitanSettingsWindow({ + yomitanExt: extension, + getExistingWindow: () => inactiveYomitanSettingsWindow, + setWindow: (window) => { + inactiveYomitanSettingsWindow = window; + }, + }), + ) + .catch((error: unknown) => logger.error('Failed to open Yomitan settings', error)); + return true; + } if (yomitanProfilePolicy.isExternalReadOnlyMode()) { const message = 'Yomitan settings unavailable while using read-only external-profile mode.'; logger.warn( @@ -5169,6 +5278,39 @@ function openYomitanSettings(): boolean { return true; } +function openHachidoriSettings(): void { + void ( + activeDictionaryBackend === 'hachidori' + ? ensureYomitanExtensionLoaded() + : hachidoriExtensionRuntime.ensureLoaded() + ) + .then((extension) => + openYomitanSettingsWindow({ + backend: 'hachidori', + yomitanExt: extension, + yomitanSession: getHachidoriSession(), + getExistingWindow: () => hachidoriSettingsWindow, + setWindow: (window) => { + hachidoriSettingsWindow = window; + }, + onWindowClosed: () => { + if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) { + clearYomitanParserCachesForWindow(appState.yomitanParserWindow); + } + }, + }), + ) + .catch((error: unknown) => logger.error('Failed to open Hachidori settings', error)); +} + +function openDictionarySettings(): boolean { + if (activeDictionaryBackend === 'hachidori') { + openHachidoriSettings(); + return true; + } + return openYomitanSettings(); +} + const { exportLogsFromTray } = createLogExportTrayRuntime({ flushMpvLog: () => flushMpvLog(), logInfo: (message) => logger.info(message), @@ -5196,7 +5338,7 @@ const { getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), registerGlobalShortcutsCore, toggleVisibleOverlay: () => toggleVisibleOverlay(), - openYomitanSettings: () => openYomitanSettings(), + openYomitanSettings: () => openDictionarySettings(), isDev, getMainWindow: () => overlayManager.getMainWindow(), }), @@ -5687,7 +5829,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), - openYomitanSettings: () => openYomitanSettings(), + openYomitanSettings: () => openDictionarySettings(), // Overlay lookups carry no cue context of their own; fall back to snapshotting the // live mpv sub timings at lookup time so media generation clips the mined line even // when extraction finishes long after playback has moved on. @@ -6128,6 +6270,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ }, runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), + openHachidoriSettings: () => openHachidoriSettings(), openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), openSyncUiWindow: () => openSyncUiWindowHandler(), @@ -6303,7 +6446,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled), isOverlayVisible: (windowKind) => windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false, - getYomitanSession: () => appState.yomitanSession, + getYomitanSession: () => + activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession, tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), @@ -6391,6 +6535,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), showWindowsMpvLauncherSetup: () => process.platform === 'win32', + getDictionaryBackend: () => activeDictionaryBackend, + openHachidoriSettings: () => openHachidoriSettings(), openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), openSyncUiWindow: () => openSyncUiWindowHandler(), @@ -6435,12 +6581,21 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); const yomitanProfilePolicy = createYomitanProfilePolicy({ - externalProfilePath: configService.getConfig().yomitan.externalProfilePath, + externalProfilePath: + activeDictionaryBackend === 'yomitan' + ? configService.getConfig().yomitan.externalProfilePath + : '', logInfo: (message) => logger.info(message), }); const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; const yomitanExtensionRuntime = createYomitanExtensionRuntime({ - loadYomitanExtensionCore, + loadYomitanExtensionCore: async (deps) => { + if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps); + const extension = await hachidoriExtensionRuntime.ensureLoaded(); + deps.setYomitanExtension(extension); + deps.setYomitanSession(getHachidoriSession()); + return extension; + }, userDataPath: USER_DATA_PATH, externalProfilePath: configuredExternalYomitanProfilePath, getYomitanParserWindow: () => appState.yomitanParserWindow, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 2da17bb8..c663bf04 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext { runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; @@ -135,6 +136,7 @@ function createCliCommandDepsFromContext( ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, + openHachidoriSettings: context.openHachidoriSettings, openConfigSettingsWindow: context.openConfigSettingsWindow, openSyncUiWindow: context.openSyncUiWindow, cycleSecondarySubMode: context.cycleSecondarySubMode, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 90e10f21..d9a3398c 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams { ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; + openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings']; openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow']; openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow']; cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode']; @@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps( ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, + openHachidoriSettings: params.ui.openHachidoriSettings, openConfigSettingsWindow: params.ui.openConfigSettingsWindow, openSyncUiWindow: params.ui.openSyncUiWindow, cycleSecondarySubMode: params.ui.cycleSecondarySubMode, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 2190ad28..2cc5df13 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -18,6 +18,7 @@ type MockWindow = { contentReady: boolean; documentLoaded: boolean; loadCallbacks: Array<() => void>; + stopLoadingCallbacks: Array<() => void>; readyToShowCallbacks: Array<() => void>; }; @@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & { destroy: () => void; focus: () => void; emitDidFinishLoad: () => void; + emitDidStopLoading: () => void; emitReadyToShow: () => void; once: (event: 'ready-to-show', cb: () => void) => void; webContents: { @@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & { getURL: () => string; send: (channel: string, payload?: unknown) => void; isFocused: () => boolean; - once: (event: 'did-finish-load', cb: () => void) => void; + once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void; focus: () => void; }; } { @@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & { contentReady: true, documentLoaded: true, loadCallbacks: [], + stopLoadingCallbacks: [], readyToShowCallbacks: [], }; const window = { @@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & { callback(); } }, + emitDidStopLoading: () => { + state.loading = false; + const callbacks = state.stopLoadingCallbacks.splice(0); + for (const callback of callbacks) { + callback(); + } + }, emitReadyToShow: () => { const callbacks = state.readyToShowCallbacks.splice(0); for (const callback of callbacks) { @@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & { }, focused: false, isFocused: () => state.webContentsFocused, - once: (_event: 'did-finish-load', cb: () => void) => { + once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => { + if (event === 'did-stop-loading') { + state.stopLoadingCallbacks.push(cb); + return; + } state.loadCallbacks.push(cb); }, focus: () => { @@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for assert.deepEqual(window.sent, [['runtime-options:open']]); }); +test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => { + // Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire + // before it; only did-stop-loading sees the settled state. + const window = createMockWindow(); + window.loading = true; + window.url = ''; + window.contentReady = false; + window.documentLoaded = false; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => null, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow( + 'media-timing-review:open', + { reviewId: 'r1' }, + { + restoreOnModalClose: 'media-timing-review', + preferModalWindow: true, + }, + ), + true, + ); + assert.deepEqual(window.sent, []); + window.contentReady = true; + window.emitReadyToShow(); + window.url = 'file:///overlay/index.html?layer=modal'; + window.documentLoaded = true; + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, []); + window.emitDidStopLoading(); + assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]); +}); + test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => { const window = createMockWindow(); window.contentReady = false; diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 8244f521..fbe008f9 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService( if (!modalWindowPrimeListenersRegistered.has(modalWindow)) { modalWindowPrimeListenersRegistered.add(modalWindow); modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow)); + modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow)); modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow)); } return true; @@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService( // A hidden macOS panel may not emit ready-to-show until it is presented. The // renderer can safely receive IPC as soon as its document has finished loading. + // Electron still reports isLoading() inside did-finish-load (and ready-to-show can + // fire even earlier), so a window created for this send would drop the message + // without the did-stop-loading pass that follows once the load state settles. window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window))); + window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window))); window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window))); deliver(() => isWindowLoadedForIpc(window)); }; diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index f1b34c95..04e4c961 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => { runYoutubePlaybackFlow: async () => { calls.push('run-youtube-playback'); }, + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('config-settings'), openSyncUiWindow: () => {}, diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 0295bce7..a567b3dc 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; @@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, + openHachidoriSettings: deps.openHachidoriSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index ea79a223..d94b4b4c 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () = runUpdateCommand: async () => {}, runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index ab7c0b66..226a526c 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async () runYoutubePlaybackFlow: async () => { calls.push('run-youtube-playback'); }, + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettingsWindow: () => calls.push('open-config-settings'), openSyncUiWindow: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 8918873a..c62eeca0 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; @@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: () => deps.openYomitanSettings(), + openHachidoriSettings: () => deps.openHachidoriSettings(), openConfigSettingsWindow: () => deps.openConfigSettingsWindow(), openSyncUiWindow: () => deps.openSyncUiWindow(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index c81f9934..962d7419 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -56,6 +56,7 @@ function createDeps() { runUpdateCommand: async () => {}, runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index b625849f..f9fbcfc7 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = { runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; cycleSecondarySubMode: () => void; @@ -134,6 +135,7 @@ export function createCliCommandContext( runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, + openHachidoriSettings: deps.openHachidoriSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, cycleSecondarySubMode: deps.cycleSecondarySubMode, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 756ed9d4..6df7577e 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { runUpdateCommand: async () => {}, runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index ec7ce28d..0209b23b 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -6,6 +6,11 @@ import path from 'node:path'; import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service'; import type { CliArgs } from '../../cli/args'; import type { CommandLineLauncherSnapshot } from './command-line-launcher'; +import { + createDefaultSetupState, + getSetupStatePath, + readSetupState, +} from '../../shared/setup-state'; function withTempDir(fn: (dir: string) => Promise | void): Promise | void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-')); @@ -30,6 +35,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, setup: false, @@ -762,3 +768,115 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => { ]); }); }); + +test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => { + await withTempDir(async (configDir) => { + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + const yomitan = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 1, + detectPluginInstalled: () => false, + }); + assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed'); + let dictionaryCount = 0; + const hachidori = createFirstRunSetupService({ + configDir, + getDictionaryBackend: () => 'hachidori', + getYomitanDictionaryCount: async () => dictionaryCount, + isExternalYomitanConfigured: () => true, + detectPluginInstalled: () => false, + }); + const initial = await hachidori.ensureSetupStateInitialized(); + assert.equal(initial.dictionaryBackend, 'hachidori'); + assert.equal(initial.state.dictionaryBackend, 'hachidori'); + assert.equal(initial.canFinish, false); + assert.equal(initial.externalYomitanConfigured, false); + assert.equal(initial.state.status, 'incomplete'); + dictionaryCount = 1; + const completed = await hachidori.markSetupCompleted(); + assert.equal(completed.state.status, 'completed'); + assert.equal(completed.state.dictionaryBackend, 'hachidori'); + assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1); + assert.equal(hachidori.isSetupCompleted(), true); + assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']); + + // Switching back never repeats setup for a backend that already finished. + const yomitanAgain = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 1, + detectPluginInstalled: () => false, + }); + const restored = await yomitanAgain.ensureSetupStateInitialized(); + assert.equal(restored.state.status, 'completed'); + assert.equal(restored.state.dictionaryBackend, 'yomitan'); + assert.equal(yomitanAgain.isSetupCompleted(), true); + assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan'); + assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']); + }); +}); + +test('a legacy completed Yomitan state file survives a first Hachidori run', async () => { + await withTempDir(async (configDir) => { + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + fs.writeFileSync( + getSetupStatePath(configDir), + JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }), + ); + const hachidori = createFirstRunSetupService({ + configDir, + getDictionaryBackend: () => 'hachidori', + getYomitanDictionaryCount: async () => 0, + detectPluginInstalled: () => false, + }); + const initial = await hachidori.ensureSetupStateInitialized(); + assert.equal(initial.state.status, 'incomplete'); + const stored = readSetupState(getSetupStatePath(configDir)); + assert.equal(stored?.dictionaryBackend, 'hachidori'); + assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']); + const yomitan = createFirstRunSetupService({ + configDir, + getYomitanDictionaryCount: async () => 1, + detectPluginInstalled: () => false, + }); + assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed'); + }); +}); + +test('Hachidori setup gates on the linked host instead of local dictionaries', async () => { + await withTempDir(async (dir) => { + fs.writeFileSync(path.join(dir, 'config.json'), '{}'); + let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = { + kind: 'connected', + address: 'ws://127.0.0.1:8771/link', + name: 'Docker', + dictionaryCount: 2, + }; + const service = createFirstRunSetupService({ + configDir: dir, + getDictionaryBackend: () => 'hachidori', + getHachidoriHostStatus: async () => host, + getYomitanDictionaryCount: async () => 7, + detectPluginInstalled: () => false, + }); + let snapshot = await service.getSetupStatus(); + assert.equal(snapshot.dictionaryCount, 2); + assert.equal(snapshot.canFinish, true); + assert.equal((await service.markSetupCompleted()).state.status, 'completed'); + host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' }; + snapshot = await service.ensureSetupStateInitialized(); + assert.equal(snapshot.canFinish, false); + assert.equal(snapshot.dictionaryCount, 0); + assert.equal(snapshot.state.status, 'incomplete'); + assert.notEqual((await service.markSetupCompleted()).state.status, 'completed'); + host = { + kind: 'connected', + address: 'ws://127.0.0.1:8771/link', + name: 'Docker', + dictionaryCount: 0, + }; + assert.equal((await service.getSetupStatus()).canFinish, false); + host = { kind: 'local' }; + assert.equal((await service.getSetupStatus()).dictionaryCount, 7); + assert.equal((await service.getSetupStatus()).canFinish, true); + }); +}); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 8a9f1241..0dfd107b 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; +import type { HachidoriHostStatus } from '../../shared/hachidori-sharing'; import { createDefaultSetupState, getDefaultConfigFilePaths, + getSetupStateDictionaryBackend, + hasCompletedSetupForBackend, getSetupStatePath, isSetupCompleted, readSetupState, @@ -11,6 +14,7 @@ import { type SetupState, } from '../../shared/setup-state'; import type { CliArgs } from '../../cli/args'; +import type { DictionaryBackend } from '../../types/config'; import type { InstalledFirstRunPluginCandidate, LegacyMpvPluginRemovalResult, @@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot { } export interface SetupStatusSnapshot { + dictionaryBackend: DictionaryBackend; + hachidoriHost?: HachidoriHostStatus; configReady: boolean; dictionaryCount: number; canFinish: boolean; @@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.togglePrimarySubtitleBar || args.launchMpv || args.yomitan || + args.hachidori || args.settings || args.show || args.hide || @@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap } export function getFirstRunSetupCompletionMessage(snapshot: { + dictionaryBackend?: DictionaryBackend; + hachidoriHost?: HachidoriHostStatus; configReady: boolean; dictionaryCount: number; externalYomitanConfigured: boolean; @@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: { if (!snapshot.configReady) { return 'Create or provide the config file before finishing setup.'; } + if ( + snapshot.hachidoriHost?.kind === 'disconnected' || + snapshot.hachidoriHost?.kind === 'unavailable' + ) { + return snapshot.hachidoriHost.message; + } + if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) { + return 'Install at least one dictionary on the linked Hachidori host, then refresh status.'; + } if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) { - return 'Install at least one Yomitan dictionary before finishing setup.'; + const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'; + return `Install at least one ${name} dictionary before finishing setup.`; } return null; } @@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: { async function resolveYomitanSetupStatus(deps: { configFilePaths: { jsoncPath: string; jsonPath: string }; getYomitanDictionaryCount: () => Promise; + getDictionaryBackend?: () => DictionaryBackend; + getHachidoriHostStatus?: () => Promise; isExternalYomitanConfigured?: () => boolean; }): Promise<{ configReady: boolean; dictionaryCount: number; externalYomitanConfigured: boolean; + hachidoriHost?: HachidoriHostStatus; }> { const configReady = fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath); - const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false; + const externalYomitanConfigured = + deps.getDictionaryBackend?.() !== 'hachidori' && + (deps.isExternalYomitanConfigured?.() ?? false); + + const hachidoriHost = + deps.getDictionaryBackend?.() === 'hachidori' + ? await deps.getHachidoriHostStatus?.().catch( + (error: unknown): HachidoriHostStatus => ({ + kind: 'unavailable', + message: error instanceof Error ? error.message : 'Hachidori is unavailable.', + }), + ) + : undefined; + if (hachidoriHost && hachidoriHost.kind !== 'local') { + return { + configReady, + externalYomitanConfigured: false, + hachidoriHost, + dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0, + }; + } if (configReady && externalYomitanConfigured) { return { @@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: { return { configReady, + hachidoriHost, dictionaryCount: await deps.getYomitanDictionaryCount(), externalYomitanConfigured, }; @@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: { platform?: NodeJS.Platform; configDir: string; getYomitanDictionaryCount: () => Promise; + getDictionaryBackend?: () => DictionaryBackend; + getHachidoriHostStatus?: () => Promise; isExternalYomitanConfigured?: () => boolean; detectPluginInstalled: () => boolean | Promise; detectLegacyMpvPluginCandidates?: () => @@ -283,8 +328,44 @@ export function createFirstRunSetupService(deps: { const isWindows = (deps.platform ?? process.platform) === 'win32'; let completed = false; - const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState(); + const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan'; + const readStoredState = (): SetupState => + readSetupState(setupStatePath) ?? createDefaultSetupState(); + // The file records one backend's status at a time. Project it onto the active + // backend: a backend that finished before stays completed, any other stays + // incomplete until its own dictionaries are ready. + const projectState = (stored: SetupState): SetupState => { + const backend = getDictionaryBackend(); + if (getSetupStateDictionaryBackend(stored) === backend) return stored; + const finishedBefore = hasCompletedSetupForBackend(stored, backend); + // Legacy files carry their completion only as the recorded status; keep it. + const storedBackend = getSetupStateDictionaryBackend(stored); + const completedDictionaryBackends = [ + ...new Set([ + ...(stored.completedDictionaryBackends ?? []), + ...(stored.status === 'completed' ? [storedBackend] : []), + ]), + ]; + return { + ...stored, + dictionaryBackend: backend, + completedDictionaryBackends, + status: finishedBefore ? 'completed' : 'incomplete', + completedAt: finishedBefore ? stored.completedAt : null, + completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null, + yomitanSetupMode: finishedBefore ? 'internal' : null, + lastSeenYomitanDictionaryCount: 0, + }; + }; + const readState = (): SetupState => projectState(readStoredState()); const writeState = (state: SetupState): SetupState => { + const backend = getDictionaryBackend(); + const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend); + state = { + ...state, + dictionaryBackend: backend, + completedDictionaryBackends: state.status === 'completed' ? [...others, backend] : others, + }; writeSetupState(setupStatePath, state); completed = state.status === 'completed'; deps.onStateChanged?.(state); @@ -292,10 +373,12 @@ export function createFirstRunSetupService(deps: { }; const buildSnapshot = async (state: SetupState, message: string | null = null) => { - const { configReady, dictionaryCount, externalYomitanConfigured } = + const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } = await resolveYomitanSetupStatus({ configFilePaths, getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + getDictionaryBackend, + getHachidoriHostStatus: deps.getHachidoriHostStatus, isExternalYomitanConfigured: deps.isExternalYomitanConfigured, }); const pluginInstalled = await deps.detectPluginInstalled(); @@ -314,6 +397,8 @@ export function createFirstRunSetupService(deps: { installedWindowsMpvShortcuts, ); return { + dictionaryBackend: getDictionaryBackend(), + hachidoriHost, configReady, dictionaryCount, canFinish: isYomitanSetupSatisfied({ @@ -353,11 +438,19 @@ export function createFirstRunSetupService(deps: { return { ensureSetupStateInitialized: async () => { - const state = readState(); + const stored = readStoredState(); + // Persist the active backend stamp so the launcher can tell which backend + // the running app gates playback on. + const state = + getSetupStateDictionaryBackend(stored) === getDictionaryBackend() + ? stored + : writeState(projectState(stored)); const { configReady, dictionaryCount, externalYomitanConfigured } = await resolveYomitanSetupStatus({ configFilePaths, getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + getDictionaryBackend, + getHachidoriHostStatus: deps.getHachidoriHostStatus, isExternalYomitanConfigured: deps.isExternalYomitanConfigured, }); const canFinish = isYomitanSetupSatisfied({ diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 024ff27e..3e2f16f2 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -779,3 +779,51 @@ test('closing completed first-run setup quits app when completion policy allows assert.deepEqual(calls, ['set', 'clear', 'quit']); }); + +test('Hachidori setup names the active dictionary backend', () => { + const html = buildFirstRunSetupHtml({ + dictionaryBackend: 'hachidori', + configReady: true, + dictionaryCount: 0, + canFinish: false, + externalYomitanConfigured: false, + pluginStatus: 'installed', + pluginInstallPathSummary: null, + mpvExecutablePath: '', + mpvExecutablePathStatus: 'blank', + windowsMpvShortcuts: { + supported: false, + startMenuEnabled: true, + desktopEnabled: true, + startMenuInstalled: false, + desktopInstalled: false, + status: 'optional', + }, + commandLineLauncher: createCommandLineLauncherSnapshot(), + message: null, + }); + assert.match(html, /Hachidori dictionaries/); + assert.match(html, /Open Hachidori Settings/); + assert.match(html, /Install at least one Hachidori dictionary/); + assert.doesNotMatch(html, /Open Yomitan Settings/); + assert.match(html, /Link host/); + assert.match(html, /
/); + assert.match(html, /Use an external dictionary host/); + assert.match(html, /keep the browser and its sharing relay running/); + assert.match(html, /You can close its browser management page/); +}); + +test('setup parses link and unlink actions without losing the host address', () => { + const address = 'ws://127.0.0.1:38771/link'; + assert.deepEqual( + parseFirstRunSetupSubmissionUrl( + 'subminer://first-run-setup?action=link-hachidori-host&address=' + + encodeURIComponent(address), + ), + { action: 'link-hachidori-host', address }, + ); + assert.deepEqual( + parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'), + { action: 'unlink-hachidori-host' }, + ); +}); diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index f0135e8a..8a808088 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -1,3 +1,5 @@ +import type { HachidoriHostStatus } from '../../shared/hachidori-sharing'; +import type { DictionaryBackend } from '../../types/config'; import { getFirstRunSetupCompletionMessage } from './first-run-setup-service'; import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher'; @@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & { close: () => void; }; -export type FirstRunSetupAction = - | 'configure-mpv-executable-path' - | 'remove-legacy-plugin' - | 'configure-windows-mpv-shortcuts' - | 'install-bun' - | 'install-command-line-launcher' - | 'open-yomitan-settings' - | 'open-config-settings' - | 'refresh' - | 'finish'; +export type FirstRunSetupSubmission = + | { action: 'configure-mpv-executable-path'; mpvExecutablePath: string } + | { + action: 'configure-windows-mpv-shortcuts'; + startMenuEnabled: boolean; + desktopEnabled: boolean; + } + | { action: 'link-hachidori-host'; address: string } + | { + action: + | 'unlink-hachidori-host' + | 'remove-legacy-plugin' + | 'install-bun' + | 'install-command-line-launcher' + | 'open-yomitan-settings' + | 'open-config-settings' + | 'refresh' + | 'finish'; + }; -export interface FirstRunSetupSubmission { - action: FirstRunSetupAction; - mpvExecutablePath?: string; - startMenuEnabled?: boolean; - desktopEnabled?: boolean; -} +export type FirstRunSetupAction = FirstRunSetupSubmission['action']; export interface FirstRunSetupHtmlModel { + dictionaryBackend?: DictionaryBackend; + hachidoriHost?: HachidoriHostStatus; configReady: boolean; dictionaryCount: number; canFinish: boolean; @@ -249,9 +257,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ` : ''; - const yomitanMeta = model.externalYomitanConfigured - ? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.' - : `${model.dictionaryCount} installed`; + const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'; + const host = model.hachidoriHost; + const linked = host?.kind === 'connected' || host?.kind === 'disconnected'; + const hostAddress = linked ? host.address : ''; + const hostCard = + model.dictionaryBackend === 'hachidori' + ? ` +
+ Dictionary source +

Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.

+
+ Use an external dictionary host +

Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.

+

Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.

+
    +
  • Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.
  • +
  • Desktop app: keep the app sharing your dictionaries and any required relay running.
  • +
  • Docker: keep the dictionary container running. You can close its browser management page.
  • +
+

If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.

+
+ + +
Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.
+
+ + ${linked ? `` : ''} +
+
+ ${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `

${escapeHtml(host.message)}

` : ''} +
+
` + : ''; + const yomitanMeta = + host?.kind === 'connected' + ? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}` + : host?.kind === 'disconnected' + ? `Host unavailable: ${host.address}` + : model.externalYomitanConfigured + ? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.' + : `${model.dictionaryCount} installed`; const yomitanBadgeLabel = model.externalYomitanConfigured ? 'External' : model.dictionaryCount >= 1 @@ -268,8 +314,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { : model.canFinish ? model.externalYomitanConfigured ? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.' - : 'Finish stays unlocked once Yomitan reports at least one installed dictionary.' - : 'Finish stays locked until Yomitan reports at least one installed dictionary.'; + : `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.` + : `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`; return ` @@ -338,6 +384,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { color: var(--muted); font-size: 12px; } + .external-host summary { + cursor: pointer; + color: var(--blue); + font-weight: 700; + } + .external-host[open] summary { + margin-bottom: 10px; + } + .host-requirements { + padding-left: 20px; + margin: 0 0 12px; + } + .host-requirements li + li { + margin-top: 4px; + } .shortcut-form { display: grid; gap: 8px; @@ -466,17 +527,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
- Yomitan dictionaries + ${dictionaryName} dictionaries
${escapeHtml(yomitanMeta)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
+ ${hostCard} ${mpvExecutablePathCard} ${windowsShortcutCard} ${renderCommandLineLauncherSection(model.commandLineLauncher)} ${legacyPluginCard}
- + @@ -495,6 +557,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu const parsed = new URL(rawUrl); const action = parsed.searchParams.get('action'); if ( + action !== 'link-hachidori-host' && + action !== 'unlink-hachidori-host' && action !== 'configure-mpv-executable-path' && action !== 'remove-legacy-plugin' && action !== 'configure-windows-mpv-shortcuts' && @@ -507,6 +571,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu ) { return null; } + if (action === 'link-hachidori-host') { + return { action, address: parsed.searchParams.get('address')?.trim() ?? '' }; + } if (action === 'configure-mpv-executable-path') { return { action, diff --git a/src/main/runtime/startup-mode-flags.test.ts b/src/main/runtime/startup-mode-flags.test.ts index 397b6771..0602affa 100644 --- a/src/main/runtime/startup-mode-flags.test.ts +++ b/src/main/runtime/startup-mode-flags.test.ts @@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w ); assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false); }); + +for (const flag of ['--yomitan', '--hachidori']) { + test(`${flag} settings startup skips heavy startup`, () => { + assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true); + assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false); + }); +} diff --git a/src/main/runtime/startup-mode-flags.ts b/src/main/runtime/startup-mode-flags.ts index 2f295ea9..a5029472 100644 --- a/src/main/runtime/startup-mode-flags.ts +++ b/src/main/runtime/startup-mode-flags.ts @@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args'; import { isHeadlessInitialCommand, isStandaloneTexthookerCommand, - shouldRunYomitanOnlyStartup, + shouldRunDictionarySettingsOnlyStartup, } from '../../cli/args'; export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { @@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): { ), shouldSkipHeavyStartup: Boolean( initialArgs && - (shouldRunYomitanOnlyStartup(initialArgs) || + (shouldRunDictionarySettingsOnlyStartup(initialArgs) || initialArgs.settings || initialArgs.stats || initialArgs.dictionary || diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts index a1206a69..1125411c 100644 --- a/src/main/runtime/stats-server-runtime.ts +++ b/src/main/runtime/stats-server-runtime.ts @@ -172,6 +172,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): { await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), deck: ankiConnectConfig.deck, + ankiConfig: ankiConnectConfig, }); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); if (result.noteId && result.duplicateNoteIds.length > 0) { diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 5544e6a9..6ff6f73f 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -71,6 +71,8 @@ test('build tray template handler wires actions and init guards', () => { showFirstRunSetup: () => true, openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, + getDictionaryBackend: () => 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('sync-ui'), @@ -127,6 +129,8 @@ test('windows mpv launcher tray action force-opens completed setup', () => { showFirstRunSetup: () => false, openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, + getDictionaryBackend: () => 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('configuration'), diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 76692f69..4410b749 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function createResolveTrayIconPathHandler(deps: { resolveTrayIconPathRuntime: (options: { platform: string; @@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -67,6 +71,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { showFirstRunSetup: () => boolean; openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; + getDictionaryBackend: () => DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; @@ -107,6 +113,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { deps.openFirstRunSetupWindow(true); }, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(), + dictionaryBackend: deps.getDictionaryBackend(), + openHachidoriSettings: deps.openHachidoriSettings, openYomitanSettings: () => { deps.openYomitanSettings(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index aa514f52..af744168 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -31,6 +31,8 @@ test('tray main deps builders return mapped handlers', () => { showFirstRunSetup: () => true, openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, + getDictionaryBackend: () => 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), openSyncUiWindow: () => calls.push('sync-ui'), @@ -58,6 +60,8 @@ test('tray main deps builders return mapped handlers', () => { showFirstRunSetup: true, openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'), showWindowsMpvLauncherSetup: true, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettings: () => calls.push('open-configuration'), openSyncUi: () => {}, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index fa3e173e..cac73382 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function createBuildResolveTrayIconPathMainDepsHandler(deps: { resolveTrayIconPathRuntime: (options: { platform: string; @@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -57,6 +61,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: () => boolean; openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; + getDictionaryBackend: () => DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; @@ -82,6 +88,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: deps.showFirstRunSetup, openFirstRunSetupWindow: deps.openFirstRunSetupWindow, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, + getDictionaryBackend: deps.getDictionaryBackend, + openHachidoriSettings: deps.openHachidoriSettings, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 18918db9..165a308c 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -31,6 +31,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => showFirstRunSetup: () => true, openFirstRunSetupWindow: () => {}, showWindowsMpvLauncherSetup: () => true, + getDictionaryBackend: () => 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 7c2ab149..d96aa728 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -26,85 +26,96 @@ test('resolve tray icon returns null when no asset exists', () => { assert.equal(path, null); }); -test('tray menu template contains expected entries and handlers', () => { - const calls: string[] = []; - const template = buildTrayMenuTemplateRuntime({ - openSessionHelp: () => calls.push('help'), - openChangelog: () => calls.push('changelog'), - openTexthookerInBrowser: () => calls.push('texthooker'), - showTexthookerPage: true, - openFirstRunSetup: () => calls.push('setup'), - showFirstRunSetup: true, - openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'), - showWindowsMpvLauncherSetup: true, - openYomitanSettings: () => calls.push('yomitan'), - openConfigSettings: () => calls.push('configuration'), - openSyncUi: () => calls.push('sync-ui'), - exportLogs: () => calls.push('export-logs'), - openJellyfinSetup: () => calls.push('jellyfin'), - showJellyfinDiscovery: true, - jellyfinDiscoveryActive: false, - toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`), - openAnilistSetup: () => calls.push('anilist'), - checkForUpdates: () => calls.push('updates'), - quitApp: () => calls.push('quit'), +for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) { + const settingsLabel = + dictionaryBackend === 'hachidori' ? 'Open Hachidori Settings' : 'Open Yomitan Settings'; + test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => { + const calls: string[] = []; + const template = buildTrayMenuTemplateRuntime({ + openSessionHelp: () => calls.push('help'), + openChangelog: () => calls.push('changelog'), + openTexthookerInBrowser: () => calls.push('texthooker'), + showTexthookerPage: true, + openFirstRunSetup: () => calls.push('setup'), + showFirstRunSetup: true, + openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'), + showWindowsMpvLauncherSetup: true, + dictionaryBackend, + openHachidoriSettings: () => calls.push('hachidori'), + openYomitanSettings: () => calls.push('yomitan'), + openConfigSettings: () => calls.push('configuration'), + openSyncUi: () => calls.push('sync-ui'), + exportLogs: () => calls.push('export-logs'), + openJellyfinSetup: () => calls.push('jellyfin'), + showJellyfinDiscovery: true, + jellyfinDiscoveryActive: false, + toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`), + openAnilistSetup: () => calls.push('anilist'), + checkForUpdates: () => calls.push('updates'), + quitApp: () => calls.push('quit'), + }); + + // Resolve by label, not index: adding a menu entry should not force every + // later assertion in this test to be renumbered. + const entryFor = (label: string) => { + const entry = template.find((candidate) => candidate.label === label); + assert.ok(entry, `expected a "${label}" tray entry`); + return entry; + }; + + assert.deepEqual( + template.map((entry) => entry.label ?? `<${entry.type}>`), + [ + 'Open Help', + 'View Changelog', + 'Open Texthooker', + 'Complete Setup', + 'Open SubMiner Setup', + settingsLabel, + 'Open SubMiner Settings', + 'Sync Stats && History', + 'Export Logs', + 'Configure Jellyfin', + 'Jellyfin Discovery', + 'Configure AniList', + 'Check for Updates', + '', + 'Quit', + ], + ); + + entryFor(settingsLabel).click?.(); + assert.equal(calls.length, 1); + assert.equal(calls[0], dictionaryBackend); + calls.length = 0; + + const discovery = entryFor('Jellyfin Discovery'); + assert.equal(discovery.type, 'checkbox'); + assert.equal(discovery.checked, false); + discovery.click?.({ checked: true }); + + entryFor('Open Help').click?.(); + entryFor('View Changelog').click?.(); + entryFor('Open Texthooker').click?.(); + entryFor('Sync Stats && History').click?.(); + entryFor('Export Logs').click?.(); + entryFor('Check for Updates').click?.(); + calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad'); + entryFor('Quit').click?.(); + + assert.deepEqual(calls, [ + 'jellyfin-discovery:true', + 'help', + 'changelog', + 'texthooker', + 'sync-ui', + 'export-logs', + 'updates', + 'separator', + 'quit', + ]); }); - - // Resolve by label, not index: adding a menu entry should not force every - // later assertion in this test to be renumbered. - const entryFor = (label: string) => { - const entry = template.find((candidate) => candidate.label === label); - assert.ok(entry, `expected a "${label}" tray entry`); - return entry; - }; - - assert.deepEqual( - template.map((entry) => entry.label ?? `<${entry.type}>`), - [ - 'Open Help', - 'View Changelog', - 'Open Texthooker', - 'Complete Setup', - 'Open SubMiner Setup', - 'Open Yomitan Settings', - 'Open SubMiner Settings', - 'Sync Stats && History', - 'Export Logs', - 'Configure Jellyfin', - 'Jellyfin Discovery', - 'Configure AniList', - 'Check for Updates', - '', - 'Quit', - ], - ); - - const discovery = entryFor('Jellyfin Discovery'); - assert.equal(discovery.type, 'checkbox'); - assert.equal(discovery.checked, false); - discovery.click?.({ checked: true }); - - entryFor('Open Help').click?.(); - entryFor('View Changelog').click?.(); - entryFor('Open Texthooker').click?.(); - entryFor('Sync Stats && History').click?.(); - entryFor('Export Logs').click?.(); - entryFor('Check for Updates').click?.(); - calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad'); - entryFor('Quit').click?.(); - - assert.deepEqual(calls, [ - 'jellyfin-discovery:true', - 'help', - 'changelog', - 'texthooker', - 'sync-ui', - 'export-logs', - 'updates', - 'separator', - 'quit', - ]); -}); +} test('tray menu template omits first-run setup entry when setup is complete', () => { const labels = buildTrayMenuTemplateRuntime({ @@ -116,6 +127,8 @@ test('tray menu template omits first-run setup entry when setup is complete', () showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, showWindowsMpvLauncherSetup: false, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, @@ -146,6 +159,8 @@ test('tray menu template omits texthooker entry when texthooker page is disabled showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, showWindowsMpvLauncherSetup: false, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, @@ -174,6 +189,8 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, showWindowsMpvLauncherSetup: false, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, @@ -203,6 +220,8 @@ test('tray menu template renders a visible linux discovery check mark when activ showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, showWindowsMpvLauncherSetup: false, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 60598f37..f901d8f7 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function resolveTrayIconPathRuntime(deps: { platform: string; resourcesPath: string; @@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -102,8 +106,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): ] : []), { - label: 'Open Yomitan Settings', - click: handlers.openYomitanSettings, + label: + handlers.dictionaryBackend === 'hachidori' + ? 'Open Hachidori Settings' + : 'Open Yomitan Settings', + click: + handlers.dictionaryBackend === 'hachidori' + ? handlers.openHachidoriSettings + : handlers.openYomitanSettings, }, { label: 'Open SubMiner Settings', diff --git a/src/main/runtime/yomitan-anki-server-sync.test.ts b/src/main/runtime/yomitan-anki-server-sync.test.ts index 4ccf7adf..83c085a5 100644 --- a/src/main/runtime/yomitan-anki-server-sync.test.ts +++ b/src/main/runtime/yomitan-anki-server-sync.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync'; +import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings'; test('buildYomitanAnkiSettingsKey includes force override policy', () => { assert.notEqual( @@ -16,3 +17,15 @@ test('buildYomitanAnkiSettingsKey includes force override policy', () => { }), ); }); + +test('settings sync key changes when fields or tags change', () => { + const key = (word: string, tags: string[]) => + buildYomitanAnkiSettingsKey({ + targetUrl: 'http://127.0.0.1:8766', + targetDeck: 'Mining', + forceOverride: true, + hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }), + }); + assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner'])); + assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese'])); +}); diff --git a/src/main/runtime/yomitan-anki-server-sync.ts b/src/main/runtime/yomitan-anki-server-sync.ts index 158a2092..88b7842f 100644 --- a/src/main/runtime/yomitan-anki-server-sync.ts +++ b/src/main/runtime/yomitan-anki-server-sync.ts @@ -1,5 +1,6 @@ import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services'; import type { ResolvedConfig } from '../../types'; +import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings'; import { getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, shouldForceOverrideYomitanAnkiServer, @@ -17,8 +18,9 @@ export function buildYomitanAnkiSettingsKey(options: { targetUrl: string; targetDeck: string; forceOverride: boolean; + hachidoriHints?: ReturnType; }): string { - return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`; + return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`; } export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): { @@ -45,6 +47,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu targetUrl, targetDeck, forceOverride, + hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig), }); if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { return; @@ -64,6 +67,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu { forceOverride, deck: targetDeck, + ankiConfig: ankiConnectConfig, }, ); diff --git a/src/preload-clipboard.test.ts b/src/preload-clipboard.test.ts index d0bd4224..f8dacf36 100644 --- a/src/preload-clipboard.test.ts +++ b/src/preload-clipboard.test.ts @@ -18,6 +18,7 @@ test('sidebar clipboard bridge writes exact text without renderer focus and reje let exposed: unknown; runInNewContext(output.text, { process: { argv: [] }, + window: { addEventListener: () => {} }, require: (name: string) => { assert.equal(name, 'electron'); return { diff --git a/src/preload.ts b/src/preload.ts index d9cc260f..4618a0e8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -18,6 +18,44 @@ import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron'; import { resolveOverlayLayerFromArgv } from './preload-args'; +import { + DICTIONARY_EXTERNAL_LINK_CHANNEL, + parseDictionaryExternalUrl, +} from './shared/dictionary-external-link'; + +window.addEventListener('hachidori-open-external', (event) => { + if (!(event instanceof CustomEvent)) return; + const detail: unknown = event.detail; + if ( + !detail || + typeof detail !== 'object' || + !('requestId' in detail) || + typeof detail.requestId !== 'string' || + !('url' in detail) + ) + return; + const requestId = detail.requestId; + void Promise.resolve() + .then(() => + ipcRenderer.invoke(DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl(detail.url)), + ) + .then(() => { + window.dispatchEvent( + new CustomEvent('hachidori-open-external-result', { detail: { requestId, ok: true } }), + ); + }) + .catch((error: unknown) => { + window.dispatchEvent( + new CustomEvent('hachidori-open-external-result', { + detail: { + requestId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + }), + ); + }); +}); import type { SubtitleData, SubtitlePosition, diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index f5f8a9d9..de9ff42a 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -368,7 +368,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] { { shortcut: 'Y then S', action: 'Start overlay' }, { shortcut: 'Y then Shift + S', action: 'Stop overlay' }, { shortcut: 'Y then T', action: 'Toggle visible overlay' }, - { shortcut: 'Y then O', action: 'Open Yomitan settings' }, + { shortcut: 'Y then O', action: 'Open dictionary settings' }, { shortcut: 'Y then R', action: 'Restart overlay' }, { shortcut: 'Y then C', action: 'Check overlay status' }, { shortcut: 'Y then H/K', action: 'Open session help' }, @@ -377,7 +377,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] { }, { title: 'Global shortcuts', - rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }], + rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }], }, ]; } diff --git a/src/renderer/modals/youtube-track-picker.test.ts b/src/renderer/modals/youtube-track-picker.test.ts index 88fe0a8d..32a38b73 100644 --- a/src/renderer/modals/youtube-track-picker.test.ts +++ b/src/renderer/modals/youtube-track-picker.test.ts @@ -274,7 +274,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => { assert.equal(state.youtubePickerModalOpen, false); assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']); assert.deepEqual(notifications, ['youtube-track-picker']); - assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]); + assert.deepEqual(frontendCommands, []); assert.equal(overlay.classList.contains('interactive'), false); assert.equal(focusMainWindowCalls.length > 0, true); assert.equal(overlayFocusCalls.length > 0, true); diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts index ecbcbfdc..6fc0c5e6 100644 --- a/src/renderer/modals/youtube-track-picker.ts +++ b/src/renderer/modals/youtube-track-picker.ts @@ -1,6 +1,5 @@ import type { YoutubePickerOpenPayload } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; -import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; function createOption(value: string, label: string): HTMLOptionElement { const option = document.createElement('option'); @@ -197,13 +196,6 @@ export function createYoutubeTrackPickerModal( ctx.dom.youtubePickerModal.classList.add('hidden'); ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true'); window.electronAPI.notifyOverlayModalClosed('youtube-track-picker'); - window.dispatchEvent( - new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { - detail: { - type: 'refreshOptions', - }, - }), - ); if (!options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts index 31adebe6..19072d65 100644 --- a/src/settings/settings-anki-controls.ts +++ b/src/settings/settings-anki-controls.ts @@ -230,12 +230,12 @@ async function loadYomitanAnkiDeckName(): Promise { state.yomitanAnkiDeckNameError = null; } else { state.yomitanAnkiDeckName = ''; - state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.'; + state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read the dictionary Anki deck.'; } } catch (error) { state.yomitanAnkiDeckName = ''; state.yomitanAnkiDeckNameError = - error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.'; + error instanceof Error ? error.message : 'Failed to read the dictionary Anki deck.'; } finally { state.yomitanAnkiDeckNameLoading = false; requestRender(); diff --git a/src/shared/dictionary-external-link.test.ts b/src/shared/dictionary-external-link.test.ts new file mode 100644 index 00000000..840e4015 --- /dev/null +++ b/src/shared/dictionary-external-link.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDictionaryExternalUrl } from './dictionary-external-link'; + +test('dictionary links accept web URLs and reject privileged schemes and credentials', () => { + assert.equal( + parseDictionaryExternalUrl('https://example.com/word?q=猫'), + 'https://example.com/word?q=%E7%8C%AB', + ); + for (const value of [ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'https://user:pass@example.com', + null, + {}, + ]) { + assert.throws(() => parseDictionaryExternalUrl(value)); + } +}); diff --git a/src/shared/dictionary-external-link.ts b/src/shared/dictionary-external-link.ts new file mode 100644 index 00000000..db52ed27 --- /dev/null +++ b/src/shared/dictionary-external-link.ts @@ -0,0 +1,10 @@ +export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external'; + +export function parseDictionaryExternalUrl(value: unknown): string { + if (typeof value !== 'string') throw new Error('Expected a link URL'); + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new Error('Dictionary links must use HTTP or HTTPS without credentials'); + } + return url.href; +} diff --git a/src/shared/hachidori-sharing.test.ts b/src/shared/hachidori-sharing.test.ts new file mode 100644 index 00000000..93f79b12 --- /dev/null +++ b/src/shared/hachidori-sharing.test.ts @@ -0,0 +1,160 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing'; +import { runInNewContext } from 'node:vm'; + +test('host status rejects malformed replies and distinguishes local, connected and offline', () => { + assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), { + kind: 'local', + }); + assert.throws( + () => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }), + /Connection refused/, + ); + assert.throws( + () => + parseHachidoriHostStatus({ + ok: true, + sharing: { + client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } }, + }, + }), + /dictionary count/, + ); + assert.equal( + parseHachidoriHostStatus({ + ok: true, + sharing: { client: { linked: true, address: 'host', connected: false } }, + }).kind, + 'disconnected', + ); +}); + +test('sharing script safely links an address and checks live engine inventory', async () => { + const address = `ws://host:8771/link?quote="`; + const requests: Array> = []; + const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address }); + const value: unknown = await runInNewContext(script, { + crypto, + setTimeout, + clearTimeout, + chrome: { + runtime: { + sendMessage: async (message: Record) => { + requests.push(message); + if (message.type === 'hd_status') return { ok: true, ready: true, loading: false }; + if (message.type === 'hd_state_read') + return { ok: true, state: { dictionaries: [{}, {}] } }; + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address, + host: { name: 'Host', dictionaryCount: 99 }, + }, + }, + }; + }, + }, + }, + }); + assert.equal(requests[0]?.address, address); + assert.deepEqual(parseHachidoriHostStatus(value), { + kind: 'connected', + address, + name: 'Host', + dictionaryCount: 2, + }); +}); + +test('an unresponsive host preserves its address so setup can still unlink', async () => { + const value: unknown = await runInNewContext( + buildHachidoriSharingScript({ type: 'hd_sharing_status' }), + { + crypto, + setTimeout, + clearTimeout, + chrome: { + runtime: { + sendMessage: async (message: { type: string }) => { + if (message.type === 'hd_status') throw new Error('Host stopped responding'); + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address: 'ws://host:8771/link', + host: { dictionaryCount: 1 }, + }, + }, + }; + }, + }, + }, + }, + ); + assert.deepEqual(parseHachidoriHostStatus(value), { + kind: 'disconnected', + address: 'ws://host:8771/link', + message: 'Host stopped responding', + }); +}); + +for (const stalledCall of ['initial', 'refresh']) { + test(`sharing ${stalledCall} requests time out and clear their timers`, async () => { + const timers = new Map void>(); + let nextTimer = 0; + let sharingCalls = 0; + let stalled = false; + const result: Promise = runInNewContext( + buildHachidoriSharingScript({ type: 'hd_sharing_status' }), + { + crypto, + setTimeout: (callback: () => void, delay: number) => { + assert.equal(delay, 5000); + timers.set(++nextTimer, callback); + return nextTimer; + }, + clearTimeout: (id: number) => timers.delete(id), + chrome: { + runtime: { + sendMessage: async (message: { type: string }) => { + if (message.type === 'hd_status') return { ok: true, ready: true }; + sharingCalls += 1; + if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) { + stalled = true; + return new Promise(() => {}); + } + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address: 'host', + host: { dictionaryCount: 1 }, + }, + }, + }; + }, + }, + }, + }, + ); + for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve(); + assert.equal(stalled, true); + assert.equal(timers.size, 1); + for (const callback of timers.values()) callback(); + if (stalledCall === 'initial') { + await assert.rejects(result, /host did not respond/); + } else { + const status = parseHachidoriHostStatus(await result); + assert.equal(status.kind, 'disconnected'); + if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/); + } + assert.equal(timers.size, 0); + }); +} diff --git a/src/shared/hachidori-sharing.ts b/src/shared/hachidori-sharing.ts new file mode 100644 index 00000000..c269d336 --- /dev/null +++ b/src/shared/hachidori-sharing.ts @@ -0,0 +1,92 @@ +export type HachidoriHostStatus = + | { kind: 'local' } + | { kind: 'connected'; address: string; name: string; dictionaryCount: number } + | { kind: 'disconnected'; address: string; message: string } + | { kind: 'unavailable'; message: string }; + +export type HachidoriSharingRequest = + | { type: 'hd_sharing_status' } + | { type: 'hd_sharing_client_link'; address: string } + | { type: 'hd_sharing_client_unlink' }; + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus { + if (!object(reply) || reply.ok !== true || !object(reply.sharing)) { + throw new Error( + object(reply) && typeof reply.error === 'string' + ? reply.error + : 'Hachidori returned an invalid sharing status.', + ); + } + const client = reply.sharing.client; + if (!object(client) || typeof client.linked !== 'boolean') { + throw new Error('Hachidori returned an invalid host connection.'); + } + if (!client.linked) return { kind: 'local' }; + if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.'); + if (client.connected !== true) { + return { + kind: 'disconnected', + address: client.address, + message: + typeof client.error === 'string' + ? client.error + : 'The dictionary host is not reachable. Start it, then refresh status.', + }; + } + const host = client.host; + if ( + !object(host) || + typeof host.dictionaryCount !== 'number' || + !Number.isSafeInteger(host.dictionaryCount) || + host.dictionaryCount < 0 + ) { + throw new Error('Hachidori returned an invalid dictionary count.'); + } + return { + kind: 'connected', + address: client.address, + name: typeof host.name === 'string' ? host.name : 'Hachidori', + dictionaryCount: host.dictionaryCount, + }; +} + +export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string { + return `(async () => { + const request = ${JSON.stringify(request)}; + const requestWithTimeout = async fields => { + let timer; + try { + return await Promise.race([ + chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }), + ]); + } finally { clearTimeout(timer); } + }; + const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields }); + const read = (target, type) => requestWithTimeout({ target, type }); + let reply = await send(request); + if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.'); + if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) { + try { + const engine = await read('hoshidicts-offscreen', 'hd_status'); + reply = await send({type: 'hd_sharing_status'}); + if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) { + const inventory = await read('hoshidicts-worker', 'hd_state_read'); + if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.'); + reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length; + } else if (reply.sharing?.client?.linked) { + reply.sharing.client.connected = false; + reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.'); + } + } catch (error) { + reply.sharing.client.connected = false; + reply.sharing.client.error = error.message; + } + } + return reply; + })()`; +} diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index 8d55f47f..c8d3bcf5 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -9,6 +9,7 @@ import { getDefaultConfigDir, getDefaultConfigFilePaths, getSetupStatePath, + isSetupCompleted, normalizeSetupState, readSetupState, resolveDefaultMpvInstallPaths, @@ -333,3 +334,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults' ), }); }); + +test('setup state keeps backend identity and reads legacy completion as Yomitan', () => { + const legacy = { ...createDefaultSetupState(), status: 'completed' as const }; + assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true); + assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false); + const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' }); + assert.equal(hachidori?.dictionaryBackend, 'hachidori'); + assert.equal(isSetupCompleted(hachidori, 'hachidori'), true); + assert.equal(isSetupCompleted(hachidori, 'yomitan'), false); + assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null); +}); + +test('setup state remembers every backend that finished setup', () => { + const state = normalizeSetupState({ + ...createDefaultSetupState(), + status: 'completed', + dictionaryBackend: 'hachidori', + completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'], + }); + assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']); + assert.equal(isSetupCompleted(state, 'yomitan'), true); + assert.equal(isSetupCompleted(state, 'hachidori'), true); + const reopened = normalizeSetupState({ ...state, status: 'incomplete' }); + assert.equal(isSetupCompleted(reopened, 'hachidori'), false); + assert.equal(isSetupCompleted(reopened, 'yomitan'), true); + for (const status of ['incomplete', 'in_progress', 'cancelled']) { + const staleHistory = normalizeSetupState({ + ...state, + status, + completedDictionaryBackends: ['yomitan', 'hachidori'], + }); + assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false); + assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true); + } + assert.equal( + normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends, + undefined, + ); +}); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index 7370e732..23351043 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { DictionaryBackend } from '../types/config'; import os from 'node:os'; import path from 'node:path'; import { resolveConfigDir } from '../config/path-resolution'; @@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences { export interface SetupState { version: 4; + /** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */ + dictionaryBackend?: DictionaryBackend; + /** Backends whose setup finished at least once, so switching back never repeats setup. */ + completedDictionaryBackends?: DictionaryBackend[]; status: SetupStateStatus; completedAt: string | null; completionSource: SetupCompletionSource; @@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof return platform === 'win32' ? path.win32 : path.posix; } +function isDictionaryBackend(value: unknown): value is DictionaryBackend { + return value === 'yomitan' || value === 'hachidori'; +} + function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null { if ( (version !== 1 && version !== 2 && version !== 3 && version !== 4) || + (record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) || (status !== 'incomplete' && status !== 'in_progress' && status !== 'completed' && @@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null { return null; } + const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends) + ? record.completedDictionaryBackends.filter(isDictionaryBackend) + : []; + return { version: 4, + ...(isDictionaryBackend(record.dictionaryBackend) + ? { dictionaryBackend: record.dictionaryBackend } + : {}), + ...(completedDictionaryBackends.length > 0 + ? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] } + : {}), status, completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, completionSource, @@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null { }; } -export function isSetupCompleted(state: SetupState | null | undefined): boolean { - return state?.status === 'completed'; +export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend { + return state.dictionaryBackend ?? 'yomitan'; +} + +/** Current status takes precedence; completion history applies only to other backends. */ +export function hasCompletedSetupForBackend( + state: SetupState, + dictionaryBackend: DictionaryBackend, +): boolean { + if (getSetupStateDictionaryBackend(state) === dictionaryBackend) { + return state.status === 'completed'; + } + return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend); +} + +export function isSetupCompleted( + state: SetupState | null | undefined, + dictionaryBackend?: DictionaryBackend, +): boolean { + if (!state) return false; + if (dictionaryBackend === undefined) return state.status === 'completed'; + return hasCompletedSetupForBackend(state, dictionaryBackend); } export function getDefaultConfigDir(options?: { diff --git a/src/stats-word-helper.ts b/src/stats-word-helper.ts index 080dcc3d..6e46a612 100644 --- a/src/stats-word-helper.ts +++ b/src/stats-word-helper.ts @@ -5,6 +5,14 @@ import type { BrowserWindow, Extension, Session } from 'electron'; import { ConfigService } from './config/service'; import { createLogger, setLogLevel } from './logger'; import { loadYomitanExtension } from './core/services/yomitan-extension-loader'; +import { + createHachidoriExtensionRuntime, + getHachidoriSession, +} from './core/services/hachidori-extension'; +import { + getPreferredYomitanAnkiServerUrl, + shouldForceOverrideYomitanAnkiServer, +} from './main/runtime/yomitan-anki-server'; import { addYomitanNoteViaSearch, getYomitanCurrentAnkiDeckName, @@ -104,27 +112,38 @@ async function main(): Promise { try { const configService = new ConfigService(userDataPath!); const config = configService.getConfig(); - const extension = await loadYomitanExtension({ - userDataPath: userDataPath!, - getYomitanParserWindow: () => yomitanParserWindow, - setYomitanParserWindow: (window) => { - yomitanParserWindow = window; - }, - setYomitanParserReadyPromise: (promise) => { - yomitanParserReadyPromise = promise; - }, - setYomitanParserInitPromise: (promise) => { - yomitanParserInitPromise = promise; - }, - setYomitanExtension: (extensionValue) => { - yomitanExt = extensionValue; - }, - setYomitanSession: (sessionValue) => { - yomitanSession = sessionValue; - }, - }); + // Mine with the same backend the app uses so notes come from the user's + // dictionaries and Anki templates, not an empty profile. + const extension = + config.dictionaryBackend === 'hachidori' + ? await createHachidoriExtensionRuntime(userDataPath!) + .ensureLoaded() + .then((loaded) => { + yomitanExt = loaded; + yomitanSession = getHachidoriSession(); + return loaded; + }) + : await loadYomitanExtension({ + userDataPath: userDataPath!, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extensionValue) => { + yomitanExt = extensionValue; + }, + setYomitanSession: (sessionValue) => { + yomitanSession = sessionValue; + }, + }); if (!extension) { - throw new Error('Yomitan extension failed to load.'); + throw new Error(`${config.dictionaryBackend} extension failed to load.`); } const yomitanDeps = { @@ -156,17 +175,21 @@ async function main(): Promise { } await syncYomitanDefaultAnkiServer( - config.ankiConnect?.url || 'http://127.0.0.1:8765', + getPreferredYomitanAnkiServerUrl(config.ankiConnect), yomitanDeps, logger, - { forceOverride: true, deck: config.ankiConnect?.deck }, + { + forceOverride: shouldForceOverrideYomitanAnkiServer(config.ankiConnect), + deck: config.ankiConnect?.deck, + ankiConfig: config.ankiConnect, + }, ); const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger); const noteId = addResult.noteId; if (typeof noteId !== 'number') { - throw new Error('Yomitan failed to create note.'); + throw new Error(`${config.dictionaryBackend} failed to create note.`); } writeResponse(responsePath, { diff --git a/src/types/config.ts b/src/types/config.ts index 71f8f4f3..3682d864 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -140,7 +140,10 @@ export interface RawShortcutsConfig extends ShortcutsConfig { openAnimetosho?: string | null; } +export type DictionaryBackend = 'yomitan' | 'hachidori'; + export interface Config { + dictionaryBackend?: DictionaryBackend; subtitlePosition?: SubtitlePosition; keybindings?: Keybinding[]; websocket?: WebSocketConfig; @@ -183,6 +186,7 @@ export interface Config { export type RawConfig = Config; export interface ResolvedConfig { + dictionaryBackend: DictionaryBackend; subtitlePosition: SubtitlePosition; keybindings: Keybinding[]; websocket: Required; diff --git a/vendor/hachidori/LICENSE b/vendor/hachidori/LICENSE new file mode 100644 index 00000000..e72bfdda --- /dev/null +++ b/vendor/hachidori/LICENSE @@ -0,0 +1,674 @@ + 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 new file mode 100644 index 00000000..70f951aa --- /dev/null +++ b/vendor/hachidori/README.md @@ -0,0 +1,53 @@ +# 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 new file mode 100644 index 00000000..94e34f2f --- /dev/null +++ b/vendor/hachidori/SOURCE.json @@ -0,0 +1,45 @@ +{ + "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 new file mode 100644 index 00000000..338ddfac --- /dev/null +++ b/vendor/hachidori/UPSTREAM-README.md @@ -0,0 +1,134 @@ +

+ 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 new file mode 100644 index 00000000..9ab88f65 --- /dev/null +++ b/vendor/hachidori/docs/asset-rights.md @@ -0,0 +1,111 @@ +# 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 new file mode 100644 index 00000000..1cd81176 --- /dev/null +++ b/vendor/hachidori/docs/source-build.md @@ -0,0 +1,120 @@ +# 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 new file mode 100644 index 00000000..02f30c72 --- /dev/null +++ b/vendor/hachidori/extension/README.md @@ -0,0 +1,186 @@ + + +# 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 new file mode 100644 index 00000000..5501325d --- /dev/null +++ b/vendor/hachidori/extension/anki-addon.js @@ -0,0 +1,13 @@ +// 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 new file mode 100644 index 00000000..84b4db2a --- /dev/null +++ b/vendor/hachidori/extension/anki-audio.js @@ -0,0 +1,113 @@ +// 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 new file mode 100644 index 00000000..8cce3b53 --- /dev/null +++ b/vendor/hachidori/extension/anki-client-media.js @@ -0,0 +1,144 @@ +// 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 new file mode 100644 index 00000000..7422a021 --- /dev/null +++ b/vendor/hachidori/extension/anki-content.js @@ -0,0 +1,677 @@ +// 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 new file mode 100644 index 00000000..a9d659d5 --- /dev/null +++ b/vendor/hachidori/extension/anki-digest.js @@ -0,0 +1,5 @@ +// 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 new file mode 100644 index 00000000..a68ded27 --- /dev/null +++ b/vendor/hachidori/extension/anki-duplicates.js @@ -0,0 +1,188 @@ +// 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 new file mode 100644 index 00000000..4d1ce51f --- /dev/null +++ b/vendor/hachidori/extension/anki-enrichment.js @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 00000000..4342121a --- /dev/null +++ b/vendor/hachidori/extension/anki-glossary.js @@ -0,0 +1,130 @@ +// 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 new file mode 100644 index 00000000..f0c7d496 --- /dev/null +++ b/vendor/hachidori/extension/anki-index-cache.js @@ -0,0 +1,343 @@ +// 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 new file mode 100644 index 00000000..784b2dbe --- /dev/null +++ b/vendor/hachidori/extension/anki-index-worker.js @@ -0,0 +1,14 @@ +// 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 new file mode 100644 index 00000000..08aa8927 --- /dev/null +++ b/vendor/hachidori/extension/anki-index.js @@ -0,0 +1,265 @@ +// 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 new file mode 100644 index 00000000..81c74698 --- /dev/null +++ b/vendor/hachidori/extension/anki-media.js @@ -0,0 +1,132 @@ +// 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 new file mode 100644 index 00000000..66c0c446 --- /dev/null +++ b/vendor/hachidori/extension/anki-mining.js @@ -0,0 +1,475 @@ +// 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 new file mode 100644 index 00000000..a2aa9323 --- /dev/null +++ b/vendor/hachidori/extension/anki-offscreen.js @@ -0,0 +1,89 @@ +// 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 new file mode 100644 index 00000000..ab70e651 --- /dev/null +++ b/vendor/hachidori/extension/anki-pitch.js @@ -0,0 +1,72 @@ +// 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 new file mode 100644 index 00000000..89a37beb --- /dev/null +++ b/vendor/hachidori/extension/anki-resources.js @@ -0,0 +1,41 @@ +// 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 new file mode 100644 index 00000000..8d0fae06 --- /dev/null +++ b/vendor/hachidori/extension/anki-settings.js @@ -0,0 +1,1023 @@ +// 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 new file mode 100644 index 00000000..fe7ac986 --- /dev/null +++ b/vendor/hachidori/extension/anki-setup.js @@ -0,0 +1,142 @@ +// 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 new file mode 100644 index 00000000..a3684b11 --- /dev/null +++ b/vendor/hachidori/extension/anki-templates.js @@ -0,0 +1,285 @@ +// 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 new file mode 100644 index 00000000..12bb7a3a --- /dev/null +++ b/vendor/hachidori/extension/anki-values.js @@ -0,0 +1,296 @@ +// 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 new file mode 100644 index 00000000..247b12ae --- /dev/null +++ b/vendor/hachidori/extension/anki-worker.js @@ -0,0 +1,528 @@ +// 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 new file mode 100644 index 00000000..03bdabd7 --- /dev/null +++ b/vendor/hachidori/extension/anki.js @@ -0,0 +1,285 @@ +// 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 new file mode 100644 index 00000000..b2dd8481 --- /dev/null +++ b/vendor/hachidori/extension/api-host.js @@ -0,0 +1,359 @@ +/* + * 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 new file mode 100644 index 00000000..533fd2b6 --- /dev/null +++ b/vendor/hachidori/extension/assets/ATTRIBUTION.md @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..2870d8d216c918f5c98ad409f7aafeaf0d55a9bc GIT binary patch literal 153232 zcmV(#K;*wtNk&G9RssN5MM6+kP&gobRssM}V+x%CDu@Q40zP3Xkw&B-6^h==hfY&HtnKtN;JLPyhbU{&o0``49C! z_Pjg%pYC7ge~kZA?hpJXbp2cXZ`;pQznuP;{}1{fWgqAM-1*n}*Y{uheMmn!{@ugs z1balkZ~v#QU!(uQ|KIK({S*8b{vTxj>-q5d=dh>t5B0zOy_tX9{mAt{`bYea{~qGM z(m%-=o@Wte6=fR>;GQ~7KNsapBZogCL9CfY1)4S`D%6qRP1Du`!Q&$hBJ5l>U4|S# zp1ybM&NDah>=MrY8$}XQ^+t;+vh-abn9l2au*iRK-)V-!wrhVm2w^M#id5$R&U#uk z6n-Eg%Sr9|a=QDT6O7KRTdJ2ESW|PQ6iZKqs;%h>^7n5z?O0Z%Kq$EKknm)C9yTZY z1+3ys1*wo`U$)1AzunVqn-jmQSxJg7?&0~fY_Id&CGVNxX{w@g*AQwV%a_i;DdoSN z0L3eo%gr%~7R7j#|5L$Ss9x~d6dUpl?g#VdOWaB_<+#INZ^tY&wD&MYf~za&Mq*Q! zmF{8n{ZYawa0C|?JUllqCj65g*$@iJU5)mC`n^hfVKvkBz`j_xjx{u<_AITS8qi#L zw2G~`bWBFPp>Vt|{|0l}H6PdF-*vxK%IEc)IJZCUFJV|NrtrOYW+=c5)s6777cEC|BX{ zjV|hh6TcO4eSF@19B`4+3{7Mg%i8p40-CDQCel4+aAHteORc)s_ZC)+zwvZceDVsE z?A8<3PXvyipo}=GAzZr!>>wox)@eQ$8UXD18V}&cx~X}ph1o{p@N)SnIb8owHHQdP z_m!bB?pDWTXfA~=y`(lKza9v3J!vN;9Lvw6#FQ z26qZZrzJFw%YwKs3S%TCTE2zea_!_Z z$v@dM@5NXT#wJ^a3%z)(Nq%`^-YQzZqON^hkm)qZx^e7dFCg(;s|FbP3;JdtN@By> z_4xa|D5t%){)w~M-zyHp?@vZKzqCnx+YgaM{kPd~T*l^rA3~Wy-|A9i;J{$LnYJ}j z*0(eE9S&w_q3Uos3u%=Mu{y6DUWu`=nh7Uj(hz;J-MF-V;oS@eD@TIEN7rh2DrfZ9 zJN!L1(@|L!L@d%8UV={~;M2HFr6jhiQjj69m=@!Z!63AZgXPoIheRKq+eZY}$<^r_ z6bS}+?VQ^W`Q+}h_PNP1I9HX1fb+m*52G-Gu04$e#1}ehFdO^>+(fPKFmuI2Dco+c zp?Eq+o!# z9b6Idz&bl~_u!N7nM;h+rQ@tkbv-&3{)ySpVpY;mNX|^>66XU8J(JT6cTn zGrXqOt0$eO&HvWEeoMa?b~_pnm^;C)ADR|bJh9J?{+Y2DXI;xyEeY<*=PEH9 zgKu05`zQb>z_LkDEBalVk|cV|W^h2;rik#Hgz#R=LHcwEo_3p>s}B_0yN;r6>5XX4 zKi;$w7B5|#VG;jjkf;zT0IF@2&9yZv4V&;WeS?sT{R--dVe;qIYm)7uc`qY_gte2Y^Sx&+&tV<%*8B{Jugp69po8orl@lbd~ z0NAq^&jEAX#lgC+q-e0k0Ljgk7Yk27BZYuRQ#@FdNGtZf>FR$yY)}98pz! z5pbqJiB;fUc@-`B_gKKa7Wo!qB|mi7S&Y)enZu61+Xs-(cC(-Z@+l$ORG@krO6>dU zQ+BN_j|L3E)7ucnJYO@%snHt_NN;)CVT_EOdgV6_>wDJP1kmq;>kChcTeQcdQOrC? z-skQz(EMgKKLPvlhHe=?f-xoXob+${g@Ly7^MGL5UX@6agaiN6ejA<1>vMLF#&9Jo zY8MK)&l(*0W{~Ul8fVGem&mFH2`UlYS!PCy4!6qe@cq$1*RK=JMw(2>*TYHv{A?)F zgm8|3s`^BTjE?@Se{ZcEZx5_F{bvVz+$=(|Z*|Luc8>=7xRiPGRlnM9`IvI+ zW`x&r!Nf)8$j$Vm{iX7k!~FRi*N~7+${R z^)7``@)5D-TfPZ}hDFY|tLhUi2N1jln?-qKu?HwR-E(XP)%Y*J<4aQmI3#T~N<3xw z%p;!hxA?6VhC6feT$Yqj?ux?p6BnHz(Z#DXc-aq9Y=^2zg!6iqa1#|B(y<^J>gmD7 zC9B3SV@Fa1StY?xv_3GJPvz99p%CP<`*p!3ufWk4QnD~ve0(K%x*?~Cy6Kjyc{SaY zV>>@fdUZ^z%fM2^#uHw9ZIWAcMC>k71>M)J*2q^Lcyt4gPw@*Xf(l_TJ@tg!1>WZV z#@^PC|JQfg?dXj+i{YGvp6kF~BFiy-xF9%Dg3GAP)o+eHlDuyUfLI56@I|-qd%0}g zY*^NVcpV5uDD5J@+W?v3-TwQ$=>O%PY}=Z24Jt%_8gHF4Dv zp7s(8O#4b*y2+y$Q*9KrRP_kKGZo1pu&n)i?AyavUtIAq>G|2n{WJ#^&qOTk7a2C# zDLiUr4U>R0Tm;J4JgQZHj0=JrX8b5lzH*6NZiT3y!tHQoS^yW#TPr~)_j5=r8jZ<# zL+?TrFWN%6nSvrCT3!q5qTY@bGiL_4ki%Al=ALtO`w;p7-BZCCm*KPpJGQLN{vbZ*9QyjajlL6T0H&u$sveMlw~rPM`JW-^O3E@`K_c@ox%`df0dwVk9>(MHg|lWsG%lDLNGbU|aW4 zI!>e+ByQ52TV);5uv{^{`v4)cl4O#+Z(Nukm3R92zPmST9SfV3GBZBU0kWMl+XexHp8u$5mI2f~p36opE|zSY z^bitu)1S5tFF%_8&wRuJgu}dTM9sQ(3)r$Navxz+l!*Hw?#gn}G%;EpM3z9v3bQgp z&}S@t#0H61!#1((Z)b;i%rQ*#ae_WjpS`^ptlzoliu#@c{5d#*SbaHJumT#W|S zrAJ%GI`kVilvv*4tZ|z;_maT-&NL$_yAWwPu{XXcs^6pb19PB!@)C&U!x@+H1T*5uOjyaAiJ2_eE)8t>jOo|*MSyGPIU_Q zi949i9MCj$R|_grB7^k<;aXuwkf}=z4&+?ujI;J&;|p%zCyTW&-~#V8Pmv|KJ$F;o zZG&Q=b0aXMVEQ;LPwVb;L;6lr1yZpYD3n3$DLa0DlIGV`y$ep#SFQbMV-pi3VJK0! zS>J>F;f)2c%jdoq>{?49-!#vj4O&3 zs%mmtb=k+#!s55xpfTl~%;ANg8(g_5xpt2;CMDvH&}{#n`)Tmf$0(`%s!8M}aoGh~MME%Yk1n7CYM4=tV(NyhtD^{Pkga#DDj!*90PGn-pbkN`)8l}Qu5Pd;mwasX5N)@xJ8UR*x90kt z`IQHEFJ%txe{$>miuRW3zYJ*#nhrNi0DL=%UBbPrP00bAA$3Kw!Rl;t>i?_QR4g(E zQ88@_zpz~Q``_W@T3Bs;XqQK#Xk)U^;6lxzPH_b4*_Y@XSy9oaz9@1B|Ij^plX{}y zD5&-Xfsp)$RtUD8;AujNECY49Q;{HVVkFShrZ=XXZR_L}v5~|}I2SH!j@BvLp=yUQ z#Nm0cn-6b(d$R+JW|KV%MoXAJgh~_~78_*pK8SS~>~vMbNU+%0FUS1Rot-Kl?Gt;$7ejPySXGJrI; zZiAV@8HyE2qun7NJa>=qwfX62mxK3KS4r~LhjEZsT(pYbxmgimu<)D*xdBIpR^QN| zSdrPQ!L^$!K2m!^V->?oOAO!NM%!qWV@6r`R0G;az$w39)BHgR zz3{ZyNuZcRph-QL6Mm6}Dw|_U9y=rntoJl&y7eDffi-k^0dNAmdoG)4Srd?(_~y_J zM0PqVWST}+b*^TV@tBWP@rqfontzOW%4#xq-EVXM>0`gg_ZPwWGFXJxwr^>@7P)s* zNgL|xpmdkzDXXloXch0taP0}9$^}{rtj`wF8}ua~;a;g{J+mG*YZsZm<2xj1>~)-* zxmWo%c%oi-6PXf>VDvS{vNzC^EcUF*Oy{LA)iS9}NH$s$>@0}Kq=^sL8A+z>Yha## zgFIQ}mtE#fHVY_pMq?fZj~(r4wSe>A4w^b8Tt47)QV4qqQi!R!NGWl84$=7c0_1Fc z!pY&Q=_Sx$d4E6ZcE^&ST_A!%&8W`31_@rm+pR>wzgYhblDFQpyUiV^@lRJBx*)ml zb2801s)4yLBExYjFxh`)RAB|l;A2B?vgpE|VXpQEY?U@U=!*x@V*K=2QnD-j~ zJUXdSiMoZjjOdGJ31U59Jw9lsZ0{>=iZE%rs)ztCw0EXzHX_E(1nT962Yu?|t!{T+ z@IU^Ec9*&5A-YGNfs^;?x}PC$0#d!$U(fOx1tQGE*>3xPQezVz! zTippmu@%>|A?+sq)p6^uGjrg?>%u$e3P+b@MMiCaa$>I zGTCxLXIxZd_FZ_{&V(+hd?AVv1hl4o`4cHO+Ug%OnBF6NQ<4~DA=tZoTH6&_5 z{4jS~3-kgp8h_pezD6u+H_%CdHMPHQH$-#($iJ30Pp@R*AC9i@a$gbjK0aHDBk2&^Iz*pCf7PlX<9yGb|^ELlaXx*@=Sfqn`E-e*C#wiS2hP z#MsH{@>emg1en4Idv*S+7vtfuRr&G&9B;8uK(LtNKadY4T0L%e!bt*Zj(I>pn`se| ztApcyvH?U0xc~V!J6R5nWhXiNLa0S|OQ7%n=a`-A7k9QP_tPW8&S<>z-y2JGsGnhy zqEgf`rDG?f`XWO%U3Pr(8UqzBB7*~uyfm#Xk52ONESLXLs!RoOiPc2OFwJUlOiof-MFR$-ZbS$ZgHGXCR|#Qe z9yOGKCpi+S3cp!xoXX=jsj^RlLui0>#UO!u>bOytmgrc_41Rf?F{Ph{nU{yI?SNf( z9Qu5{F%<0hZQ_d&)qvR*ofijnr^347@NE+x@WWRvE?MpYjevN6-IYJG$7)wH zZZo4T5lKly!1O37HvpC!A}%z#Cwgh!g9S?MKNMKbkIAQlGbzwCZ``FiN}sm+y(sWm zKbQBy*M5h(?+eRi1#u)j*3z`PG4Qi#A080Yra?k?Lbc(`^Smjt_Flu$veqtn=3n6! zW!3C9!O()=O<&Cwpw0V52lh701H@y^Aq!EfUvC~*7zEIAzWpe8nPd76%HynQ45bp( z0ZU@TjEK)yONU_%eLY%}jbp=G3D#>`4ei-2PDde{GLARgSiJoldES!DG2gx-p z@DhECkC4kc#pe}Pr`G#hrm{auie$&EMeztVP*b77Nhuhz>qo<^09W|wGXN-Q=AT~$ z7bG#+M!HSmkM35-5#`k4ngR*U)K4DI$fW`@8`)3}6%l2XSCeb=mP#8m;i+f=W8+6N z$NW3v7+LnH5xbukBpd5?a+FF(vs8qr^WJI3^kttLr?JDU86G7W-Ra1Y2a4|W9vGR2 zzH{JtL#65(;=;&x*Sz&1Vc~V$&Ul9#^xLqBewk3%a$m<*x_)s)_0K z+@12DPEqecbTP6W(H8^G{9=KO{-PwOtTvI8+}mx9ch8V4lb|d3?7%1{VOfQY5C?gW zxBKh=6kc{^4#8P@WIUCF3QR)yE;86lcc{+uezP4~Ys(`ne>>Eab6R8KOKzv$42eiy zb5H4fNzJ+kYeMN9MlxNe>7%$0?ARRlnfGJoQWuQ)l2-*~k+4h)z9X*xdTpmI;jHt- zCDnZZj8|oE=^#SxxoK`7kET!UcXKfN>UTqBd4}ec(G6E>z-*>G{h7k4l1?cQ{oL&@ z3rtoFG9ZG$7;$%4`4vFC0WHhJrZB_dO_j0=gtju*x^7f0}4fECII zbYgmsiTPF@rl3W0V8O*U&}0(w>$R&^puOb&F#tZnf&lDGd5rd)RyH-+t<%mZS8Zz_ zd)}R7D2@U_V(OO~P#|dtWXg}#qtvQKvJEIuOcaowq`1FEr&NAzZBm zLQb}bPP4?V2X~uO1TIDiet8)GyKc+n_>jWQm*j1s z03{g@*0%klT+-U8ul8A0D#>0e9%x!r6v!-&3!(H5b_wVz^b>?`6sHh*_$%MTHeECf zHg<)P(V{2)eP2c$@DBO*AQ{)JN^fp--3q@(OA1j}ru||Ud|R3if$K>w*{IxwZ__go zba_q~42+W1b>Hfj?6|npr0(q(=as}HE&66QcU6g61i<34>SaI9_&>q;rTHX>hy}&{ zI5xu8v^H?*==wwVvIxJwaXTu?r=y4_pI9?}f z5;@yFrGqL=o-x4;{()0`Jv#kOQ$M1NNIBWCvL8GPSbqY^Gc+==``OLPLHVj*fz!PY zSxL%7H-OGJ!fE+@G<`u8a}6@FESZo#K+ocpueNEF-Y)q!zlf(w>oaO^bO#?I(?qYx zj8dqAED0p6!f2d+6%wtLh`8Cr1u#P)c^w>iBtbaDC#vL(rH}mN0lH!k=slEu=8qpM zAf*=JqK8p77jBCpM;UfyPyy2_;!4&lkK8wU@1O1mfKX*`JR9zD17TbSsl(sr-T-wYtzYoeVBioS{@d=9l|R9X(@=A+ZA zWK(4yp}$=Q$$1+)H$y;evGAr1thk19J$Mg?PC$)b(($lh3L)iUM2xy0#e$U;ha5u? zt@~?L|5-a=@fWXiKVb6Ou7E#CsK{mZ=R+s*2Tj{@hl8XZIsRd$Rl}K&pjTyAy?1j%l?0y1iNVPjB!$&;R? zO69mayZs>Qpw>jx@UM~as}K-Uk;rgi&Vxjih2Q7~y!kC{=^>82!Ss5^n9avfiSHj%DCw@huVveP3eiZaxxu?gLywTYlE+P6j-yxa8;op1 zeC{GL(VR<)P}z^8HdoKDuz2GkoEIjHDCn?1f(x=!*2h~e^Jn_}%#Q&VRLT`6A2orT zL||3pN}t|VWPTUTyfv|eKkS?11v*ygqjQ>Dl(#}?tGiw=GyyeGY_eL4ptkCZd*D;m zpTa%|_mV=y5}Fma&mv3#l_eH`ZitShE*%FSn98sPf@KmmGOv-fA2M6y{ayFOj6B{) z>-7c>QaI~plF$SOniYT_razCCMAU6Fn_I$!yy9I0+9HHplyAYw4-3`R7ombg2lA1o zVNY6!Oqn|-@aOw5RFN~vg zYsM-x^q@8Fee;}yKduM0y=u9;1rLpK7s6u<$ZbTVW7T1kdC?Aog_jhrg0jSZ;Bqh^ zE08<2g`~6>@C^n`@f(wWY6sP|yrYoQ45X$U(>vSnJ6d>O<3|{3Dd4~PlP7hJ|Mew* ztUW6HbF1!fF8+F;=MLED&;n}$NZbt!`n9I})?Yftzs85L^17pEkP@5rbXH{(81Os zql;=~s}oM>0h{it6|U*&8+wPNnvu#8)(0t@UNj`Ov=7$YF)~ZwRloV0;W17t_YJE! zC-|Nji=7@K{{I5`eslX7D!jNxn#PDZfv02+X3X~WpMMIx*T)|{=i7*Cs+=vc<(NkY z>lr@4?nCkEXj{U)>w z54|!Wk!ek*Oy_Y2{>7sVf=7?Q(M_x+>GTdX%bhWcuI76z<`>92&XV_-_+ zd9ZcsyDp1_DashW9WDZCbXP+A<;ErBrQ(9Pux=kO2dS1v%#~VgeJ`LF7oLm zpBdQ43R;T6;_TuOqTW$zHOPC<;WIoRf`E5L8ueT{;<|jVEWhQ*w>2d z#~$OwAia_eh=QXrhvH6!AI zO9F4Kf5thXd$9Wersy{FCqQco@&x4V|YG4q?cy(lXoxx$Fj_RON;#k zV1~g6&k%`=$Zq8YPbK5@E1Ep5L=ja7TqWdHT{95FexW|+U(m(&p$d?#+G>-HIhc(Y zo-{!Hs8}poyAFH74;e-Q-4md0SkQS$2V>wKJzh+H94@xK88ijs7>0w#5%Fua{7fdi zwWs89iQFDmi|g!;|I9t2WR6<&iSdREQezua&kfb&JY`%MtadtNgl%OO4k znr4(T)$Wanx(n4g-3!oxX69>zeIs|}O9&>42s~9qCaDLb)oF1-cg(Mm4P|8+wY;jh zb;kyJFX2M7qE&9MFh}44xOL`zBo#|NGILJyTS(;ZmnE%+RFPEI90szf{rl92o?xqs!>m^WAgEixHI|)0dR=aMNvjCfnHWI@<(Z zXwUv;|5^xZ2W&81zksb-*8O;`^=I~W_dA=Ms))85w(h4arzX)_H(c=1hMq-KP57Q+ z6^Zx=QRU$+)3^@teHyK66kl7jV}UvdPqWK=dkJ-9vxG?cjN3x|dQ5tXaA|3m$JpQa zHXRoDO7En}Fy7?`8f52KBMUG^PL~KP%Gr!dB>c_R_ecgbA@qD>Aa&es-J3*;YkMYjD0-Fx*{zoYM0xsH(*Mn$X^c2UhvKd&!sZb4 zxAr&UCaFMbM zwj9NJft~>3($s>ktzZk45=h4VU}B~FMVe}TlIUQvlbk4VsK~wKs1HWBQ{@P9gNzVD z0NM7+Uk4F|h?D733D)_$Jz4-jjnW9QLR;wkJeMApEZW(FLdi|>vle+^!ARoP=j&~s zwK($gj;(_jwMzmq1xcaTIQFg3J`8hXz>m=ID}8(#Jg(;8?9EN(cA<(^OjNREcpi{{a~wXA(gVR zE3-Z(|M_!`nBl^7hu6di*DB$s_2Wc4LbKA@bYL;s0{y0Rsu=%?ouy4w32Bw^=fUxUh40>Xf{;$5P;<;oG8CvhZxoUQ>mJdfH?9T#sO3=#v_m&(I7 z5~~ZSOlI!z%@Nnn1xvJ9>ym}s1Jw?R#X->oxt(QU&12W+h3L)T6H%4av{Mx4zdW^k z=&q+lb`?S`7|_WLJ0%u_0{2H5Kt)5E=1%vUAWepkuBoQ0yjT&)VU_Yg?FkXV#78gA z(4u$hLw+EU*@Sm5x)LR3(CE5LYza%~y}?WEP@4&f*_Cebf6|y}fYs8x<|?a4$(JHqiMJ2SCtrI} z5NXJ3iRqZlJ}0)N1M;(?^0~&GZ>@QzOQwSSqR%-pMwZ{!D^`_0$M3&<8Y26=+>Kra zc|Pv~A?N)PlcaEl;JG^6Z0p^UNIiBgCCGX=OxjbA9ds-FG&H;cYeZPqBCg^{+VSbz z9otXCbH4PsxHv?OU$mbZ2V`SqDOL2~x~<9*{z*)z7JlbviHy`)f@8Mvti z<#w$;w?BX3z=B8$yL3}{)8Ta9>IZSUtO~n~ni&-1fO5-=fE6Io@Sr8Ji*SM5wKRm= zQF2JCVVHv7^Z6#cK`)sf^i>Z;)}OPkt@UB~yiJpkuQBx80LuEwZ3+U3MB6KnuHJd! zxRk0YY9+R+^j6>;QKV({5#asy)~00 z!!5|GAC0@eTw7;($SXR5?%Mz`jXR04tz@P;?}qy+OlGqrJRveUIVPvl{C&}TT1pD= zKpx|X81HyQ>e#@%L{@T_M_Jw9Qihl4YgHU}KNcg0#PmY~)LgSeAI~(-p$$Cccp-J7 z?v>}&4&Ml`b7_hxvtkCOj2_s%nbbAz#1V%*(Y(dmu~eqqhZTq+|HmpDDSI zmDyxztW&@aY&%a!s_N6RUhzXS=>m*42^bp*75Y<~9?xW^MCzYrBC6KRvRO+VC1P~k zFU(VxW!H}siYw#hFa&U?+T^~uAehJmHh68oJ6LU*3nwH^4K-P^S@?O!f<{VFP-u`- zInI;QA6Rc-qeOxiJ`mc-MB`L%?3i8le(RHW(Twj&_(O{$hF{>W9(ca^PBTESEASO; zpC|TJZpx6vmYC#NsPEHY9f}ru03&9-Kc5Y~H7;$4 zxsVzWB#7_o85Ut>OQ%4v4b+nGZp(_JgP`Q-Nj)LJh&D=>UDwo|~LP*@Oo7 ziQk}BoDEufZUVnyJCX+mqmO6mCAC#0_fMX=GM%k!(eZ>KRT-aJ%3ua>&zFv+$&{Lx z5j~ghlk{5jxTCNhpTfC^%iVbPEuf0Z8LIw z08u-dTX3EbAq|<#WFAjhA-yNSLXny-t`- zBM5TWpb%u+G;npEU`O?8WE|LJngffNaW4VU@@lUt@m@!eXjZ_eqOc} zgkY6>)~MMq?jorTK(=`W(J53khr2}&+G)uK3CAc^HQUDaQ~;dZ4k zCs1PxzQQD{(z%-Tj0wwuyc%jm6ZNYJH}kS-Vh*)Ex0#+c^Pk~Z{1#GBY@_QBY`F0C zS>)vV4tD>sfQ3sdDpU*d&i5Cjsu5T`=>ECS3@(nm)tt!6VGkhKL`yjrnT&)H<~gat zy7%yWycdSc!bStHvyDE06!ES$fuk%MpMKYQcBP-dVUdAAcxNV2)Lej)dMNIbsN3Z= z_mwCXC{qo+GbWnKU*eyI!d8Af#iNQ++|9(9avhJBFLlU?t|MWHx)JXci65!IZZbzX zrrAlh^|t1+laAU8(1B+-bo&ba$p;K<{a@TN=TDMiP+ z3rbAk|AWw<<`m}&!M#c=CYjmIE7`}XPzC`VdW!|Dl!KR%Us(>L>A4)Cmvk-^-@ryf zu!Pj?HUK72CU89hHD8M^l4EZHmxK{bqcu_-$D94EVN^OP|0H<;e}oJhSNlzDQ@m1yq-xLFG_I|sNnFGT&F|Q`u~Ui|DaeN*PJYENsflN?R03L0 zP4Ykf$$W3|11+cA6z8b;HIZ!u;?3d;SRLtj$sQVd*3J!EAjZjtCbnz`K_<$r$>UK| zu8(6vK{eX0wgX~bnNj@$nwL3{UIz^9#$5hP?RzETBwR<#ReN0Jwb# ztK3P!PE+bvz5GT_C4SGpxRYfZ)tqVd;3Mq2d2~cI3q8=U4kyJTvKXpkr6{fJnHS8Y z6T)%Bfde(cP%suAO|b->=Wn-DsQ=ACW1y&XJZe@_E~wvZBtL?6%&1f(E@SbO2|eHu zdsa*x@%egrP{BH@%=IU>+9E9cvN8qAHPz6}<$rjYHRs0xwzc9iexXvV=2GB>arF*S zO9Rh1IO6_%;;!H}Gfwykthfi+K^N(Ho;m62V#>-LdxuY_pazy%%}yp#m?3|BBfryD zi2cvQau=|~(iB}3Ke133F8{w`+)**$9=$oD-8S`1e?kGf2;r~G8ntwn%kLX zTpG0ME6_~)5`$7rH{M@}J4#jl(JRhtxEy^oky88R$wB>139{=Ns(>;g5mldyEXx5a zcJ&~SZj@zG9JfO%B;E(JH?VjaMK&y0kpn04*osPb-q;l$rfLK7F|8(vihbG3+4Mb&H$CMWb`P`%;7MXD8)$>4Lnt>0T_C(-qc?=} zJInb8_VyDuD6Mx&7oUNzWxoFe0rFMvIFoBVaK_DEhsr%(ABH>S< zp>`pvzDfgicUncbPvA0J?aE#X<%iWFu)wSEHog{m0B|% zX=^_&zAvtF5JSHqi&hNgfw7_Z?G5oc!*BAKGzt#AXT$#jf{`_V{H4{*Ts8YGbd~Rp zu)eyP%s#O%c#+Le9D3=Z=|X-WTz$ZDhps-g;XBu!_7h4t=0h|TxX>am?UhK#A_b3l{m;{RU{eTiNMlrd|i~VVy%# zS6B}An1X?v>Posia7vg?g)Rn8U_b9QRp zct!kY`147(e05T>qnK-m1h(#AUG3wLr{N_ftiuSlmxVdVS&9`Wl5OogrgafDBNtzs+hzR!9vjlSTKLV zb6--rgnDqJBs(qwf5pJ{JWBxjuP@2CjQX~5v(o07V<9*YR^ zNIfZ+)vVezc+T#wGFSBQHA|Zq4lv1H6_lRX^>5YWZe(md zMuFa|*8q?f;1eo9%LdTF?*5)XUaO6xUj)>SW>jR@xWvEnfZEh2W6=R~WIef4h^$EG zKwJaY4-91uwONocizdY-B-CD?!*+W!Nx$t2bB9#;>?cT2W{!PU#H~!Mi2g6xy;Ei1YO6 z%3MVn+UM`p6r=G8J<%$jEUNG9Wxy;Qn|b|jYJQ>hc9^Xsrk2uGP!3H~T`2YJCI9_0>x)8z~vt ztr~#EVB!$N2zA=~;aS%GVnuZ_U=ecV&{k!QV3Jt5qIS+=XI-DT0Ece(t-!fVc@iJ<=J3!b`WY;u{N+706^Acib>Ynh7aEzxno z&^)Bt;>!)dI*V9s-bTLfb`+JXZo$>D3_TDe6I;&QVy{?3pZKVw6X9^@d>y$6Ijh$y zC5s85!8nWl`eaJeX2P0^hK))$ck91WBtb^5CKW{P+w@DP7z3gmFensUcM8kx$$ zR(_&gC!n4^=i}2-n~aiAix(K~OZUTc z^|y%CY>i6{9@%8M7sAO|2*9+q*R)*;=FKM`z2$=DJZHB0Q;+TXEA7RWa287UBZ=6K zZb%1kbu>4<$@;|0pX&*D;1DlA|IV+$^&~GS`@ZoWil~@Q0)AYnYCGV{4ZcPXceeIi zdBN@=GLEStHDH6Qo1U6cq_2cF-l(lvZxtL*(54Ley&m;K$?rI(QZB@UjAIijJ(o96P7n zDg1aUE6KMchx}SgnDx-hyP^seDr3<9{w8c4|eBI-G>daaX6 zBbGr>2}P*!SJ{2Ziqu>G6}5_8P`$}W1JLiv+nz-Vsulj(V-2s{?XPsTRWXzLZ1Fsu z-xBgK+DWvd@64pKb6o|op&7Bh3o(mVPOh~IEk-XCQl$GT4-7svJ2-oGl>$?Dju>na zKCuXQEhq)k)9B#F;{YpDN4PluMqqs4FlYBgLn2p<(cu+Ixk?;?Mhh+j+^Yw61oA{$7@#Y5brtgjaeNr_;;u|l`6MDWVH zgI;^DgFBc}yttA!L;8YDpwT7ZuBRn>!aS~+%NG}*cz(q$$St`9^HThobZ`Ghdy2=I zPUm;(3MZ#U_$JDoHqe1*Ia{+Zc=Gop?6$ zpy=UST@Pd5GiYr#wuUTrm{n@Ue9w1yPVd?k;W!%8XT>LClvHvOs8zvla{jI3wK%vD zr1_ojs+3OnGC5>WtmO+g(*1dx`a$xl@vRVB=V4MV*G7J2E{owkg{L}# zYh2}L)`j&^pau*wE-sNlsbonutz@Y%oJ58l<;A)B6h{ZtQAzoEoeIeIOQ zVoSb`ZQ%k}A&DfHzxPLu2Bss9w6$78Tk{mXJmANLud}ObkKuf!g3usDKga&V=5#ug zHYHQAf!L(@$vD>giszm_LNhWxPpC@~k&cRM>Du@DGzl%03D%Pnv%y&^gmqm0@+fG0 z?iV8m1rr8EXH zgvHRqq)sulpA{B(rnMP^yHeF#+mm$fgdZk!B)$~&d}dO}jDguMoCWk@ACcNmrXJ0t z1r!Ep_#IW(*=6rm)8Or#}RS9l>eJ<)&7KbHU}K-j+yeTl4R>3xDg z_FH4zz%J59cHi?^-0fddigE9aMy6YVpse##dRou4y<%-8YK!7s4|p8x zNXk(pxRA~?vvXo|ULe4c8eNq9%jpa|ar3n@1bllp;}0MhbIjV^Ig#Jn-{4fPv_J{Xij|Mlo{brcMb$)czv9(H819 zrtI_XINZ($73BK3*h|R3?xW(51%{Is|LM1YGEPAw* zC3}X;l#_moLoXotybke7jdp{8jZaY-zGCQDO& z$OZZBPk8ZCk*)XBR20Sgx5EdY_rPccOfZ@MpQ~%<6LPKb`7OxJk+HbhWxFmXY$etc zneL8`+beuhPb6PbVDPQp%c#{6iA6uMhFiz`u%}C6HH}8>khLKj{IO&61jRt8>%Dn5 z`%s5%YH>G7V4$c&dscn5M=bUStX|a%d^hor1b_fD(1qjO(rc{z5P;yeTohI7G{R$0 z;0a-l_m`GUGSKhVAK*$tVP)g=#xsFEbla`T!BV%thoS|xBNw6bn*=w4!yEIkMS{n72Nr+Q;Wvx)0gRFu&ZeK;7zjU(^5jy4>(85Bel}c2H+C`5Dnv zNO72(IF}xBt)w(l%q5Pajy#8Qxs|<0ld9mE#Z?!qqzKEE5F7!uz&;FEj@OB2*A2$K zpH8bA^r%#~JN*&fF$LEEJ z61}4T6@6oO@8>1(MfdRJY(WLIs$mGKU}cE3ocn0=h4uG)=R}w zVSpU@D(+-iY*oWt74l|!N^w5!p;xcSmQ}KSy1n2z z|M@^D^tCqnh@rXLmS8m0V&E4nZ-;XI%*w3@K^n(BsMz{0@Egajy$BP1-nGI2JA8BC zG}P)BS?Y+WW{X-cgj#+Zj#D-a-u12bMV6n^6J3zJ%w74L%OqxSg1*)P0GG{G2_kB* zH~8rsLytjIw^_C#1I!S3C*_-bav0G?U#U}sRA*~Wg+|S2P!uJI^-T=EIl68-t&Gn0 zA34$fT6GED$;pMlJK8FZzh(@Fi5}Gxbrc<#BNVKRf2rT?`Ji8v!97^IdhTb6Af=+( zW{CyX#qu=hH1781uX{OL)~Sxw59L}3hVixv^*P==WxmK76lh;iBYzGD9UVFPJJw%4 z2c1w9ITA^;!j&FzQJ@)~AqG5yKQsEegZge8aEqOw%?X(y= z97oD&Sq%EP^IH@}>YXu3=DVZdLYls~zLAxc!tD${1rsIb)lAlz_9_T~a&^bO(3K{D zu3l8F78#!(;LHkBSaYUyZQo^DL{7GuM%I3&Ri^=yRwi@yR~588pkYu^f*WG^Nk;#3 z-+C&;e6v3#7-RJq%$wxq3ZPaL1S#}})J8S)K}K)*yh7nWgftN7*E|ISK|P+=9YK~9 zqA+%#?nYqDV+Io0Y}mdH8nC5oU4gNqYr2}-N4FiHj%|m+K`!FJ-?bB5=>3(ZWCTg+ z9jm0xgxt9nJS%lZc2VeM98AJ9+5oR{=lI;qzI(mK(i2kb_)&`s_b`mywm$Oi=ylu1 z?p#lq*1LYUjHCsx@I@r!T0j7@UuQ4|y#q2p4^vSv>v%whuovS1lDJRJ?5>fH?y-cl6AA zWuWB}mVoqp`Fs7^W5PNcqQ3|S-}fn8$Tl4j1_x->Ss<~?y;Qfz-WA8q!_H6)k=i)t zyOgr@%1Wg)-TeEg25*k@Z%PhCPQlqm`H}wR3zgP%e9sK zqQJ_$3)8Do@4$Mw2KrHJvkFoN_oU2*O*)GpHZN|1t1@7BTw*fw^MQXJS~t`BlA%o9`;Y)|qwVSy zlZLlUlsEU{z>9wDi>%-(7xZ7=XP)Zmjlwu5*fHtwAD{pEBY*Xr)e`&>$Qf(dWS5bg zfF)QzihnXpbN6%>V(CH?9_dOa3!_o54<^wu(TboxaqMNKJ;f+uk1kF;40J6HJv5D) z#*>}eKs|gXJKCWh^}jT%qK|mvSGw4%3vt6F?hpBLhVEzxx(m14149TJ?ILe$e`N@& zm;PLVZJGI&Sqf%}e_&Gl{C|(rGb`~h6%!HgtWku&)l;Z#3K6YP6ugI?*{F*vV#U2FzWjgvNg4W+kp&~AJ*lQ&eoOc<6>FeCt zq?o%zCX}fGG;w3Sa%X1|0c|TczN)TO?X51p0KHayb5S>a7?#VlUS)i==%mq=YK&jeh)e1wVB_1rJ?J;Dbk;jaxQvSgQMi*A<*(2wF7Tm)-v ziS+8~rQ$T<{XQ0vkRct~Q*P4WY0|aW3xA~XoJ^RtJ!Ca9qo_1E(Tp1RLGGuPj7?{( z7pXC2z$C~!1I!wM(i)k>o*X~#^fvygEn3^TBf&L0d+Ekh)_Y;}>$f}-CdYNMn+sV` zsZ6UK!N?S!6NO4l#^isgS_b?f4Kytda*`BMiHD*-rofg^PNPQB!f5(em_zP3XUlAqNrb+?xkJz+kqAKt>%`n%;Zi!bD zqEAESC z>}e+iV08%gR>z2g$;Y5t>-|=IW6tODM`u!xCwpSK4#3@MMm^*vh`L^bfn*k}LFr%D$sD z&anzuq^Rd68T%Rb1z?S34o4eJW$PN%!S-F5B0&n%1!L<{lr{4VI_R8~2+k81smp3} zCfq7*{DDv{L-+e){>sycJ3_hl!0b9H z3xvR)#pgI#T86$ELz4hbBO{v(!>UZO7vFe&$boY?L38Rn)^f)ltruu7*H|dcv4#z( z-iz;yw()K*xV{<$B=rwLjS-5mMG&Q+z(AvqAjg`u^88PJ*RE8|!@pS8)mX8j*1}n9 z$6_8fc%Y{^P`6S3pX9khwO9J^m0XW)J|&F|ym*C|XGyW4Hw~(#1D+GGEb4w(n-?u2 z1y{9r0p_9u$f4CWHOuqUH6*d-R0!7OZHwSimfsoIb%g8Vs582%>{Gs8D?k(zC?@zs z>gr9k`3D38?Xd|Z#M^}eJk^z~E_L*;$N*vHl8*4W@4pB)RB4Nrb=7CcTJu#YT4%-& z&MJ_LF$th*;c2s`ba)7qWA+)eeGc3q;Zo!b~^(br1QL-D+F z;C4R0QU|frY2R_5TqG7Ft^il4u+T7s1&m315BF<4%Py($ij*id|9#7ohJ_pkwSn2b zl*jA}4AZ#T^7-oYYg9~ycGRi1n3oY#8}TJ~T=Ayhxyg%`4QQ+f!n;Q#q}f_z$+EJWl4gpB6f z_K50Ua)v#|tUaLrqt*d7sEkIKk+-&j%Y||?D`!%F#w{};u7}pd618WQj`RmSk~I0~ z6kHSAPoTWWbA~u#vqt91P!CFvme_{rqf68>Pk)VE8O$V z?oAxRT6luk)Z0}*y(xl#>fEu+M6gkNre*p>VEaKrvAL$sU&+Y%jcAz>bY|*jUbAD<+s6YB(c@tB^2;w`Vee7Ig||? z{Nz0^BehKTc51bT!b5;HdwVjvlnjh4BtM65F;9`|O}7$hiNujpi+F;|t2!IqwN9Ty z%Orgrm0E`;=1$K9Z?_Xu-tS8$=R5~5#XJ0IzsM4I*uewR1Gj!?(MJ~Jh^lDdC}eZo z;}9xK)sy|93cjzug{VTw-2s=oQV>$tCWzO+#z$+`^1o{yv_lQNDJDHsWcAI^)idja z3%Scr)6uEdu5d_y+qgW2uASSuM@S%aCPP&z|E8HyDCnyzQaVu)X6b@dTb6uw5}Iir z))Tg7pZOLG=K*c$zmD(nPvKjtZbdD=>G-HR zi+Jh?aUJ#MjUL{Ju*~jL+am>??8V!Q#@HXd??;@~Cw+A*E@;BYn4`r6ebi;+LwEc* zIjzdQ!WvWfaa!rVi22lz9EV-*-a_|Efl&-qsT6M4rU&e5U;X~tomvDEd3Y_I|4yAZ zTc(JZdll8AD^i<-4PV0JC}(8ZkyHBs_@B3=-Rb(_Iye;Tgn3z9z4(3&Ix4H2{&MTL zd!hmV!u8nW-G}KW##k`Ro_6&%aJ2NYCSnXrqSy9?&j!Vvf2WK0B-#5SkgJE5nLz{M z@q@{%&!Ugy!FVUii`1H)Cj#H5+ecz)K+}Ctx14EvE8Rgv<1)3;g5Gc zf&WL4Oo|`DPiNzO6L_218{V|2^CRk>r7zF8Eg8o>eP+xzNKv)GsZo2e1!kev&zogE zXked^5NM2LC|Kdf(dV>>S&ITSs?kdTva=JcRSKQR(s({xy zP9#Q0FYJIl^$jXsW11)O+azg97P0=ie%=KMbQj~6bSz%;79DUlAqrHl+a>~HmXco0 zo6dl$0LGd6Pe1V)1E-p7t+lvqC?GBRyhtM-L&N10shki`xc;R1I=t;B6GP0!Sm*Aa zTupcHFTrZ{lY7xDcD#@!^{STL+&KI-8J#*0%ybHE<`Ee>H3NACq-3>4*CSvJHH?Km zYu7NANKAIUK~5)9f{h|k$j{O2>B*I3K0-FSp!Q~|%(dRz&M@vnGi_PzDQ_8vo&u>% z&bK}%{ksHeWc)22xHic&5zC<@kNPl^+q3iO*3Gi)_f-4*FiNhP{Sti_u(|^bv(P6d zGMhorqSG~~-AZcfgV0Lm<*khCFI_LN8hpe zwKgm~s8M-fFqd!dIL{7-=MAH>Qji{8@I?meEdwXvEU!AmGVCEC%gzf$`1NMa3cjOLhw>a*7U4~obIdi^3pFUs$mH?okn|HA#J8RpQFtKjLD=%>6 zE3od9sQj$1>bniBtZv9{$mAEb0-L|b&#HU}B#t?!PzE@*WjvbB!STscvjDDNhL!!A zr>STNr(^BwF0`PiVo^yJ#|KtBA?g5gPj7y30kwP~cGTr{9kN28K1xu6Om$_gz{~N) zEO~ts)zH*h|3N@Tbe#ylIa}P7M$cUAIi1Vx9J+m@OJY`G+IAd>%dw)X=$w7L;sd>5 z&PoW|7{2i!A>E|20 z$=+avC5gwipG%CGI+!t zbv>t0LP2HE7M$z|kgH_J%0ViW2(IW>@>S0SYp8DU5%q&iI+;2m8VzA$L{%LpnFCig zy8Z%}i%tvi1XWEsLS?hqle59YN7HM~SS4|uZ;$f(h>Jp@_;UCapLydEimcTk+FB7U zRUfC~&+etu%BEFE8vqk6&$|M^(Z7MEUoxJ-=5$$=VQNn8RiYzZIDobDJ!^0kaiZ1! z$3ROkJ6emq{Nu46>#2r(e>g`r$Va6Bat4ZSr`yTq;lT@236w$6$*=X5sL*oYSejo1 zs5g{}1@=rwX(M{E&rQf02&P=5l%(*T03Kt5u4Qu~q4}y051%OurA9b}<&tYnfK4Vz zfAgT|sQh|M>z}ftBEjuhTalk9u{ZC)Llu`2LX)ILD}II)hV8=(ZwjHwJ7X&M4*JK~ zz0DQ7v!T5M`P@iIAioD2=}PwT*5&TN^F6U2foJuu+9>k<7+kPc;!nwhDJiX$d$bL8 zKLg=q6C;GoaqzrC0k4wOEKh0qP;rHXC02c|9NMqnGjpXR4oKb`3s|a%T-8VDeq^;Kvhr;{)t$ECU$mlkds0ulf(ItI#VY5 zQ>|;$Z?~{6J2+Pdux52t3hd!Z@JbY(!z(z8KXKgx>qXxs9DV{RbuOVQjZO_M?WEih zm^ak;mI=RE7tE{Gs-f21>CAm=W5h^tC{VdbR5;)MJ(X{+ttB11rCZr!_zpM zxVK5J*HqDRV0AEbidDL>(LaBoN8M$+LMx?kLYm?3(77?XxjqZuLH+w6_3UE@D?5z* zQoMap2r{0bnXGYuzVtnLH*O77`?*?q-kVN+Iaa4Lxk(Jv)#e8XxRrG~p`T2x*8YgR zVh>N~O6l3(MGGGukw|l0Jd_``nMYPD((rF|#F12>%_nHsS7okRI_vXcYeu<F&MNRk8(p;XKk>)Oxc=1RD-7qvP)330x$Obu3&9?0 z0S_OmQq6My&ry7vh3G$DT@L8Zd8Ed;2$lIYpUQQWw$atqqia2&{WMMhP7H+S=>B+K zaB2}c4Wf{P`O#A5;W6v0DkGy%i`cP`9j_9coCIkI>$)S0LDe<3FzryFJKGIK8iSN| zR&6)^`C=D5fG&b#?j}yR6+H$31Z=L1hkOuNLzL&_myHewO+KuiK#istK-C-LD&2=@ z!1)iyA(HANo(Fn0H4y!rPA_k8FAe1R33>S3kx=xarsaUkSOQ>;v@WoMUgF{Hq~<@r zos@ZdXD`kWp8kXpx)H-!XYxjvUCmQ>ZM|76(kCDTRFWb2;!B-^YiycSYEv$G7x{-# z2t>^mOv^s>wd8XOyTdiemuMH+c}f;COS2F<z|ZQtly-# z@QRLMyB)TJ4y8ST5o-UBNf5e-`UGi>7ibgJg z`dsyMLTsv|NnF9uszXNSxBb}TXlX56K0GkU0hL8X3Fk4Em5{NyvB{FW^4a*7LlqzM zXvb$e#N<|79hgQW{O02~tXofx z^&=ovK*Y{WKkzl`)U<8Es*t*f|=D%iT~WL`Z& zY~h9~T}A*;0f>wd1!N&BSlW~EINq=-?EN-U{fUK$y5@bAs1U>h#?#vaQ2%)G-vN5< zzI;+q9;cRIoy|sz9@}Uh+^+;s-|p!qRL_4hE}3gKm~#R<6<2wDBC{xE+Dh-Rl7h&u z_4uH$k;|tNK(>dkDI_dsoS=F5j;(;OBh8vrG7@6o3o zzmjTAiT1szzR!?wIfteC<-bHV4%L<_`*h&Q4LPOfYJ3rvIpn#+kcq>FPbn_&%M(M8zZnM$Fo(V^;T5=vbii&o3$uf%l2HI~tV9?4|!i-XA7xaKjgwkroIyq8@)zNQ4%mL0;cth-Mk;c72I7G<0@ z_?-h@58%RxJ~c$=ZA_x!=RCM zKbb13t7ss!_@TjdozWV0WM46P2|M-kHI3NZ{M(L5rxnj12YAh)vVN~#;5_!y?)+lt zB+Au%VAAc3e9KlKOC8wsAqNl6q16}KFOtI?eW`j7N`-5ti9IHR3c6VW6fF6T4`RK?->L;`4&OF&mg#o32yJorA}jEEN#JxZbmao1wgwPn z%98P&Pk`LnQt1nja5I)JrnKnv>O3nG!ovzSsS6lpll}e&f+#k!G?o8Opl5OvYPlX# z?Tl_Mim{w%{(Crk7RH1FULl;g#a7%h@#62&hti36+(x|)Wp5XoF|OvxBhx)*yC^Tw zAOIq6%QX2pie-H&xBT6GZprK|>>9t_0#N!bN&3_T2XEjpe_Y(EJ0=$mnJIU~C%!R)b z5L`(CJCRWA+ugeH#Ae!ilh#zr{{u~l*A0WWu7Tvt7PShw(|A8wvYE|&pAiZYkNl7K zle%0;l^#QRFhqxO?=pu>QZy?@s@G0%dJ&^K%6N(x*Xy1SF$7C0Hb}u@AdSAHHg@km0LpK1AafeU&hWN{o8J!lV(3`&L@#KiJ`X$POwF`Y5H0nd)o@OxU6rqHuK00XQQ%m-N;Zh+oO|>zRPGm!k#`o!wq1seOfrb;2h?VzTUa?MLQD#{c3cxtiz0pJ&0^-XOiLPkI zHlh%St@*+j#hbM!PA0T)t4!zabfM@GTtp&v(v4|2yiZ<%?T1w7(t~(2A`m7izl1%dxX2)0B+V)#x3-s*3l)x3l#Kd6 zGaRQ-$2E(C9?LVmbKyUk_E;nkliFMS{WlR5 z1z*S%ZeR$jochlVUe2P&H>3+c!~`Fuz1;MJPyR9Pivv0%MK#X{eh2p>dV2WQcW;{q zocL7L?Q^Dpw9iB~LGBLGT-j3;U2ZQ=bbf=8(7pK>`f~~OaB(^QV>GTRJ2xE>j**44 z$iq{AqS}Q4W)cH=B&SjgFz|RHf%vgx=@QEM;A)GAdaQ<%tB@2kAgBu(*@QI9Qo=M% zQ<7Ro=*A+;P)1h-u*QGdhC+on`;=o|`T?*KmuhH#j2jFb&djzv+n=E7^C>hE{%pUH zNGstv?`2IvUOq{`O4wF9oc+a5gvyE|Ut$o#1WLRzI04BjpPKv~ zQ4=>Z7^Vb|>^9UXqt`D*ft6OGI}sLZE+|Ob2|^n8i+AyYd77@ zy^2<6)FKEBdJ)J$_zIifsBz$`1%zOI;XMG?E;hBj^Ov3}QtN71TFM_D3BSzP6~j^> zFU%|xu#4w+i7XB+prUzg;cFcd*a=W@I?ncqU7q$cLx_ZFLWHVgM~aRte2jDNj#{of zaoP36*YBv~!FOiCAyJB(j0q@Qf5Dc29ByOjo0GzKYGW1_f!itmxX%M>HaQrb=Dki> ze2XoCrYCsq_r+C9qw9sQ3nW-G8h#a|OqDmuv60OMh~93XdtvY%UCdoKqxV~Kl%;Fy zkG}zj?#12oZ0h+>%jU9dNsb499fV_ROTyB7PnG7Qv+3_KMRXrH7QnU8i_8AG2?*b+ z=|=#p7^MA9I>B|u9omX(81f`=%yFf~O1y$>{Mkd-1h5vW)KKK~nHl<& zakQOr8SgA9Rzy9C-5&iYOrK=ZCJ;$?8ZUt-PHP#62Xz&WBHOarWvO`&`A_Rdi2ZXYc4bI20t8@@PS=I$_vR4F?$k?Y@ON?mL%WgPh#wsXgipcXvV6M)^VL|j2Ki()#SPT0RA=K~k7N4v7HT>KUI7hqT`9l$ zNjsu5?z^PfAlK)sA)C$&+Z!fBRS_r+i1LjtB=ex~X+ZgbgVt-Z;KPU?*d(9;c+HOl zqvtp-k1BWxoR~xm;+j5(PSyFnNoEzX1>r)m!L2f8=z$`}_nLx*<-J#Ukunch|AhDm zh~g6q_AEJSiagsXes_%gL_X9XM?9F<^pW_hK}kKhqYD<4h6-kpg3Kjg0;z`Y#6)lR z9mJ5Hmp7p;NF#XlJhXuN2J#q=DM@tS`B8#z-cx(nmuV!jB19xVFqXy5SfIPxFLx

    m+LNeKA6Bp?RS>!D6bil<|8-0`(m{j#@9&$7mBt)p#u(= z2T3tY2JYw?KD?y`VYArPHH44G;nk*pTT(=2yIBX8HAWSEXlc=~&;sZzzM&IE0s-<7 zjn{{X%&=WpNBXxTy%i)Fj)XHTIwd4o@uVfXvd2=j$|)s zB<~O=_iD+=W|htuC3zMw*S7S$uoq8Aiy6~7W?&Rjrt#^$uSv@2GS zn?WR$^!^}o(jDKq0IIa_*dDeekN_GM?T&gCi_^?-ZI&A&Xaoorf=-1hD^_o$sUtm- zsXVIcLfqVpT0HweA`)NWalnl(M0kZ7q7Qdn{>adU5O5Kf0jZ6}Eafre+*gdI9fo#& zz3u{P;tc0c2O@6D;Ag+Ur0jibtsB+kI+C`_+I}5fH5L{jN<;11!Yrh7k&<#3tUcfF z`yu974iq1ho*I95NRVsIu?3bk!xP5SpeycBcnrTxA^`FF5eEXVJ)ZdCS`K@mldG-A z&;@@<-s?{x`tEPv_!m5}O@rf7AYN{lW9W=+FY6GDoRTcOFYf&zY`Jbk-tLqXlN&4v zJj{dJ{3~(;#vI$gaX|qU1zz$hq9iq=mQa<9m7bMfDElbCWG#3C5IxC6;h#4zt#xva zyElufX99-tj9oe>wt*F|e&qPyhA{&-T`-Ymk0TN`1Ng1a>Co%;_f8@YziXvD#EoM1 z=$AQ9JAx}EVR=-`kM<6eA4wwzBJc7J45=pW*1?Xob;FGmM1r5YA z43Tda7YK3At*wwn_5iYElb+Yd-vOlE$#$J^Xi%SWJc=Ie++r{eIz({@V`3B|zb5WH zU~6e1p&$c9FS?6C9J^9O;YyBrgi82>dF0R)U=p~Ofupq2FbOtv;auqiPJY&^NEzt!$ON4|a)2Y4pOdW>iX)@3!CSRt$pkg2*psG zzNn9kTY!o%+Kj)kLF>(N3B{g>j|6ajC=Jdo$W@;_^6{mWb4uiR%68Ip9?;&ydl2M} zyZN_6LTB0!sRJ)*B>daO7WY$ic#l{uFGV`|6X~a$w>N4t_O>2grNW}Xx}QsOe-eyS zm(KFuOls)M9t!)Cc;D`dAGq+vW>TCbNjz_6eqg zDn`lH#vUra`bdf-yS22;s)?;dw>2C=2PgH0WTO(hP67vE(Uhu?uyXo&wJT@0)6 zHPGPX#k#2q6IEcflZm4s?Xm6A9=4z%D~7^DXE$&GklRfi-$ezmb2qg{Qr^PlN{o~% znw1z-KbYcNLA>zw9H51gpCL5o*gvg1F3k-=>1K3VG_zCn$noKidj>78C-c&fX9pG3K)UsK)n(n{_(Ta4vz_wfMH96?mUs7Dm@W0t6uDK_Gr& zE`Yy!|%rbEKX15UbUM3`g;_>RFVF=hB}-D#g%R57$6DIad1vSWRa&Pj{(v zhu#KycrN3EMs33jwVw~=o6}}buq6cUJYq6aJdWIN6!8^U-CJNl!H?po82aKb2M2mo zyZ5`~K4+VciSYxZQ4Qi4hHcOs@Z9n;DgA6WPzyy9sRzgq3h%9KmCwfuOy`@&G$2?T zfKEy0>hFicTw2AZ-5Wt+Gn6`^^L#gqZDXX#NQT zub6M4Wq~3o!JI@|T0P+aIeQYSbs=6_!*RZCEcI5RB1o7k63}xbdoI;M*5+3hzvm)< zAi{2R;v3ir_1?-{Vl~9dr8NpXM&_rRZ#v%RN&oMnM57unVu&=yLDFS1_kC4Bq?&_+ z5Swj>vTV%Dk_*LLv`K1qqbytB3pB|B>d-WV31y2gPe9+B&HKlL4cVMiFe2Ti=u4Jw zctFMsWx2nBXO;XPH&4-*B8vp1Rg6Ls9z*16=Rc`n7#l0wvlISDV8k1L)GlIOtar;A zcGKbZ?tNEgoSa{o<&s^&J- zRfnTgD`X1Fp2VqLLU1y}O)w~Dh0j-Rt5?zYdff_M`iq)(oPu^Nx5u$OKH{w2mCh1rq+$!gg>0@pXnYyc5Z zuOS|`Ls-Kg4`(53Utnb>ws>4G3pF7U!db$Y(Fe{-<^!BuBNJJ#FR*)gnxH7U zWVUpk3_*eM8uD+CqFOdEC;S}#qhAAz`5Vm7a!{n17ET=6uc%9@+Z^Iq$gZ)Ucb7;w z4HW~e-@rRKh}39O6~-`S;z(>5JQOyHa49}8!J6X+%wO=R0>!Ji*S$7*SqO|Av^*AY zx1gF^cE*UT4GRbSCf^O(}?cNKk*7@P>&bn=hew>g&$P<#2TvF(gde}<;IpgrSxn0tVMGoH{x;{ z!FYV#r~wvB1n|+qsXKj~j`9SD<4jx6qReO{=0zNknnTZCzQOB5$@6bl>hm(*HXPH> zVGN>mo!Sqy_eJ5$cVH?Ab(z(K))!8{Ph?xY-B28W)Dwh2L{+}?OoCpK2Sn$i2m}IT zeQ2`6c=22fXL%QY1+khr za(N7q{#t-0&1rVNkB_WDOGgPgl}ZQb9AF(L{U?|p^!Ak-$uaN$Ytnh?#F81EHB!L_ zZp2xnH?R}nyMKg)D|r)!{fD(H!CxT-?h*Vr&V+Jl?Pgu5e2T@ zlVYmmWl6p5+Nx#4R=t2I?At)@fB{d^E_&W+P=A@H>OwXfzY_&byS}Kp5@0>cwzuhe zz}kZv*Ni3{n$7}pwdYR%$5ZnG%e3FyN@vK$4YQ2#aZpCs|MklNKR~?nnFEN-KtZ41 z6`#lz4SDOiLh$T#KQ>d4mgM&#I%(6`p`)T9H#QL{({hcnn&^_sQ|#-+z8fpo@{UrI zVJx5#6X2#;{yAZzHIt-udB7W`Ei?|k3r_;}bYBh?IddU_{vCaRi1t^oi-#rc)QgnCpi?vK>Cd ztuShMPidH(QN)G*(lH*>_7TM1sbbf9z^_aup#J2prkd+rJ(weEDS!Y5jM_7Q=K0K; zKOs7JsOCb+(OXWj&(tq5?4o}}Unq07AqP6dd{*^Ugj2egI;OXa5*n>lHFs;mW5Im6 zSPt}|E1(>x_v^r_L75kunNzM;eT&FDnLi$itxF0*6|VyNUgQNrpy-7 z1SSIfh0jeqi4@hZcZU5F$yR*`N&3_7b!3lHKm3$auv9)SHMgH>_}ebpv_Xm-M8ZXU zgR-8Y|28(87CxNDiLgyOHBRkHnB-RD)}V+zYIeOahiz_)*V;xBxgYCDHshJ$$|cp;$j(JQ`Fm zq(E(wJsjMr`a2gRx!A@%V-+hC)*t{64WA~v)`e(bUju(bG*ST;08^V)ZWJR7`}+Cj zn&+$h*qub?wLx~i`@WPbyy5m}SYGXbE+XfkzKmOg>vRa;M`}T>WDHIM6?g@tYv>Zo z_BI#`R)9(xPRoT8UT)lPe7}ZU$Bd-x51!BDZlEqE-$wO&}!|c;yJDx0%$K!m(_og&1Jt&g}CLMHuu~8 z9eFomfhfdXP;v+t=y)Cv|CWTxB1xtfR$a(NqdbQS$$rW6O|-ncwNVQQ-DX5BT(Hbn zC)1%*vnwuJQ3J3$Xw_Xqo5k^&zeuvh+-cDqJG;wnE8+iJHhu0wg@5p%I{t#x^E@e& z{)flelK=YQCpp=(_NivPP(DZGGbWR~A(pVS3P)+q`rQIONn~rsHjdU?Fh?fPI}fsm zD}O0LF^U@{s9B!RZ{@q?&bWlG2wBXQ7E&tF9Q}|wSrs+5^d1IY{s)%~hU|l2S1*K@ zI9w-6%Dov7-Y5IhR<*!UX9DhHIa7Xqdeqq&0fG;)ztW<cMA zzyNdS7$^q{!Fr+agCSKub=jSnxwF$CFbF zh5asX2qXbW0fC|3>v`~)L|Qbu{~)6Qp1wYDaAM0R-(3JhK)k;YhHUAbd61YvhZI$e z2k3-)q3R;jb*8=wNb{tOGgO^tFwfFmT5ze+y$%=#_wF~VQLK90TG=uY_gUWO^CGJ1 zrm{~8SM?brb_ar2SyPELLh)-r1iyT=0V}#mhLTq)J`wy5iHQQoX*@Umw2@JJBrw;l zQjH@VZO-Yaer{M)dKW{^x85)(;}9c830G<6swZM+=bo!aI8ntAxuT22GT+fb5(29V z&my0|#yd7xiX?}>0EtcayMB_*Z?oCY8VClMbgY#cZ$BLmbWQjj27}_A z;AOhByKxmUOb#zz^(w83#zV2ol`9F|(ZnW5MtLRp%tKdbx#2P^o%GD@!8R2wh5zt?TDpZakj%2)9P-$uYfZN-n{#htemld4^MlVA43{K4#8QpR2g@gG ziIP5H47WN8a`wEDUdXsu=5Rw@VOrbWIv|@9zRhsV3QTRan=iE=P??UUZNv2W(;f0gm_S+#*)aYHpoaggM|KadlNunh1k$# z+|x|aBdzVuE3QUOIz=9Z=N3)FX)NaB%e#TnJK80`}w0ssD%*=WFU-VbNsi{vwMaq?vH+p@Gd%SD+yXR39H_e&0L;2FtA96Er z+mMFB1?a)g|NKvcDpz*J8gOdmHLnj@*8LC~fR zUc*d6yT=A(9%ae@Nt~x>{xRxO`ZR9BBri{4TiPXJn;JKN2Je8h`e>a z%X>yVRd+5+wL20()wms~-t2t2A=5S!NOm#K9@_jZfnO?NCiSpmEcy_XD!*o6&Dv~k z4MZ~}DO`MRxL||7H1{RMJscGcjByQ&N27OWh(`DTpfUqeuX#(VH zvFW&Ao4zvYh*2BxTEKvlWe>(Sgz#E@Ir6{t{Kc+dv1zM>@1>P zN@%Q^3Sfu16br1UiWOX^P%TSY3;~UwVumX zc>e^hBsTwF<@5fWIJOS56uLI>LdNWKl`2~B7b^_gq9jsA z+KXZ0=SwaVHL=lArc>8hK*Zh+B6w9RUK3G}G!0Jtrg+B23c)>ZE4^sBoLwNvZ zPl*=_FhzT`@qX;kYf-JTAV;in=l5wd1gRi_;{-G&buq+I@amD8t!cSttF--3Z2|rY zBm~ct-OJMcG|rJn;L7l-Q6XrINPUfF3=9_BYLxeqyqrO5QTgf;cDV?F;5zaiOEv4z z84_+_Ty-~AdC=Tu7yZ&m*tbp)O&;2P)3Hv$r5;@QPU%t>D=fcyNHia(Ldo6iiH}Qm z6*IGtsSo&H0M?aB`n&u)J?tK2IAzp#i8vd4LL8(w+}!XP$Uiga(GQtcMqN0gwhe+2 zk9Y3GCEGYW@b#7f<0yrn=t0g^fV)2WL6(_7MDGSajw{Aab_~G$VNnM$h%)SltF*aC zix06o-}sGwtWcW{Gob(>kj+x>Ym3GnXUgg?L}2)Q`!Bk@XGA;I`gFT#xa?j*@ zJ}b@7-TFrg0PKs`JMMdOTcQ#;FLwGPQ91e2`~?W`n*u4jboVvPzDhToi)}{%up@hk zBvESaU}D=M7^yUdj3G#GV}QLZ_mAL7oO)qJDz)OR!}kkjk}ttY!*6B>JTXrM50*XD z-;3e(m5I0VDBo$(>`#@*gvS6R-O)NfCEFi^d2p8PFL&)zk#2Mem9XOy{%dH8#Cw@> zIGx2&vn@K~aD@@dD1=JjuNtfw=m6lV+24VL3KxIBfGxhe;v68{34L!R zQ7cr&o9-H?cm>fyryy+}O-@!gmcVF5D+*$;Z6})HIK5VomL;Ic7hgk=`2eL&DbcKq z%oZve(=WpxKc~X4;qTy!h-RJ(OAaV3s=FK9z`G&l&jgr6<`1%hF-&?3!~p|1XTs zNQFJ`v!0e_5r)$5mMqpl*u7CeuwC77CFZ|+QiujMRk&a4Q z8Avx{QghJyFRfFE7qCwVq&#pUwyI(FJ}kp&Dr?MONz5k(%n6*R7|~AnKj4t%r$hoH zddT&Lg;a7LS9h5klwdgj=DWBu`JelVtMsrlxoGcG5bZTsDJl*}Ln;cqyES=Aagikc z-+tKW3JbFYsRF5hRE1inYN7x1z$68c;h(JaI8vH zYHYtN5NN#XUn$=h&!tt2c|)Wbuiwu|G`UK6Ol=PsdZyfF_*B=NJPpH>(X!Tx0@*Xa3PsOE9)1as^(XF^w?^WMTl}dl1LHm+vsUh{lfci=_omp8f?BA z=hc}*CJHekMXs3N=Pgs8^&4P>!KZS+Wm%G)8SnWmRiVaKTk9l**@4`?HV12*cxF4|JxI|S;vCBRKArqYsEMT> zH6l30sA~nbS11ZU4P&%Wu^;QWuWiT`%Q)I->EX25@S&5J@X2K&iDjpu6(8#@R$A&x zp1xnqbw21vfRSC=hT}_T^vg*VfS?{voo=c{=xyWWGrO7Qotm+fxJvvo`J&gBnq7j)82^(G^ zx75`eZz{032JIOvP!yt&;F9}_YZu+rlaF9h=Y8)0Qpv4VWGyx;Ds4SEy2mXU^CaUf z2=ydJnPyh{VQ70QNz45N!dFP&Uf>RUfOa3Tr`os_1$(h_;K;_+kB@?i0s09ij2%a%fzjJnC+kzuRXWW!IoOYCY|y904lvK!zCQ zL1nSj4i$jS`|rXtL4jpZ6W^}m(eUivutbq5)A9ASnefI-^Qh(5r8d-Zo7 zcNbtI9wq?OK0egxE4gLt&S(-5xB#tot{I-K2n}SGrhdC`)k!HF*~J^knAlcIqq4Wj zSz7G@t6cRB+uAn_<8t1c9O4@uR8>`>C2iy26)R>8njKWEenJ3{bQXAVocU)^#S7e* zl$d>-XzQ6wV~Dz_7Rpc@$&_ZUkA~Ir`)@u78+VETu`|fuZ6vb?9G@M9OBEjY{J1iv zA20_u17Iz_kQ0oI=UnQq5d~5q1DOq{i5J2_oUS<^j$q(urC43!hc&Y`R=TanWMQ0* z$~K@Zoxehc^Xo8FPSY}j#_OXsWC1i>^|Tk5@R%es>~>!Qi;iMJ@|Hqz>(uxlOv_l>ZLIbTqXEOL-u4Hek2#5p5}+><-{+2*#G_3W#_z> zGzw8xbhx<|h2{k~@5lbE2=mB4mRb?diJr4KjSbjI z#Vf13GS~b_HxvN$#h^bXI7sZckMM?$`K`)~VOVl`TtZs9Jnk#z%x29_)v7z2L!Qh~ zMF|??G6&d3C>o*IgFpk)Q8-u(!)b#^WIwV5!TBQn?f%m-Q5)?>5B)$U2(I!7BKR{y z9D^VoNJ{xvv}BF7ymbrQ_kAdcl0={7?+rzxZ%>Kf;LlE;Af@iP;F`|&j3@;lf}QY% zd+vk^5s+|eLay!M*Y>@4qp$31Tn&uS_w11ZX(Qs0_9GzJ4SmjsTeMSEw+-a_z3civ z-Ygn4vX9r`1N)#DYnxq&O<6((VP6F-prU5+MkNFv!D%Sud1s%Bj3{xTwmEz@#Bc0Y z7fVGAwz(-V@`V_eAr?-0l~$-p)y=d}Q=$(6A~pxvKl>N5hXaZ`kXLE;T9DDBBhr=O zIUB%Dq_g_&nZH{n{-E~qT{~H{L0&WJc54nN+7B=}z6?r)PbO610kpf${JxPyP8vsEueGXfr1P=#M&MR z(5RJir>rrCi-O0v2i11&Tn}nfB>jvZsJgGOcV;djU$2!+2_U1|acKnXbbgq>(0IP; zb_&=3>9lTwPgOiRF^whe8JWyS#-kDL_@n7CbdqdQ|BmRgt1E@S!R&sZt&x8MsqVnn)44*yN=BLW}a#TwU)^uY(y45 zg{M(p9{QRQXhXtb@B<<}DRdZa*K0LtIq*FOGHl4n;!ycGy+WnM9hWQ|Q(?R z#VpBjyp2KxN9@*onEOL*p;0*^M-w|(gK9k6u}~j-8FmL6G1BrDUS9HYg(|HR1osyq z!qR38*KNUM|Gf5eiEHb3cX+x52UCBpx=k3p2kn9VDLIl?&n{CIlhV~&{PFCe@DONU z4Za$F$pJ%w6*L)2&>@x|pf~12+ zWfs*_r8e4>2|}&y-Lfz3xXwoiMDIj&(^q7?aEMqllFjSi6tAvDo9#Fpv#Kb+@BPNk zDh0})ISvZ1Q8zr6$iY-3>MI={6?7R&WGqnUS%Q0%?ceDy{XFab4J|Jd;QQ|8VCfR( zA(rtAd1ABlqinq1UPhAhijxj%gFE}Xt6pwK3cu1glWqk-JYiEbGemh*$w#WTV0OHY zEEFFTLrD^a5scSm5SU4ec$lPATjl+hn`4)P7Tc%k|n}iWehfrGU0TjVrgW%n1K66_q1Kdh~G+OctyB z^cW^lcnWt4q+n|+0dw{hD#_d|qX>%$kU->C8q@hKuTB$gV}QP${6Zp?JC(5|dIK1` zobD0o=)v;^kzhziYDb`H7BwpsJw>=wAe6&}wNKcm%>oOFLOblf!Q7utAx%-J4y{s% z=Y*OZV(B|T_&)?T$(kd!`CpIid-gupe#Q*RQqKwobdRjsXoCZ7(r`oDj^(EjzCO*{M zzE}g(w7aLzEhlxF=E)-6mv3Y1S7zj(5QW!ePUeDt9XY#l;j^FW$03~=6JR5P4H~6G zi6UxLfJJY=j_n;r>r^43hmko&R@-b{n^)oT+#4Hi!G`m?bQYVQqz_tJIq+B1g3cfw z2cwxKPfJ#V46m4)p@=ozv~%*S{$>I{I^fjdP440Dl2KTfq`=m6+~p4mFcA&2u4wSO zfHB#i(i;VXh5beNVaRO|Ww82P%<`qfq84yHRT+3JnbQP6Zk~bpk(3yeO~qrw+FFNi zFRCUZz?ch1JiVpB4%Ov9YA4})QhHTSo3n$M`{@fZ(6@fITuoK>?2i%3cBgXwyPsNTgpgbo z9RkO5H$ja20f)bJCQg0`o%c{ns`dNtVcnU(qF+--_P8%zt7+aJ*B3t|#NroIumgm4 zVa^kBu5Fl&^sO=eoo8Ys!AM(@km@$p6z8x84Ou#t&K8ycjVYsj{y}^^F`oc3z1LC(289SMup`>Exb7u?h+t897!=eu$CdphUUDD0R(0Mr^ zUdpC6MG}KH^Mq?!Z-L8V*7&_kButE9*yZi4jqkjHY@}`;v%d(EKaQ5g+Xm|m4_uJP z*dvRx@D?sdJ-p#iHu4qqYxNO_dF|lLqotFfH4Jy}1=<^qA&1bPjDWTL>ew?u=|->R z{QR^XmE&i1to8Df?eDG_D*`YQXSl1~QpN~eRv>6_=Zgw?#M>Gz;L^kn90EbSV5lIm z;HrF0W75u;IqxYPZSWN>bpC(C{6`;!Vjlf-T&T)0{1>mrk1^TlaCF@(j!a23`$myu zh6!3=_=uUV1)@Ar?-bW-fwbF6gKi%{brmfcmcC#ZlnPZ8P6oyeAEubIN@!E0uD;Q|*Wp@Es#f6Xxc|eWY3P=6Au8|C%g9kF6yW zlsD(_Ekv)_^(BI*tS#-=w(yz}&xbPPW^GGKqIi#Y}>Hao*%ZH;Gl2vKbUdfnCajn&YAO<%nG}Y*R37|kHCp8#92yN3u2xOFeSLZW4Q1)l8&%pT)YNLbJL=5 zocItN8I01bsYlYn<~ZSfZ6Qru^Lit{yM~)z=nO==WQV~MnjRM0af3s4f%&cZsmRL| z8AQR%cNE}7gSQpmL65o*AL0 zR#8z-jc|M&<<9i8!oG~j-f`&$cT)!>XvtI6+gd<;U>;RPlgyE)2*EkQ?lzs(nl%Yi}F zyU!fyDCAVryHiy}wd&>IideO7C%h{-u2~aJ62F6mdV*}$gN!rP>Ezi|vS>~iQnNAA z9%$&CieOO2{WTMt#QxWYNi-Wm&ipR;7W|JBecnS$v+)r>n_}f&h3I&Z(y_;+zJU-P zqsw_sOKWW^OUVD0F42?J?RRami+&S#rOnDVl+j?7+>SHZ8iXw*I)FDNVlN%hqh=H= zb(Cz67%hva5R9xRJ31L?Y;l3)m|?eJD+GUxqGZ^G78VEWty#W^&^L|49ordye)k3G z?~|p|^861gzkm{hQIVIIgE{h|`i*I_Qq_;#WA*3QbAHn}BC_&}81;yW*S8Y2cnX-D zIvP;81bF0WBSAuTzqi%?ofi#D4!9vln^~FS&2@#CVCVPlqOy=zk;NXD13}y>S!Nfk zdEB;WrsKnfh0gNsnvsQt6x)_nK4l%lsf4RO;*sEm(QpoeRck|^A!^a2p$Qe*;`~ut zV37VXQF12WWN<@%tVb0^BZ{gPK6XPtKrx5_?y8el4%Yzz>y8obIUCY=fb0SOI59P= zSTJgP?(RZdLPQA7FahI0fK&GYmcYE6#DG3sz@{0j?si*jMsARuYg>+-7W2jBZj*Zlkb^n)uY=&O9p(8lm=)pFg3=4?z>FnnN@4EO#?S4=I|aC#0BbMwxfBWP z01QhX@y#X55!lpf2ORNjR7)(s>P;+jCi ziz}6^=_)JTQ`XeSB>HNte7&cC@bxCchIdZ7;Qq@R&511AS<~V1@CF`{$%@rOlaY|> zQ5TR`#zN=+$uW%XvwcK@XUT()96q&JJ~~{+8$t~;%w{(S+RW+Gmp@`to$gLFmczb8 z=y;i0GB%U%T~-k|a^OG`WL$v8TYvvh95A>iPh0-Kh})t8+~MjpEW<7a{j-DWB)xz^ zk8B=GAK4YxZ&Q0Ly&bU${;n8cPojTuXur-ICeGO85&RBu?Kyj%{s)JheP$3iGj zB_)pKNoU?Sn|M@X@gJ|?l6pC(Nq&P9g`-a)D)uq)x;!?>3yvV9ukpb}C_D9LyU=}R ztm+C{C?ah>(?YwdIMxvQnpZ?cq>dY|<9 z{rbnZYWWy z8CSD~rN*MD_*Kw5O0n^nI4S{FFx|To-VX;F;FxLF`@+`Mn&>UuBBCRz5+7^9PgGCA z;!M>!o+Uv~=h33Ydy9kXvVCtM`krs{b;42X;j{x73;-%X8b4(NVWORO@aGRA$L6a4 z?E2RKsf(cQpPt|nR-Trn+wR)NH831~oXo}fuc{yBSc@H%s=L|0hjqf>Lpyw=hx%dZ z6x**IEo$uk02cWi)8f`|NA+Wj3Eg+3PaI`kM-pT{NA4`#DdTX!%x2Szpyt!17 zyK`+nySlv-1S7A0oLqJ_HGKvOiNFQ>kKg>J5uUOk|`b`-XpmCdyA;@ zdl^)*-0tVEo^rMng5XSi;+ark0ywY7eDV|h;i@{J=5ck2DxeKaZIpw)xo%iPqQJ8a zBTnIFBvC`6xkLk!?Rlcw3|W(ovwcumkY;a8zOKkhCEH`@4bjAi##o|HD(s)skMnQe zXO>Bv!7rN)z0I#O;SC3A?6UsB*8NE>)BN#ZI%<2Ew$EJ7mhOcndj9!p%bQlUE3Lqe z$n7U~U55{7j&Fdum77|6gpe)kT!Q#$v8Nm}3sn~KHZ5pID5Jif?lgTW{vKFGbZ@)T z_IGVS{=vF<1gvLf<7<78)}1Lw2cl1uO@L`kaZBdp`?ewxnsyo&EkH;xz=O{vMRfkn84<<8Rw)i$Yu`{sFUZ*i zn*d{{i{3s-Arw`lHam%NOoQ;ZL)jOBXZwb5 zR+eX08yif3Iv%tSW6~!f+wOL(UB`%9ahYq=n!8xozD9SMg_}AEZ|FJlw-y&fW}*?P zn9~B=)#%yX);q{pwy(|Gc;Ka@eAKwnO(oyxrG3`FKi`zl?Qci_Ts@I3l3dA!vU2t; z#k4**;BQAo#jD^!p(n=o9eAhmTC2a7RnHvQp#j!JIJdii>&NFYE*rr2tmiv~IZWx9 z(r&cZKm8Wp;U{TFb=6{CFlptB7DmBm@pjV8#M4*Q-$9Z8q`VEY=&2?SgNW{$HsFgo z#|bGkfndMtwh-SvVXZ?kyl-^`QtYk=TXlw;M)-q85Gz+}q(+vtGxHUkbDbi_`N150 zLjT!Y4RXkux$6YCpN z7}j`QifB}t;7{BkyF_&LiJVT(WqUYN>U@7{tpzS@l$|mW!R8AGXi-vWU_K*6MsYmB zWA{`U;yhO8huhqZFqPkk>6LrET{gk~Bud+I2%41T$P;s`n0DJ|A`bvr?OmF*ysjim z(KM{CM+p0M@;qh8+?0`s&LSb{uj9FhF-QYk?)zD1Lsw&fK5q`*&Jhm0hfOjqN^cue zFk0@p71jEh>YzTC^`c>Y7-EH%1g297_0(ylenre`GVYSf2E%aF)l?N>#l-w*XV`*qK zgYu7s-17Fz5e9)4tVx&(9;ffZMNBlA&phFy zt3C1PXX}w(qHmt!u+UwwGc^e(LT6-Z6+|-YJ`RGh-+xeR@w!Ml`(??x_>#9pqCdJ= zLpNAUmf3+i4P=-UcW~tQGcUl81u;+11+B0e1!f0~Q${r&3r~7xY;RibA^QHO&gn0F z{+~A_UIVc5?sYjct1Ti@_w(Tq*7Y!e98|wA3Ou@%a7R#X9|I^F=G(~|!ocpu!dlp&L9S9~0G ze$`P_%n(L&VXijY@jH!e0QDHb<&ei0kc+X94%gW?w==+12GB+r`4q974N1PTXgo&a zkEYy{#t!<;2pLoka6^z#zPTNh5)*SG4QbBwpw!PP92Og?UOr1`VqD5wM#1FOI{Mb6 zC#8Vf*{R3{-G1Idpdib?nm1s2T6he;e>w=x8#qieB1(}zCj7>A|7aU4liP1GvXXzu zaaC$u=wo4DOp;{@c|U1ixz7^bPu&!+Iz}hIOD0|6m^p(}80(fUetq9|e=&a;7Ljqu zJrY%JTTB8(A5;F( z-`{fihVnAa>k8RmW-DKH%%pxFWZH$7sw87FF3XzzrEMarhr zh!vOBRReZeHH`qf&o6{v%aQO7p6bwlyBSs{+#5{!vJ!Wj3UpKSVF&hn(q@eefcc9k zfp(=3xAm_0AnoS~__kzs;w?IG=SdwudX$q{K zZkl9JcC{G5?G+byjF-9P$ewa_Pwq}p_Hlh~DLSTrJz2!PBD2y;Ms(?QzJ1Uxm{9NM zx5I8$Hc?IdRY4GdR_Cv~&PqW-u%#&}`i{QJo4r8CtJ~-@5x(F4XJjR!Yru9dd*W0u z{xB@udBYgH23b6bpG5s&bCjrKe{`c1OQbNZP6^=h+K*lr>WK3(Uo_)lltC2v8;g{z z1)`7cVy_eqT^e(~PLQAWvW`eeO6LHxzA*0VEgUzD173@YN_HrL1nfBcWPUz-vHsW< zPLoEFxe{LZw52nPlo&EBC(tFCZ0lQzD_;}#$E*nRP60zF<(^9H(~ZJ6iR$UODqZAx`5c?X(e?)54zb;vwr!kb}&vLUN@7)lY1oODS_}=aRF(Ca<359*=6|vkL zocf)&xpE8l2IG1xEz4u*TN#*_{;Ma{xdOvqGCOM1M_%htaZ4RXW&tU4`>UUbjWLpe z{ad|uO$s#GCIWrXFD`m`8!1E{(j!Sn_U(K0K1&PGmYkZ=imMi9<&g%&R7&}bOo0n$ z1N@q`)M9$upFsf`g?nA~8HxJ7I)s-k#&>X0z?WUFPM{kTVkhR8s@8Uc61;e9w;4eG z$uXV6trDn*vANI??3vpXHA*Wkpw0f}N%@+0YViG%PbWBo0#fDWJSlAuNZhz2$Dj)Td#Pf-~D&3Tg6`;-f}5%JX@5{(YgbA+EyYao z|0HG2o5P!KFukFL9ue2or&NT9yJ&TW1R%8D>vLAaR-+O)FT@w_wQ-zu(y;wEF*7Pc zCE;{L@ql!;4f}m!ZEzrO#wbQ9%mEC*$q$2+w#SKJ9?$lNdGu#_s35P1pJFS~#d&I6 z`a4$HU@sv|`^^4Niezog-r81Nq;U)-4qc#_d*S~eJGh=UJLNbt5Ck|jO8Iyx3*C>! zLz?+KZ{Bj8M2=!|N)wH?LALxo%gV-F*@exWZ|w6{UyYVVO8h9|8f+G9F0#NyCd_oZyeRzZcyw>6)x zVuCJ?;SXqlXd#v;<2F&1{nFuA{Tst80SDW*r<#0vl19+Jud^76ncdaC z_7U{2Dok++-zH@temZfS+hiM^x}UsEJWp;+(d%KLY!b8acpyShprl4w5@WtHqB2 zLD4?=S9DnH#^*~kUkw+#%gZ1mQxP%nbx9EEFbb)VmkB&H?MOm*fZ(_Lp7XfB2K2P# zQ|NPQ4LQ}t*TH_W+IB0*%5+j1aEj{CRI_0I&|VwMBV9x&!hfSX%**XerMyP<9=b^R zIGo}(jH3WkS$?kr<(`fA92jki=dglKQOHb;M9``xn?sbrL*#QbK%Nkkk(Fr&NxD}U zZB2KY(YsFW%jd$f#2*tkyTU(p!M5^N(`E8c6gpwtkQ%DVxNelkFc)l{BQ0H!Mbni{cb8Mp6H-)$O<`%hwZ`>M&sATqh@~>JVeM`*?tIY!Qv6vsc02-+QbH0k0Z9h&1LqG_j6v(JqKygMkCD|5G zwuri$gMo9zQ!R;&iy#2Btt%1SmzuCFtuOgtgaz4_&3Ev+6>ugaR_J2MYVS3c)A z9b;HEqo0=PL$4@M4;tDd*y(f=S)J2+rb)JyRzFYFrq!*IT5udvAunZ0-n9>;9pB7o z(z$#(B4FrcK&a|C$x(fK%Z4|5uIXtHmqBHKT0)ny#UodG#c+`SYtf3|=THVOMI@}?2>x{Y zqoy{rvFCVN>kfiRky*~c#*hNa2=;`tGc1xqBU-nt%8PFDQPHLGOSDh08PvODYm6Y` zS>&6YY8LV^AkSNecGIooM4GjJ%++mXLkNK$Haw%a%xmr_5uh5-cuXJ~Zs`DeP zMA%ZY^6>YvQ}-kSIrgoew6skD6Ew`edi(1%l3FkVAaQVRTlHe9p1XapWmnXRq;<7b zjIUKeojnX76XKz_D>!jT?~@=u`ImfJYeH8~l%x*2_v$G?;lenYd@pBRmw9N;yu*O& z_ptHUzdAD1P>U2|4@|#JOcB6zdVFP|mLU?4ECR!uKY3~2CQfJYx*1uJ(JV77lm9M^ zN)UHK!t&~@a$+X}$q7r&0Q&>JS>Jsdx>0cE#{w}$NuQiT0R7aA%fUT6*|siYwYORd zE%DzzXkv&;PF-%bJ5Zo|gVu&EchVeLt!xpE?-ufAA`Tb*z%hBAjE=J>G#_N08H*gr zlI-z`X#qO*UYNsCY;@^%8(e#Frc;p>yO@VdU7nZdn4RE50#&Zm3R(Z5AIz(gUp@E+ zJ68veZL6O`b9wn4xN$I#_L)rCq$U(4=AFtg!_!8#m?*Wz2gC3DjmCLk1pTEO*Opd= z!0~}UMz*b%aBnbv{aOe-30*;C~ zxst{S5A%fXs2}7kjQ}~sS)SQvo2D!TzY5s36c_8V5{$rd=g=ETq*dnH_y9AA7Bp8y zGKI1?lgp5SjxBs{_t?&WAzR~7>_)T?-fW63A`aFobrjnTBw`K>eAAVItATu+ z^N1!0KkCtf#4D8Z7*C3)Isvu>9O`e7OLA4ktR)uK+-_;0HW8>@mim_ln4L&#MR`Wq zbcKimW-T<*BHWm3O>4!I6iDuZc%QkI7)-I&h{_olhO$Y=~DrEd=R^Kq}SIrI+NI%el?*?|Gn@)cj2Rcs_WLLFu3DV4I~Vkl-vYtq``zW67ON0d!sF2V43TtqBV7Zyl6AS>Y8@rTN3NB@I$2`S9PDQKB zAs>FsUAkEzYLeQmLX=d54ZeGBeu!vp6v1$wD$N^h0)4x0-5emwvuI$?1l>dJtyc&7 zQ@>Jkt0EKDtq6?VNVT(1PG3IV_pGZ-pLB67LX8B?H+QZF39EP~w{P=hOr{Oo$w?sK z3tRgSaw`yn43juV%Su??NltSjUxH{R?tZm-f20GCpTALJU6RQ3CiUDQ4_ww+-cEZj z=!TBIN9YjHTSjLVqir*aT&&)rYY-1LV^hEM>Crwr&lUp`RgF404b{I&eGsNBz*1>2 zFQ&An`bwu}qzx7ubM-L;N)+LPo&pwcma2EQA(H1H=pX{v?2PEIKFEwW3O=*hFyRG- z<;s=42u$Xqry`II>e}wiR)MPbbCx!WMt$wLG@8JUw(=NVg6=D0zS+_&lyQN#Qx6Jn z84Ok=aJbm=3Vwye(c9SU%=j`B4GzZyl-`vp$3Yw$pxCUk&xg1B)r- zLoS=Iu%mH&lQF@K!marAQdWn*Q5FbtH-Awm zI^<;nBK`0fhOi-uV6Iq?RTQ??T?G0NSNA{!_jBtctpHorq)WlJ?@}t(4O=86G^d}T z%aC68UC>_<%!q=Buz{%hUVyAe1wH&WgT5kzL6w9Zo1Q=d#RhNXzNxtfQ6T*dy33XIdoPBcH2240NJRaG0;rf?bZ z?T~$gQXc^`eGhHh*Jerd65xb3&xxkVfksOZzFw2DpJ`NY*0o{{WT9WiqM`~Rv3=p% zn-Hasz2i+TBhk_`t~(l$Q-A!P%sQoH2G&G zoB1#fS3Oew+^6X2M+h>^#6JidMpJh*O8Fm`@FkVRDX(`cAdHoeIn%JT`w2s8tC`fW zxK{9_r~srPc2Qd0Gfh#JUZS7OnfAqY-Y%A$I`h+9D`ne9KpA4_C2Tbp+=y`kwNZ+H zG>2r{^BAs zEU4f$utnH$#AN!In61CD{=0Tu2Tj})P5pU0;)r)n8SX}-ILYQKc4q@M$0%~<9w5zu<_CqMC$Rpq)L_0@ys}4my6^JA7CQl8|ux=LJ;|`R~j)Am` z++`^l*|sgAX;1z`qL!V9Ef z**$H=VclImbFyUp)R3MomLN3hnOFX9sBjHFj4gHU3DN^=!ih9YBV}!G3DpAeE6vQ) z)*LRca>PBfOF|IB6U`Jr%}z_j?Gn9u=mkRHvw(+rTdTy)qCc7ZuSKVD8YUi@oB+W zK3*AQT*ZmLr;QchUy3eX^~{4S9(`d(Ns49 z%jYft15_9yyADA;PXkn{o?k64pidoLuWpVPy#T>pbCGj&m@5PrFf5`##f^awX}tux zju(|GsdgvJSC}$6+*4N59s*`r_~-fAOy*}iIGO0!$}N;T0NBKcO1$eYiq$_SxR3;hzj0@xF8u|2^`6sZuzk7%qCsmMjG2<` zuGJ8(P`K9oQq#4d4Tm%9BzYPIOI?yZbIMNj66X*ot?|P=RJMQKyEphFKS>+4bd#Ni zIN0K6pr)I}l1c*(D^xW?V)t5JYquBAuMfrd)H2n!<^aS@)JuxY&d&HZEkUL7bLpO` zZxPeO{dLkZtQ+N&t<5{dE7Fx^^s38CV=;vuS+bnGM8rm1ad=9?&7GZOJ`#eBYG5V? zFFY619+ihwJu{%tPe6}m)O6imInKB89<^G*-T`sURlH$aUJvp_}CA36E#`NQqDCC3|FGrAf*KtB0!ei{!b@5&D8GI_T9 zg-U_&4{?MymeawJic&}hUv_0rjU6uZIu}P@d+E`UA7O2xX~6w;3e#aSM~&gUQJ*)w z^UY0b|Kz!seIrn168e?;eRrmm(a5R2FD*Oo%c6tZcvu9JYuhCm+;2Eaa!WRMFG&!Be;5Y>dPp|w zAoy*K()>THns;3JG-j@?Bj|-MM6m9b!^0{`Igv-LBX_wnAf$Zl-Mm<_TgIdcBqg=s zI@Ud!<<*-sCz3(ec`npFS+xIN1OSGUl zKEoqOX^><{O%bX+zi)M8y^047eArn>H;x6N)U0e#B`CBHE!sWOX|Zr(oxN-Kw zKbQ7*7j){DwgmzOGcnb-0ZJGG{AG1kC8)&pabWaIY@AvFu(^1JhQh-mt$0vt6@RN# z+;~rwhD(&Wr(eXmUMs%0r26EBPD8s2WR~xLjhW{%eqCs~3~-^i0_dOvEe0u6;?$e| zc;5}lcX#FkDfpvqjV{6(W0!nn-$8yH)Ut z(~hry6ShW+e5zE&#-5I=c={i!ME@|Ho*qAOE!tq6`+Ej%2|Gv8_ zpBwElmIf05xY|F$V-38pko10mHmWvS zu(X*y6gIDlon#hs0^64MOj@&r-MhJpx|OkM?W0qE2<-34UPJ#i{9RLoy38Mz5s#;0 zn8vvGwIVR~#mL&RV~>*;;P<)2aB8x)oBX#< zW~msz7V69K>aI+Qj6ZZDP3(D)p5j44b`#VnpeVJiut#ytDpNMO^6KyJ3Qp988BHG6 zLB`W`p!GjIIxYpo^|J7eXc}}0yf*cuE&C_Z1e414l{2VLa;BPnDNqk3#xkzQ7-;SkN%=h0&en}QyzSa`ZIHC_AyYy?{2tv&2D$!?#g!=_o zm*%DFM#xVWQLChJB5@FH`?)8>4+;Wimm!hR06-(@_J@-C0bA2T@z}=j1-lgyl!v{3g#TBak++P3%q9P! zQ!WWKLU6KQ1%YW;<&hXfWpBVj1eM6xHLH0fSKahRcUNUxNA3|e*Hc?^f| z(Yha2gWKua(Jt*u6JsX*V~K|AYFgz~#Nso+)gj-(bdUXfrz@UV>El4=s9w&9A085c zTNXd;-2a2^79o0~i{1HOavTT(NK&!(Ys1!R&+|;^Tt_+#*!D}wS-;e4YuMhTAai<;^4X4ag67&t1woUEM<=J z06r2~-J$Gkcc^?VxOS9`*5H_Ti`^ez2c*xB`C8MAT+Ai%>4Bvs-rGIBOp1BoDm0p> z;S%HuA%@977FVd;cx_9KgAeM2MBlT-u?OThkbcGyAsbb}LjDw1(pQ5x!ix5@+(8nL zhX>K9CX7svNXzC;lL+Ovn;P62ptA>wCxuwJM#uvsY|=B$73C^H3+@h$N8dwT7VbU) zh7JSK2!uA=Nv4EG-4V2fHS&cMu6zkjN;7;u=-Cyq8ae_l5O4xUhAi79m`djg+19v% z0(WOcp+Vumo1_V23-#R|a;O{Bnm!!?j{z=usxZALX_wE1l{>(6j`^yS6>!5h7+ys| z05bM^z8sc3by472+Vvk z_KE9K?`@wJ8rCn+!^-$^iQN$nPh4V3dS=0sx-m39L7li9?Az4PCh4q67;2nOBy8z% zS8AA~??E)-(ha_R2kpdDg#_mhsq2-GUgGQh6Wb$z}JbXFymkiGQ}tk_E_eH{yS zfqlo!avUGH3?evTY)eP)15OwW1mZlGozT+&Hr`S^8Y@! z2~!=3QmnudmlNzb*GUQy$+y1^6W;nqP4pe~?y_sb$$8Rt>~9?77vm4jG(b1*?6bl} z7BpF@0wHv(d&>PH7~&Ia*-Zt{IkYZS3bq{2(&?ATLo<~lR=e1(waKb;b%>wLVzit_ z(03EYxjN2q+Aj#tvi&q54!5yGml26UU2{J7HExI(hT_|V95HD6 z`2~u8t|=KJj5e?{<=1>5TVdY!_6D`0lL@SZpqjC?&>UjxuKyG;9GzwST=9sO>V4`3 zXJ{;lY-6KR%Z<#%Wp{gzPQoAqgp4i!!b==;Q%VM4=w&oqdPIFYncPvjcT_YQAKxR^ zVLrz4xs&%}(`>iTqgzQ1M>E)UCoW-p1fYeBFBumYeD$n3SWZGz6I*{!P+U;TvNplrVIi@dgA!H_B@jhPR9K>3BOCm79 z3u-pe+f9#_aJ#dFW^wE9??_Gc7;B6S=T4(kj4*U%=!Nk4hrAn-(`J46Bun`O$|cW=p15OOYkky;k|kz@FLS^0ufb%K3V z{}UkTD>*}xA~*^q9GFGF5HfCVFeFqtIV)JK&sk`{q3LwR{t%TP0F%{CLyMhZe7i_Z0i0 zslwQM6pooxo+1}3->4!5U-mFH$GjHM>P36#{;ajOmyYL$)_Z54lb*|>s#SBlrm>Qm zO~w}NpS4bdBX~2GU}xBSwMeIUD0sH>QJfE7VXj3?gf3h!r4;24_wvt>5DI>&(^w`0 zSw{TQN@Xp>V?4+?l2&hxI!oXAQ-2_&{XiVX+9c%bM2g;r%gFn?*7SFuirs-j?Rr`N zruAQ_SfjymSr1N|-qzNTjo911$^@&1aiAYm;CzD%fFzrP3_;9F* zWz`-YQpCDonuAm<{@VNahWIP_F=Bnh{ZzUJ34L;)-aU6BnLTX{@D7U)hOzYqP)T;G zdwU!PJ8S}U`}R_K4equcbf!qZD#Wk_n387YIEEXU7nKDTMU!|lljb`bQ9zIFmG8@p zk=E;J$C=Q%E?mf-VXTefM=qmNA$FISr`m=@Au2Pj%tpl=bJ}-p*y6ZJOiw;Lfa*9 z*(SPq+CRs<6%O(9(-+zTzK2hMbaCGcm)xx0IfdgZpOAPYM(OwV( zM+-9jdBVJbP96SujQXBx!w5W}Wy=D*yhnR35G)j zO^4PnG`}1{iB~xo2;)4IIM{?r=q*mYCo5C26t`|L59AUCO$sUJ3A2ipkgeTc=C+7J zET_~(UIhZ_WQ_n2rKgstB)571;;|{@YLsN;LLyt6dT7;t0*xAspRSkLMvBz|MK!~dT8#TQYo-m`Ng?y+X%^&>7Rb{^_vXy31rB;&_p zr5MSB6HR?G6X0r-IdTvb{_>CeK1%xH2prI*Hb`9U4^8Gl#MOOQp7~SSiUTYt%Di6w zwuoq-l9UbZ?Tmy$l;A&_wJNFGMOAKgE%Q<1)iD?4{pJ>&_1~G~Va%gDT1DFBxjECs ztSCy5f7tQ6zR4xE?6;sASN>Ke1aq1Sbad&&yu*n>9ii{b@qs<>)c52=*d*Kk##5Wd{s zSVr_r3o?j$fLFw6!8-WdU3tO|dimug{(OGPf<%!D;$*uNN*GX@3i9-C&L7wsIk)%R z?bM@CFHqzP6yo5klN&8>iW)5Xp7^6z1X6=H&z(9+qT#<3?73>_=xv zNCae;qsJYlTqV!5A5}Q2HLk01`BY$$pr1Y0z^_Lw)b{-Ceunfu6O+aT%jd@jtu$b% zg1}^Ei1hKl<|;M%HDvf0`}ceD$X)&Goz>T9ikSc*ZcF-{QiNk3gbqD)YvjmdU_SsXv! zv+j$R>kk8kRc?9vg=dB#dp$L@e*Cu|lX67+mTnBVJXt``5L(4itS3C#9RksiQV9(CFYA^ds58;OTS}I6 z-l!7zCJBEG)Q^BT7vPFYFFExo$o^+L%Kwxg?2L~*oX#>Opj0li)1=Z;)00OXF=|kJ zuy_cn@{**y&KHU>4Nd$_QVZ~K>xa5V1wYkCv-MFZP+3+d?U9OCk+?UQ{tNkVW9Y#+ zy%95maIAvK!vr$N1#8~nr5MU67VhZ<)d#41Z_e26SOmCQ7_W(3qQ@e=`)aoQMou?v3GkXa`qzSrC5tj_ zb#O6Hn;dWV`@wYsO}1E93%gbo$$J5*iE6& z6;>ou+KhRc5p z@z8;ZVUJuH|U0S{{Ge zUo>F7$(<`LJb!4I((mB**#$462wM|>C;x?Vz-brEEDF~0B(L?@Q;{QG>;r%@6rp1igh1DfrhFTg*38tN zySTOrfVihJhf*hYn7Ms_*4eHGzt5~2P0(L#c{UHR%S604L05m~NPOk;oT?s+I0cjI zdVQWGoU0MZTWlc3Ick7(9Mn%HNLp11HSNro=@(wUZ5X#F5Y8?$aX~PO)D3l7UuR>F zfi!t*mgfY1k+V!vp;H0nm+PMgDvgJ{Ob$qzzDm4}XUe;RWdhHkfT}e+Hn86l^J0+{ z-IMVMBj1O9GvXa;O69F5@=S3wTR6@HEy$}*S4k`IHHr0 zN$HWGz@s4H1wJWo?yp~A>(}Rboodb(&k=hu3WA=f-REp9{DS5@Y7PF;FX_P!yWoQF z*Mh%=jMO@vevtV2YZtKdyNL@tH+By_nVy7xN&7M3)e<7!^cp`^xY}UmMq;UHcUx9) z%%>B93ZSh4_luc>(5oVuj`mi_8$q&f;U%J z_+XoQ^U|G*l(TIiK(ebleteDnbi3$JLW+8*F&I88@P{!CC{LEm2gv$XvbP0R4d~}S z7{`)VH+`HZnihcKIN1IMCfv9lVOOH% zp-{Y!^2~c32mv*Iq`18BNI#Ck++Oq!wHJ!I$#a#LRMuD8TeBjr>&aX+-y%-5?mwN1 zQTh#>X915~uLm)YhI&7*HBXPCx8iGRf2RvGs{lXIVXcGX$y4uE5C~ZQO{-Y~-`>TU zG((ode4IU^VW>?pi^rC2>&VUsZl-F>u#qo`kWOUII=%2%PS+0;l8Y3hIkjo}@=vns ziZ93je%RkEa87{bM?_5n;lCzh)wz51HZx59^?6bM)%)v~Y9WIZ;C7cMDxv_mk{)clK3(g42BR)z^_BW-{@f*s56q+JOYZISGOz(8MJ@ zZNyRcR13kYyG$-mppl=R^I{lS28BTlE=~`?3)+`I1OxxHr#-D6#GLPe4*2cAY9_+B zGlb7H7=q4KnS$m*^sKB21iQ(!MEI1%9;Vu%y|RY-%5g(j{%sAX693A4K^hmBQH!FI zZ8u7hqoq$)M4|u6y3yea9MGAYnei~ldwaM}*RYWM?Nb#9T$lY&&!NKYkXTRqzXI1N zWC%iPF*x!GD85 zzG;>(?lNbGWjhucL`C^lO|-=jH8a128~N@;u?Ihq^ku61Vna&ow30)j2tQ#>RE{aY z_X0AFlG;XNqCF^h`Kj;YZkUlM0VS6K_$#X;kb%@P<;$7!Al5S+h1wJOa+^1|zgOw~ z!<;tF>3;VX?NY>GZ_dV&A8|BCecQ|W(u=>wjQkZIW0iW(7QT}4(=05+ni;B3`7lmT z5*O^JJJs2bjJ|ck-Z4ulgGP}KcOnpg84^dFgICKUJf|5m5f+?b>%K+nhVzBqnLgBk zi-;M|Q}C{@-GwT(BtJuaAsC5hC-DEQAN9xLYP^B3unsyMH08vu;NfycDYCjslP4gh zDwJT=2^*tY(rX<~{pZ`%O$Jk!bngL`-99-AbJ~T%nD~YKaF;;wISnf72ry&RS~mK- zmaNzpH&tJ)pF7s-#kMdrF+hgs9D^Qs>msN+(xVa=Y(Njvt7U*(ktrge0OVIej=5vD*bSM$7QHH>$-)<9iJS_< z%v*HPO< z*k&L0KY(u{RYtu&XD4;s(7nF&?%`s|9hDVb9xwU7+g+xrewK^}KLHsIR4sG!E#!%L zPE37leU6fB~&_ zE2HE+g99&g@!Xw-@aEK@_IdQs9lH?Bidm*uz4>ZUG4?g4uP8WD>&bEc;)f1>u(>6! zuY=fSH(*26ClHQY5@mcuTzk=w=F*CdJi3(P=*!cU0jR+_ zxrb1vCba~};A$S!fFDdh!WplpEmzoDcV4QJi5DaN~1wBT6HWKSYuGc`YE@Pxj+sf@)5%}*FD zBgn(r)Bx9z<;9pa0TW%MuFNmQpO5|Xsl=5*0!` z9eIgj9cz#L8^xBb9Kgv1^hMZYGR4JDyCjd}aQR1^eMewQS7JokfeBg$ao@G5ArMcp z+31>3#j+xSmVy?RHWLjgn1P}oE1S8p@wRwbfSN}dBdgk>=0)LCft4?+zd{^Cn(WtXRF9Uc6>4jS-U9RGw66_gtGT^P<*_g+Z%+K$ zxi~3Q@HQ%BPT3>+0i#ExftlAs;jGs<`qGN+|DaF((j`_6#ARU8a9#y=fT)%n;pIS^ zGYYyE{(5}XmH+!NbI5(%h3Dyc@-)5-f(Ye?|EjqG^1Ba?Wl-W)qD;rN$~tnlv4`b1xUVRGn@C^f1NCcz|?MvK3SFH7;ffQ`YSI#%;JJ^yl+&gTPh4GBZ zq0%O+z-edP-X&1B*TX^WtWs5T9Lsut1%eUDT&vGB3jS(0JYSW#rf#l2deOW@d~<94Y6v@S7b*wN zmH^Un6MF<{4l!g)%tEa@UjhBoewZ?e00YgV>?!j~`}M&t0rs6hZ`Z9CTG-~AmgI;= z#$-UjdvYgVB%6!?(01NP&SjIiqDRX+nMmr%KZ9JdP1&|X0-)Mx*h3Z`nYyGUeN7~H zEv^{z6)GOnB<-X_XZ=2#0+Ln41&#gd@aeiGT-MuFa3e9g3)upo)4`C%0Ds)5b!BF} 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 literal 0 HcmV?d00001 diff --git a/vendor/hachidori/extension/assets/preview-background-3.webp b/vendor/hachidori/extension/assets/preview-background-3.webp new file mode 100644 index 0000000000000000000000000000000000000000..baa3a2afde0b450a37b22c7aab5afd1b626c8113 GIT binary patch literal 145796 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

    |5g-62R;w-#Aq8!1hIF7CzFqFU%IUK9R?yg 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

    ++h2ooL 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 literal 0 HcmV?d00001 diff --git a/vendor/hachidori/extension/assets/preview-background-4.webp b/vendor/hachidori/extension/assets/preview-background-4.webp new file mode 100644 index 0000000000000000000000000000000000000000..eae57e76bdb25c4895ca513e7700784d852d7ba6 GIT binary patch literal 191904 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%0Apogk;wPcWSd5xFhlDe3TcZbO^MO4Dj(F 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 W*~F~PBa1k}|Q({lsL<=p;)(lP!N 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 #u9#DCpfq^F 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 dQXW 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 literal 0 HcmV?d00001 diff --git a/vendor/hachidori/extension/assets/preview-background-5.webp b/vendor/hachidori/extension/assets/preview-background-5.webp new file mode 100644 index 0000000000000000000000000000000000000000..e1756baf276cf6d90afdfb93466f486ffd4fd985 GIT binary patch literal 178482 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%0DN{MzD6hCM1PjLh0H63KqT@`!lLG)Yz z(lVbLb05fx*B~h6iH;alyqp{1<(}sG_$M_S=od-xnY^x)5E8nPPC(z($66$EA^D8n zzv2Xv3du_Q52Y0-i|t*)kfC~-CKXwg(Bs$ZdViZKayscPsXHlzGS`sLHQ!O>>|WpJ zVAxFf9cZ^+R=PCmo@v5+pr?$6IF6c9l@<^Q%1Xp-AR<$M7~t!PBZZ&*>~$&+U6v}w zUkGja@TZm``G%u(PCjUrVFHSq1u*hdGJxmX!-l#&^boyP zBCqS_xO52Yk(N?mFi!8O+?p)9X8B7copVLm8wo z7mq5U0aeMSaSLB?YFRA;aFV0bnznFB-1KqcydDu(ohG9M#XLnZwd{qkavYEyT1C{R zZS8!*;Bt$zst?_e{^x3lQU1Ah5_9yu+;Pl-DQ-IciTAji=k*Zd6GYHoZU5y_pJilE zEi$G1IG^88STWhs?f`zA+YZEeuQTsGspJOj%T0fk|8C1+NFiZ0Ce}8=qz9A;oWX}{ zL(dxzq7~D8cevn)ZSLogFzw0ASOmA*+Mf*hdR$#cuQrJ!hX7^c|{ z`<~f#s#<_(BQ<@5YonsE(ZTUQ4aIvybu^Qo{t+p}wd#miGQ%m44(w{3I^(>VS%}is zo9tTEP9q*oF99IpP^PR~bva*Dq5Y62` zwx*t-$-z^Yfo=^h0??X9Y z5}4nru{a^W>&u3|Q6C)@A4%81YGq@K;sB#<<_I~E_K5Zz>)J}^j5H=_&~`m;vjP&) zLltZqXb!hoH;|F3j+q&qY~t=s$x?7G&;w^_gI*cHi~PWE9s0{ApPfu$RnQ7q9!cO$ zuA5J&VlfsJC%!}%HOing)fSQVCv^QMCkFHXUB8}vDv zTH9$QqmtW6Nr!Yd{_Qu|P`sro(Ju5?1r3utM-D|_^LL!T3;I2OBcLvS3;&AU!e)J8xdOenDb^zudqQLzUb?f zK-C2kMdOH3ZjzfmzUJ}rkwenf{LcJxSXC7y%cVW_uEA240TLq5L$+EQ_s8$i=^pUz zHp{|kN#G*YS_l)>$945|=ntW3<@Q&~Iz_`BZkbEd+GO z^JbRjPbLwr6){GCQMM?B;GWUaoIf_Mfgn{M@h=dz2V#ct)Bc>-S`esWJw>n22A6mr za&C%R@={sYzI6N9jUoN4I}9i{zz9x-L6Lo+yg z^@H>7hi3Isp>7ZJWIJCu^c9?mjd5{sNj09s-;w<%zIdLtrp^XXW;=5lDBAb#1ma`I_0QLw;M;mqIg4Ucn6(OGI_FAyYoq!?B*f0 ztPAv|O6Zh&oGsbOtx`Cmv@M^fz)>#&#HXY2#fD`cc`>a8Tb`SU5t&sbA@>>hx7`gK z_C=JN!bbIf?4SY6LWcZatiYv8PCq%L_^@%ix<#ej`*FjZ^Sh8uucl_Q6{4VSyB!ZK zX8W3{L0jj3QH?Hk3eInV?|^0731XggAIEIb%`nbmO|so+q2jZkUZEV_jo}I$0Hao6 z)V}1)C60LfW{CDKf(`phl@rFYrK_-&Ia2AmnprGu*n{643J(JSg*@e2>S(xI58lEF zFzpRd4_Ds``)iqtu#kR!9*5QNpL{$sGRUX-vKS7mtkHEX{)lYN%q*qbfWlrcQfD@& zg#>!X{I&iakn`$_>Smk<{^%ZCkax+Ah+3}{VE)HvZ;gO!nT0gmZEUv*c_C|@O)QmU zJQW0&zh0Juv5_7*FoAaabT2;6d=~vJcly-ll>u@VB-r~ebuUra#S=e~+Dt_;YoXTG zA5z|B{RY^(k`W&RBhGUreexN_Ls+D4cJ+3ym(VsKaHKlPnRkbL+?T(+zEEIlZN$A+ z6Rrqlwtr{nO%fcceHWo4&uoL(ME|VT;*%n(aQH{1yh7xG zTU3690)M&554;_dY?wX}+El3N656r%_Vf#VZr&=QtlYMuie)ZAUuG>2y@_L}=JAvO zR#m-aC1zlgV@JoklF=EUVjD%N@Tubf@{SU3Y$T~%PEv3wkL^H5R0TA(x?@za@<`JU zD)GX}vIHHpdcErRhX2s?w|^+^?pUbN0-%L>>iRL#@q3^rNfsqXyCqzEjM{+%{II#h z*ZAim5zGCx!65pf45?KS1XJ%-T>)GVL%w;f<;si$N8&LQ9oLu@b?jve%+b5;2P8}p zFrPw!paB&ubM&g={IhclJD#Y&JJFS#YYI0ky9dI{sVcfO2w&?yiDaJB-BbSmnb_p_ zVYk1PrtWa>0DQ@7pvtdK57PlAs_^DgTUg8>KUDAAlGmaZL0rJQG|R)w09mwbbWD_d)% zmCRcm56g>fKAtU_zjbQa&wOeCXo9$(TNfd*@Ozr&I7c_jH{JlK)3exME%Ya`%5AXd z(3j7p!e4!%%M}ZAlU-*LqM=Cdfbc!1o)6jE)tL7Y?hCxfO>qx1RVi!HhEeQM+4G0l zwi#_`d(#<$%=!kpr`)_gw2Wd&wtGECmxZ0MXg3ejOLS??1kg&jTw*sYpj=3_=`Ml} z#L-&Fw_2W~vk~FNXFG;NGCT*`)2$ra8<+vpwoo?7pRW?S>j$w`z4h;Sn@yNng@_KU zuA=neXawNMC|yu~jZZa&PrU=k9-~aWb-n3W3nTo&?sJpzHaSZqsT~v0z0CgptpqGX zcg}k8fk{($M09IHb<*}JwgKd<6OW5o18%$?qTx)XD>%dziE(;k9Szg*e%GYVVD(Xo z($|HUqbr#=AxV_C`aV-w_QQN^)4ME6$LBO zYIlzbTcAL{iFqFPmd6ne_B+6&n5d^p+fMeJ^JvmW*rISAO)h$g&jyF6Bb?P=2tNbq zHj)-6y&7=?R}=}n0g>SN?BE9X-?Es`R|DCTwgUOc0^$tNi6FIjh?2rPY>+RH=8KQk zVZ}Oostsh7A5hb%XzKfz*Orzih66Re2H*W;(FfjT`mCVWQ-7J%KIuc(k9gPiE+9Bs zZuHKRbpyUceJBE5E=+le)ZaUi&3We=b#e)#HLUiL3Yq-RH%PxmAT=a^xxe!$a=w(b zCCtulfg1Ntm>Zu&560b!WWnHT5>(ks<6sRWB2}b1zp2*66O}}Xo8>taqkh|pqEf^A zWb>{4iYde<6twGsi{eEbGHnutrq~vT6#0N#`8<_qB8*sa4yP zCN5=ug67;XGv+s3^OL0u+t745382^+xhpnV!s?yjswDzc9}-HPcf%KM7Hy`Ahty(~ zk<&L?-zy`JIKK}Dp^bti+y9rj*TF*x0yZ$JW_z+68%UyW9e_a40Pi&k^c^H6zJx@v zRvH)09Dk%n{6}jii1M<8@WY}W`y*?6#Aw&;f?PjvdU<;kfPA2gm>~~-iJY7Sq3(Am zghBZ;BoU`hvvI|WHPC316Hz~qq={lm_ji+xZ4IZH^P=+l_nJJ|>2%K%bzS#J|GfC?SZc((cVwNL8?~(|B*`cc1 zy*25*X#w6u9TyI%l1rl7K(tUJyLVqPVfUc-!(??C;w|jpDd57G;>Nt2`P)Q*BSDS~ zvgX62a#DcWcKsd+Ne$PD)4m32_lz*|12y6-EpsJ%xC3aMQM^oq6=g0@f8(&gGIkWO z;T;SQfJb^^IhT+VJYe7Zz#D)7{h&%%7gqkOMRU+pu)RJiDY0bYQlt| z){QOwt=muFv)grvB)_o+IJv5wtH=6?6}=bHhwZ+65}F9#1cS+e_F7qL%7)Fwx1M!q zz0oQ$CY}d5FUYBw$IdIA%g35vHGq0?dM>Aq%TpgewI6(n`qeuimy@@iV5na|1h`tR z+_VN8GT<{&`U?ZMVi?cW1IUUouMbN~9zM3oHxb)Xr4!9`q+)Ztm+gQTxqIE?J{9(x z&afaf_QUK7Ys;nclp_r_xX<$SAj^ki`*3mB`w$tUddDL9CB9NDq^$wyz$;H=!|@i} z@A)iKK(Y3@w~1hDY&GwJYDR|2hYF0g#whh^hbs*_lrt#gW9Vi`xJ?eNO7A50z|O`_ z#q|)levW>gj|5jmBGu21G<*OM2FQ93dTUBJK06~OfkeXGj(N!Pr!C` zkUWva6}8PrTf2H(|FGlP4Gkt6KX3wr>sAUUm81`4PzqOxmbM-iHf zw9ygO?F9WQg-!9Z6~batk3y3iFh`ln)w9#S37zos{=owKC-Dd)D-Eu9&M_FQ+vQD1 z_F-0qO!;V6{rB$B66#_K@I;@?w+6Cx!M&(*3&Q5sVo|&d%gHftaGxo&S^b$NMfYq6 zRac3j;DT4aCDWAlkE-~=a8mX+4rBz%8^H$V8GONg#5&>@#M#oMz(WhXeq!IigKpIC zf+o8L!@1H<7&MqzAim-}us<@+KG1X4TUj|@Wa*uEiZg!1$y98%F8vU@41h3rv5x?D zZ}XxsO=esQ>a<(=XdC__9l$#J&@Zgo0FaJ`5b^))YY438p5en~{=TM+cqH90LkO<4 zCOq0q_FJ$4OvxT{$;;|cE`>|&m#&$vT)gU?`q{0>JtxsI)h-l)XJYCvCeFpvnXi_F z_5PJ-7z13c+0FXnZ~yozZ|V?l+S?|zjx=O|1{LSyhH^OM?LIYap@JVH}vLk8irdrNCTDbb>IIe%sL)w)jDIk@83!_q zB85#DSRoczVe`|a2FP(GYj-su$z=IX2-iInk2?R{k{dQA>@`BM?MsH5%`@2=^$W>Y-yqLNaJ&XH~Zkv=0r8={(Osd!x$e3g3A!SJe5{yzT! zkHrUziW`Gr&Q>L6CW8^?6zywz@Fj;nVk40}?0O^`_HKEMvTV)KvN}Cq3FzIr#CyK8 zqDeba&}OJ0DjeD*3>Rzn}@ z5C5LwQs0f$BPaIgc(o2QO@85TaQ}ujCqO{aT_~Ha`{#Nd05cuZ?Qkh8)rbwouUEwl$+xKbq@BJ~hUY@iZuLz$y1} z^5UZenr-~MFJ`}03+9iMFxQ|nBI zgA&FI+4n*3%U&gFEUOn|Mrp5%p0}tzVGx+|LZ%J6v*NeLUf2tEl+Nx=Yaoo#un(Cs ze6S%Ksw@r8V$$<_zUXZaAn^r?atbDKWhu#QVl5(-sEXEbhUO>522rQzb;heC}t3zTgX(Qh1Ka15P@1d`n9{vFo!PowjzSdgkXDZZv^<%30A3ny&(yJ^zbd%nIxXd%&CCM{`ud z9kkQF$|+dHz>Vy`rhGkw#6YA!hqOA?Z_Z+dZzK`~Sx@S@6%%{tT*W~#x7fpJ<9RK~$zG03Teo`a5vzWIR%B{H zm~8ny+aR$>FpZ4CGAJtvNp(Wg#eghKZ&F+H*W>-uK`aI%vv$Ff)~>JWTZ zTSZ!_#PB>I6<)IEkxz#3OqgeNYs#g3x85paG_RPFD+~JNSDScW&6HZT&|JmjnF4Ud z&V1bkAY1D9R*kgF%|AU9%v9FTNyXKRs(kD1BMDMJmT$~mmTrEz+zncZZNv^6lF-z37^!(S^Jl0jG^P5u3I=|*yJ9TyhX-k7C3X8^#$z^Q({5J@P~ zOhtrnl)VT!^=}C5PV}ih-8tkfGC{(OI*Ctj*4_NdGKK1_5^#Jgh9Dj>IYcD9=w6*n zC{*8BRoF2>*qJyNejje|}A^nnUsU5CJ@r{NtIB%-zcsn$o zs`^#IdN)wXL`VMGe(7uSW!Mdv&dQ-G7{vL}wI#k3kFk-dA3HOf%4Kv~H#RYRhsXim z-

    E-U_}x^_oPZqscTcSL^lhx(wPO?DouXs(V3;n5kdKc>Ggn=Km$^au3NjsFLT{ zUVkRpZoKQIL>{en%GXbvz zp{guw?$R*{B;3uMSo~r|ds$*`Ya$HOVJUDR)FFi%^mE`xu-L!TEt4r1aW$R=Fyn|c z(mW>ZJ?;qHuV7`t^;V3jLyH(if@F&Zk|d7<{e5!{Y^unoywo0l=wahIEZvN{VW3+>P*oka46BCGZ$23#~X6v{FMxzA0FvgzfBv)jybSn9z2+c}zrcgSf zA&BsKg9m(Ns@@8q`oHZ(Jg=w6)!DgY37ti2z=G>PuU}`qV1050cfT}lfUjy6-S5X2 zWpWBFVnScFMj)YOC-$7Y&$)s-7jUq<1xeN34{Zi zpuvU#$P&bP=_X?rJQeM#-iF%}LH)naxWA8{@=8VC$UOvn9X?kcL1^1~ho9=aP-_!H zxa|8yP_krj=Fy9QpN$h4wdiWX@eH|Tvow@E-N(KD*SXk1CJAKu0-ZEHtTt?)!x_OX zx49zNLxIdYO8J;Cp`V#;>#o9Sru1tp4Mr-hj`Mq-=hvVO#s^ za5>Bq6pH~`Y}Tl~p^~czxv|B*DA{Kt6leR7H_)>k3OvgRv|i+P-~34xUNvkcA9xJI zLT04QcL^Xk&O~G1`?D4iW=~f%^INB3U}ac2xx4xa@HEG@=}evy{4(Zb1NR5fvED)# z@iIGVPlEHRo)!FW!-~wf!84D4&_ymdm2Rgd!Sb&ueZ`G)vy3~WP-h4f|F6v+>1XmK zJLpdu+qvRWYB)DzkT0Rf$rX3xy_P(cSJ5F%0buTNyBL>kU~$mhVq96Y1>MzvpJ+re zH&!FOZv?zIJ|%+WWZ;ZW4^tavz2G-Il7kiJ<7#{`v8}7{`SKM-yNO@$wK?|GqIeua zUQW<~op({R#1I3SFS<09(pQFh4(!~1tCJObg=_|?rGF>RG4m-41kVJiMqaV}y0hdc zfc14Z;T6H60;V9Z_Rt)5{tfoU?ze?jM-l7el{3ohPXZ`3_>>c0|IU?;zR3gZ=1aqCI-PbS z-)&uhhNV$N3pFveMRZtdslodQMx2FMb<@O{&r8G*1A}D5jt>CI=@Q{y`|aTn0qoB9 z(4I>sV$iBRxUzHO)9L|UyOY_b_xGYJIkeMKLT>g-I9fybvg9<&_)+_*9^>mId(VD_ zK;qC0qm?rD>#vtnTxm(m{o%C;CsDXWq!gd@P<33Q7sEdmXgqXPK#X#P!`aEo3r#k9 zUQ=ycK05zo;6x05R$m34qkG+Xt+>yq9C3cf{(+#tI`1^g+?yBD=f!aUD6>j>id|ce zwJO{I(OSlbwhNC45&_wvH+1d$}(f>2jS~x&&_IZ|ptkW)gx60Yfq3$u|y_q`qK$Illaxg3N zvk<}-e{U=i^$obJsV(|`@?RquxVI0r*T(yqmk)Q;V-y;zH(;<6` zx1UEGeh5O0>nQUeUpa}qOKktzp7yIUB?BddCfHqH3Q2T8^uQnTs>nTwd8GMSFz4lq zt^;U;D@z;MH8*tp(U-8XM!Pc+^+0m9^CQei(iAYL(IoG{>XfV32Q@(aDu^S||GZj(h~FNfXJUoc zvKV|sr3&_fC5mqCZ{rBtcF!5_TgcQl@-VY~gdXD&b(Xuc%(c?9xM@Ts1~a)hAU#~l zfB^pR)EZ7F68~~;9Jj*hoQLP5*%8RF)#*)`!LOpBZER zwfbeYrgTknvukuFu8nj+L3g#Aip|)xcp8_}Ohp|b_1Sl{W+5NyYOWY*)1w>Nm^EYu1OV=Dvlk`5lO1lCGj9wEzcaAluK{A=|qd`DQ0U(+g zgOHf(aIN(FSM`p}IX)RYV`bP<8wDmnzfxW$;`Flc8Bh`~@3?Wqm~Mrr;+vN&NKO^q za`A*ifjfU(rPL9u^$CLlJ8vZSv8uizLHGa7?01EJIQyD|ZZ}6*PkL9%*)?m+{K@cEFzc>%s)YrIhA}xW>-R6NM*0YppXpuiRI&jFsYrGTg6uKKN0pW8q@W(atQiSc1h! zJHL|!7?+^2f`#Vw-62zA+@wdRQePNAFwW&HCvigFo1H=i*bkkCvh3EJxxF0Lg@MjW z9+fB|m!N9HCQS-@Zlw^Oym@mSJr?l)PC^l_{zumtWq+?r@vNeysUO?Q9Uz6;tR|6c*NRdmTTCll1kU zC|uITsk&hDM5U#77>mh$T_GX!@u7a(u+f2!Pa*|W*PZC&-yTb=R%NWd0wB;$pE`FG z#boAK%Y1l`TumkcKMr>N91^*Ds$nxSL)!!trpZq*zA^A4EuxoOHGg5%a!Rk_ayL=IH z^`?(@l8b&fe)fkb+i!f7j1B*)+5t@q^utipLFR(J5`HV;evhEXBhP8l-Y^sLfkqVY z35X*{0h{*5(`?Sg;CFTo){)o=9kaGF z#`?LmmT|dcl6n9bwGgbv&jH$a-ghKNidCRG_RV~dM|YQBN;1^NdDC2(jHYG@!EmIV z>GIY!p#>#A)mk&d54UyeIA~=7)Y3WM(a7UA3Pr}d@V32OyR?^Dvt^PIEwJ`eJN5tK z_c+e1!oV4tPwV#rfArUjQ6p?f`JRz}$D;IgnGqjM+RUmxoM(Ae?-rM()8^aXFkg)f zvC+{_yB9Y2NSyuL05Uf4vQ8Y>Rfp>4kvn|FNU&PH;5ehH5y)qOqxPQd7?j;bu@$ac z+)j8j9bby0hzZ7tI!dXt8#z6N`+mU>h|f6=R3JKrPE@`jWTkR0rZcw;E-jm<5|vGv z$=nJ-i(&T11!YO2QH)_Lx~sG;x#EEQv7a1C_sW6c5GQU( z*rU2gqEEEPEe@G&I!aUF>EwetM_d|Q??*sjCUctq&qGex&Trs%VNn&p>DMOBS(tln zzFP7@2uAAo)k9_>B~l-z@Z%WX?=xJcMQbuchB2VE1jzV*_X2BH|0irCWvON%`J9{u&AWYL1Ml)7NRnoiWnul*e|K+TJ#KTb9PB4^ zm;InUB`SRV6epWN;nt<_hfI!_Gyy?rFhoGenkx>|l+b0w1Bo12;rsUai^ zYd6+;lNvQHl`9|(Akt{sFr_MJ^@?6&nN9!vweg@|C*=p1S3ev6c5OxO){$R!gXJnd zlJEj%NJBM*2Tg}C7_lbAg->7|ts1#jilB~7ZQWWNGmFks$F^XwZ8w{#W%|v9zc4E; zdXyz`H7ifT5Vkotlipv;DeIMP-6~*mX7*4jm<>({#<|;^?lromg1)cutD}lPSEJQ|Ph6f_PeS|}GrFjfZLjG*~2%KpXxH#E; z{LG2|Aj?3T`+)@`OE{eT7ou;S3jE|cb+r#CpA`q2g@OD7aNMaf1aVYA!EHI&gI>p) zb`EEclVxBh`wR0ZTmDo&PIX1$`j3Lnx_AhnPJ_(v@XMJUe4eBVJ+I>{QTKId2&x^l z5ahkZys0{tVYBqm$nZ9smIV9pllsrSxXt44QX;TQ+V@EZ%SMhF~jjm?{JQ5(~w zXb#P;J;zl*=bE>3gMADJ^7e$oi{%;UW?Fhlk`_Yg4RPh#YB0yuc4Z;sj}|epEi|Jl z@(I(#y|n^f1=cl!_hk_f7SAT-`;^*g^)H{qhoG-PkHocRlLw4yCbT?4jnCq3)PHjV z3a#U1uu{6x+^0epE4imZRu*82D5`0YFUcMD(brm&RH)iTja@pPU!PJLYV=(-FzN^= znj1W~4B)Oylr9=CCOwV=VM24GP@Tu%f$dOYKT<9=u_SY!IUeJbW(nPYcavdlMMP%G znzhW-zM9a>V7d#ppjcN7o8APUYoFeOMr=H7M{>{4Jw}3g3^Qpz`b6O`aC1#Hq92}& z!`How7pTaQmA%qFBL_Ia)dNdMVHK*=EDO^~I2m&ow6Uh_=sID7OTfj;kIwrK%MX8# zm$R*Y64Q7ca!fGVXaz;n>Op@eq4^q9t{&p1mmFJkwC(U;o9%D7xz_dKv zZGl5JK)n?yRicvanIegGi!Dp&`=uS<_T!Lz~~#3PY4U}$$)-$kGiHKwc90`r>6Z1juGo26ie)ajWam*xG&Z#X1ZFuEAU zZhZ79ls9bfqF}Y~sO-4W;%fd7ey~)8941>@bCSaz^S}^@l6nZyLasWcvh6NJ)g+ux z(}daLiF*@J7sm=ysy>Z|gr`MB%^oHZknrOnVBhKPXyvusTd?c+;+v9v*hng@Z0n|? z1FwHH&s=sL&3T`yuOM!@9vC%2HE8po+x`IK;`IX$&C^x{TzfDmE;xrLeL6+NZgsIJ zL)FT_3`{?ySmW4UIff&>T3lcVo3FGij^sb{20eUKSCQk<%ExJ?%&vctyt2W%2gu;Sd!Fs^So3XV~fIfvHZS zw;3YcDFNQx_D&DVc94#p^@RGRR<@lkUVYXf!Jm6G%;txg2e28`4`o8@6#wP^20e7* zP3o@;wayQ6w*f?}V&n#{T0R(qFW1EoV!1u-9i`CUCLfdJhMxm8i)Xb0yjNP62sAw?Ft{X4?_-{LCRHBhb3g|pOGiz(}*&1p1g{{kuPT4!zNuLx0G=8jh+n$x@ zb4Pxn2#lA_D#sDT^*tAUJ2K}7PHNMffNieuP=uw%=X+B2#gwR*W39bBJ=T>*^)73> z6@nP+ue|uHXGuiC6!?u`eI83`;0oz^#{=BUt%V<6`oL4_anU`)yQipqV-9%JW(mEY z3HTN;58ASJ_$>ptY}8g=K_7$8UGta4tz`#IbEJ_5@hk4bSGjoy^RYmW^A}xw6}Yot ze;Ml{pUJE~MuAxfSDUx#xO65N`MdT((R;=9EQt@9_NeO&$ro?yi%8 z4T!}OSto<_nrVL4&{_liX7~iJ3*RsnC~3=gJasi+BaD(7F8WnHjn}L`kGWg&6o*#S zlUd}D-R+YvKFrY{Wk@4IHhU0A^GC+zg+j(9Hk!C|yF4oS3bFR_?~Ts>KO^yM)H0}i zNOY1IZW7hpXeU0EVocw8K6pd@i6lGsA9D(G7~=;`6eFNam#kG^ zJ$E=GO=CT1LoG=w>@g#;=TkHSBkVWBKs_b^5$<2!c+kp4Wjtn>gLRPD>6;h|l88G~ zp!BT9rq|M~**=qu^sIfUM?!u!`F(2!utw7rF0D}7&3HHkerXlWWWe`uwUFGYOS+5f z1dsMX$<4FjkKeMu>+rPIDQPwVSHm4q4h~qmk1b&5$vz_hy8J15^N-rj8;+C_&-YQE zhRDm}v@F7}ZQhg-r>;*Fw?8Z;6x^~Z9zPov&l?xqSm6K@Xy#YIE;<;xS$OF4&;r>&3-;o&MDoyYtWR@rirek;SUy+zfT*a}11LaRnykj6`1YT^ zOILCYU3niRes_mxK@U<275zr+mbl4dL!O@lcA;OHua{X1RTS(%W4o%c1T z3WF0>_F1BD?S7J=KO3)0aGT15eT2CgZgJ98tPzNahE?(*;gRkP5g+y`>6AZ1rbaI* z7iZ;e^Ib2e5bRy&v>n$-Cr?175Z$_s&q1b033~un94o7 z(bD;lT`83cT{kfLT5amvVXIV2At9lm|59$;G^KGI((19+y%TZj<}C-PRmsGZB-31> z{gpN)&Q?Csj#2t$EWZ{KZTx{|{VHyiFBtNx#gF*pNOFsM=CJa3sLTBMd-wS8z1bF@ zp9J|*Ywj1cMPxgGgX&*`!+C+=EdWuGEQu^EElAUTMt%LyeM;2h}0HYk0kDBpxqu_)JKAlw@0aO@$9#>w$!J(^&a> zsfR{_j&qgsHP3Kr?O25Wpo<(>BD{72b&8|f=m|M-e|ssx8kO}&EG8hRa{dckXUDu3 zF;=lDmqV|Q}phV9&$TdcuiysqX4sL@REZ5pC%$sHnTE} z4r4&zwjYJS;+M4?!|(ltFYXR-h`)!_bbRUs@pub}}ymFLX#E$zWqdtQR{E zhgqZe@HrGV&HYS7gNC#0fB9dXh$^G8pG?FEC0_Z^NXA4%&(h?ZlD)ar{c@Z%PW(Tv z!+b5Bz&&aw>ZZN%>fdVbjOZYUr(`Ur17q&ZE*sEcfFO7puwIy(VL&_7q3Y_u3Kz2* zN%t;Bpx20dE`45Z^Nc^1SrIXV(!WGyB1o8MfMJD9-Qz3I{&_$ZJclzHGt40d8V|+v zA?+aK#dMy8V#G%0w~Z9qYSTQZ$1h|a7~X{Xqq5bX$5^&g%nRAxTN79-<7D2VA*Bx6Ypx-(0nv5Ka=PE>V;7%P0(C@ou74&RFv!jIW~viuy5EM6hYz~K=M(_s>lHQH#js94NekW6?k!TRLjZvg#?6d(rNL*Hs_i>0|f zh5IjR7vjPL)0CU+;ynT|YsOm%ro1axrTf58e4xaqds@7{SS(V8U)5ljyhNh<|6!ON zOdB3TIM$Mzrz-(c)9JkbrRX4ae=&TN$BaBYDweOzRJVQ2knk6@|NPruC=AI(DY05= zyc>Nn>WQ!+H7fB;sWdEJz_MMIE5l-oQK;H$cJPlU@o+;c1$;yRot%p}*11kFJZllj z#77Oyt*jHEby)*fy~}GQnVYM}>$8g(Mz~&js?AJF8vu-l?H(!}oDlT1F$-B^TR$_K z?2`tmj(wrVs`M5eOC~7ZNH;Mu{kkv_usk`((mKiUD)x46(I)v&auRHD3So)UJCc=q zp*kl_jUb3H!zACpJ;0dFx1d8~;}hJ0o!?N8-Zond4@ZV?RM}ad@&{5`^2qnd-ih*> z-7ur-3d+%Qk=y%rCTVib9+LM=LrR5pD4FZBICV=RkfH`sGiqe@AQFKCfoJ^JlUzI4 z03T^LSo9eU4t2Q0>}r}X)W(@b%qD;uR{#RHb;NwIb8Gb}I2T#{v?qSS@K?`Z<{h?S zGHq&OAbOB!18tZ=4$^~ium^3q7p0ZnzIjSAF*^da%SvJtMLGhUz$&31gYak?sx(l% zg?9bH;z&r_p|D4fK!F_HbC=(Om;ITNZf5cgb7Z^s6e+?U`NdL;WD51){Hd9{9}Dy5 zchVX_iivpwK9Z`!WRI|QhTu$e%wwnmv0k)b7jW`VG;hFcbL&BrHB(_~Xfrb*VXeI= zLrg2@Dm1wZ_~_6&!J~ z+)`}(W5LXu83U9i{ZCizyL%X$<#Iy4{||nz+x{fDgApBXr8P@N_&Bj3Pd5w(C?0g+*^y zrem7dX?r`r%JN;NI-6dZ|CyL^zo^&aU@iHz%h4-@{Sh@2;F;t;>P;oHhtJS0iUz>H z2`>NFtoB_AfXuLFI`uZVbKJSf{PBmtsn>6DHF;YMZ=tKg z%xaW!YbHo?U{kh~F7`TW3PEf^QO3d*{02&{^3zpU(Ry~ZEJeQBw8oP^hY=LB$-p)S z?z}ap(a)M>2I@ZZw~k^>qOhus*#(UoH&2)6OScEk0<=v+Z8YYQdIZ@7p-Lo>z{uZW zMmzaF5VNQ*IW(@0CR$}}LQgbcP~u~GSa?3E)6UpBY&%IEs@!#g}Q1Vkpr9PF~Zm5yJq8cWa98BS0cY@V(LTNziYAXljJI6Yp8I2*u7 zNGR*>G-zN5sxmG|8j8NCVpDpj^?PFa`z9iIe7=zFI(@hfAS$_b5(qs|B(8HEsqFd_ zr#)zsME38O*;4El5#xa)mbtj%d;tIoqHk$^^UF+crVi%I0(TqL@K0;h;WCyZ8h#xl z%&!3o&@dKr8gJhW;BxrmWn;~7=PbX~V|8aAD(%Ge2hf6Fb%XcRoYW>E0H^YsJ-3cn zp1ncX+$Qa|`HHAXXt*p|wWhSgPQY3$Akodih6ZFc=(Kbw%?P_hUX5xCh!jcRWMkL$ zSMOiqx0Wq(VExxgQG@T&8Sty&KIKdxVW6mP z_wJEq;51#C-9!wE2j#F+#}OR?KT%eOSckJ^qBtp{lW;$lF6G67eCL?Wa8Sg}JNpl> zrxvoY^*{KJXdQB7P;1{ISR~l@4pYCNmioac7o0w%)D8tSn78=*r^;@*SO2dpZP^$>r01#rfY-(HwmT=%Q~+e6V4f zIMuhS#V)2~_Ac5^NL=PD^ky*xOb8}n5+wa#4=wyj*_M`3{w<>Qs)};4mB>RtZ&TYB z27=Y#1>&i}@p25%@m6a+%st4ms%UgU&T)Iks-kj&zo`{S?r%~uty-c`Sls_K_vVZN zCRc0Upb34sTT4XiePj7Zo|mK7=kIXYa}gx7NAOh>g|wltyZCTC;*Q*3)awS{`;oe` zhbr-!h2s}f!KMPOT@4X)&gHj_J7VaN8Q$>S<>4poDytWHq>$cGJN=cp&5enIEx{;V zUOAQmZfhCWmQ6%0F1Au#KVNg>+|ARZgJVU)aJ;A7EwD^N!b$5GLgizCg0apiQ3;$M zom&rSaAg)SlM3r|?9>z*{zjAU_ngKkWqFSNzc;bf=_)>ON0e{LgjOX%k2~^)WOf}4 zxIBPa2AWFjNuP_simnQBtbL5ZjLgRC`w6gHzvUZEID+xY`kVzls;UTAF#A5{NB6{N zvO`TFjo;n;P9RXj51QrH-afJoRP(gX!`2~LN5%l}yY_IZWIs>ttu7wP5bAyYmPe?Qr^8z(&p>ZQKFq7A0G-F-Xkjo ztcxka8)}!9=6~gT<>Z$y$o$ZaTDFB9E1a7{HErd!CgyFd3p;hGz-hBH)MQlOuR+&a zY2{1Ty&4^X1^3>U{J4Z?d1Fr%?C3+M6^$Ub=d$Y5=~ICy``yH~m@f}lV}n=^0A0E_ znc3VIrs<0f`%mw%U$|GJ`sVzd@xL-prTk61HVjAX!TBZ)Op6avK>p$j9w5;wFKtyj z(ngeyU2xXcBvPkhcdj@u`5-CFIkeaK4fk!J=SK8~rYmeetP0yrKEAfJ^h z;td9k2&E=nX2`pxfbNBx>K?K;N&U%bWg1bNu@!Gq`|4K2rDEHdIZOBpD084uc#`j;Ee=PKc|;G>z=JI_;`ji?4#t~9xKswi-<;Zj((YyVw)=|WWPdx1 z%;uFpSN<~k!|KUW;7;;(>~Tp-a)p1sWkhyP-D~GF>L`OhdlbBPB$x4(8P! z%fnLmGYGkY%BAwt?v1xzovo5GYrNW8Zb1vAjjN))8=j?MygaX2jkgNL9+Q7a(YL|Ips{xV;fZ zWC^pxtc=aYvKANh)W6%Ok+PbwE(*&SSi@cNj>ory2q3=?6u5yy0_IdJ;!WFK@#3{TyUbHWs-x zs0GQg|M%czRCe**Hx0y)Ym5|^U!FyBlbnq~`b&jWZgFFN!dc$QoX4ub+IH@KBDq9+ z=TXe|T-j^>ql!kp*%8Nn?9#T`@e(kV5;B~CnZO$32Dw0B?P1?hNO}Jbiv(v8bRjpi z{C*FQO9vFc3$2z3mo+!SLe{-qkn0h@k%WI1kr9W=+2 zF^C||n`bD_f*)r#KKrt5E$I>P-}X;s_RRvajVsb3e|(#-yE}_cC}fJ35G$uNBr{vp z227i~oP@)^Gm}V|`D-D7)5`E%LA$132$ztu2!5(1hn&%>gbNzHPK)fcSc@^yR?GM4BVsI#^x}lUY%r6gT`zKU;T1; zmvn#`$VQy(&#+Sg=8^~O>^ekQxxTpjzR`S;&gdMbA2d~&uT&K=K7`_!PrPdB%~a7J z`o|;iN}&!2PrKZhbJOtn;sSRLGB-3KT;?}_4E*o9Ko{F6%k5ZJG9Xg4qhSG^syuRJ zZZnlUc4aY3b6zTg_-=@U5L0m`;g5Vt_A`-bS?(e@N{5Ganj*sh?&21YM>iq@V*b&; zC{_W;3|1%?oG$Ij&{$y@Vp69sg^8lzm^I*+-okUclne*WwIbmZ8h^YrW~>X&I(P9{ zGr6asV*8w}>wIl{+5yV+*hl=^@uso5|2qAHUJ9Hs6bb(5zt8pxjp{7Emuvh?BS)5khpbh6jE#dhJZSy?)p0|HIn z&u@CdbCOq)oB?WPVnyZWx`nxk4-NRAepf{ND3A39wOq|@nyBsvp48JXc(QGEo<&hM zVg>eBo*HmX4Cg@$7GG51?cOMGbxYZ}vWylT(BRR6B}lz^|4vx80-rT}s9}Qts>5#={hS;1R4%;zj`>R4=T)C>JuU8W{ zjWX*;*Rg{J;7cXNSIx)_s#y~Pkta4PcEq3$xf$FR{lL;u5b3CQ!>bi5 zk@xMM3TzAGUVkNTySu~89Q^R)&sg7B3eNF4Y?I{L{a5&PxOc6902H*v8 zL$qaQ_Ou(Vnfz(DER2+kI$U?N)@eahK&%y8(dR3vu2G%ro_np^&<;#G)p%H+fc-~+UQ6yut*e4 z49iSX&{-gKZY+S)7snCx`|8HLhU;wzXA-9|0WWhLf&8ae#w^sjcz5o~3)xerJtJ~h zDej~uCpM&^*g{j$&h5G$C{Y@;j0uK&=E8K`{J%U*5rW_39{O0i6gbwmVXb6+*HjHR zDi(y8iafEVWFkJ;$+Mw><~&)TZ)HQW3VIWl^*wT-Vbi;W2>;i3#J+8J5RZ#ook?Xn zdRZVdr;s2E<{$!M+QyCo_)pvF;_(RjCu)~JBm3c;>qhz>A6*ldu^`j>?V0R0k5YR$ z;Uv%!6BRvbD+Sk^MBftcGgH3E*+I=AxoT(>E-g=kLW8U=bb0oyICS84(?-)8Uo&&N zEP?NGR9XT*tozc(A@_e5?l%BIQ(;>$bD4pCAigYn*!=XHR1(UsdO^2=6IPz$Myx!b zBXyQZApomIYez!Xu*Zyfat`jveTcc)nJXVUh>!lcc>N$8J+s)6$90?-GxUZ{3@b+S z9%On9PKsbJ++uTE-}KI8G-wrf*8LTis)*Ex{BDQTYLtBcw`lZH4?Zrv3CU~bo+ar5 z=D8bwImFMYHaHUT8;uyJ^kkSBTZ8{0>Ph_n^Kwu!GC975$;gK@pz%FVq_g2^i;867 zYf)SOk{&qjK~=pz16`A) z9dLgi%t@tmF12M-5?<1)I7K$KFWyyV>%($Ps-4i2UKhZmLG)3J5iNW^KC*w2MCKpS zd5zjJ?H=8hpJXZj(53{MlXLX-0&OP5@)_I2x@qw2gICv`LiU{;`>T%3Lme@fPYx}i z^(uQ}97YwT24Z}vqfBcc{WykJFU%Jn#6praF(lbU-7{)2VV1~;f<<_uTj~T>FttoL zg|B@)7GlWU&NjHI69F<0VfuFS3UqD(;F2>u&Fe1Ss?3cwkDze40O8T_R>EtN~Y8 zHB50c5~6sof#ee(hfV~7;&L8?2m*egB?O)Pg^+ODmpIZ2Wx3;LFor0@oEoc>$7+4J*w7it*iyWBv8kR@Bg;ucl9p?j*koPH*}N1H1@7Je*zv-I5g3drx2CE+;m5(5_zz!cGo(wQ$3qmcDrCCCaJ1G_T9xr$%z%&*=C zxpM@h)_oBx8v8l^R(CQTRW+0EL;zpU%Vt2xHE>2uYe%H~v>?837EEWZ|LMH>aDe7n z;m22?N8L~sCaxjWC?)9n`%nNupwzK};6z&#bwD13aJ};=Y_bB)-N+Rld*dP)bcCH& zh{F~LRg;jrPBGxi_p(h}VK2{jvD#dlD@kRBgw_1_z*F{=8`;r0bdC^L-!#zfj*FTz zWi#~I@R^mJMa3q1cb~#Nv}`LXN^Ig^7f;@JrY64B@t$O=;7<4>4oSzG0;9?scqK+`dbFYzzlU6-X-2M5 zHAX3M>{U--=)RD9;DcZ*GBm zHLjief>A${MJR9(sVt@_TAC=XS*SRYx-Q+KPtvbQPiukvfbL^PT6ZjBV_DhQC`(MF z+u2_>mbR6rn0t13KM?(SFD5q~vsaL>h!E`vMQB(O23Y`(vgZdd>V{rC^S&@~)LaLj za)aQbe`+RU&&S!998(vqdK=1x(oN(l?vZx-4{bj@D3-OuN-M;)MdME7BX_vTE1}HL zRd-FeeoC6rjp4i>HO{Yv8z6>X8?grqXXx$g#}#awJqA`(<79e3(Kv%8bx)sPFU`QU z=H-04pq}!rvR9kU!sfa$e+yIQq84;$AyG4w8I9Lo4o?y23FOS}HyeXK@vk^k=M5=_ zmX@v&*~2e&$#j{u$hR~3=8rH1AyfH@wV(XyY8nKOJS?(ZfVX^yYTI_uJJnT%$0szA zT#DVH6LC;z{9{_XbnRj-v>NLMz1{Mgr4*lFx>5SHVd#-vPH5+-C_- zEtt7%zv5!6!l zbk(<^U_w7N{m(d1AWVP6D`Pgor_?NWRTdjMMA~H|JODpHz`q`I<*&nPmrCBzh+2b;@D3AGM2&r1vhlJZ(s%n5Dy@VPx%WirJr$OM8 z&VK{G6fs`b2bkC$x;_%wX~m|Yx|5jwwX$FT78456@)}9zoA%O;rI1n89|DU@}*lj_kWsWNSquPH80d0f_j$bDFR) z?z%NL3U+Gkv_3HU0pEU5b$x$9+8^>#eT1wClXj#Z9=%{}_I%BM(B;ycqsre9`?YPVbr$DEtafv7w&_&-f$PG|&<)>M757Z>yICD*g zhB5UY#08Wa&e~Wf<*Y$xks+ZBSycy9wcSx_pv=+JRt(&u!afA2%oP%O`cG3%UVg19 z01g~3b!aaj$Wi-XgYx@2iUnnSgie9L2))OTqlt6O|6C6r<;T=}fj^HhCvg$#4&4Gw z=AR__Bx1$1G`%RV>3?%oUVqw5f3R5fQ}ajbnjHvq=L5ayTI=6JVq;wjnj|MCTGvOs z9tq_LEBY!b^D~GD_{&qvXoAjd!WQ9=cQ`nGHu!2x_x2gtfW)*{$IT6$ZuE{$E{SnN zPyyzglsg}3r@cBHz~nJM#Xe>0`hC848Mvgsg>Jll&;VLvcV=c?D$&Q1Gb}T+hZvmp zdWsx}qOhpo_rf!5Iv01I1j5%kg*F6eCndq|_$|2%$W<(&V)-Qi+e}}mL>q!1n}GIhF!E<|#n1S>BU{;9#9~ zCGyHkpwPxkQSfd->S>H?EIonSmQujRd^$L(ww^UT9>vuWZ+gtqeUSm!4irQi01c?W z0jqml9{_lzKd^~O+WzZ+oWFCzEuyKu*wF76jiZ*mj!*sTVMuOXJ1OAt+>rQO*Iv&T zs3DITx};6}u+L-c$T70yR>G=#YjT>QcC#>6L-CAMX73@#LM5m`%z6agR;5eXVYFu~ z172=~qUx7cumULgvaYxUrk!pdDBt3)mmPr=-Lm)NFfsf8@BvfOr2dotGajB9hfypF z#R1#*uQQ5QxT3&cs-G$?IAXgUG)LxHOJ`&j>^zZ~=~jVoH5LJHQhy*mHhZ}iaqr8V z&INY7dp>s0z@MG?WA0J`Z|siRv)Yro-hCh)wbjVNG)D0b3WIohQ^$*4GBLjVzS~+r z>G%2C&(8GK-_97s=rz>(xD$%ZrEk-(ljxiAfj5Q`OZ}M{t5*;rx{j!>Tqm%-G1T{m zI!G!}30>xjl;F#nDXe-Qh=iGI+2IJ8^@_Rlr2~Ucnc@Vwf2QAwk+Vz=<@HFHpFhhu zbAE)qOjn5Jm&-doEoa9x`mf_*i&a+P`-KS4RmebcBbZ2$aZY9`PCVOyu}HK;-Idm{ zd=*HM?%z^0nHy1&CLOpa%>E~=^G4exhon>Jgw2oK6^cmj;RNk;EwPSgtYuIO(>^i( zkf+eJ979?62&sL((WV_?7wT$m{EHzVe{~ai^1;)4t?{DNd}{fMGvIj80LMiAcfHwA ziH(@$Z4xdo&uaTJNqb4`XkQ))?oIFBSWsa@ILwOo=X(`n_JxgeQRuBC&gWqRZtJ=e z^jI0{H>Gu+loB5fUdpzvN?I|dW9RDA0`Hn5t4G&Jexzx4-^CKG&5}zM>@wY1iCkK! zQ*}@Ebw}2~boNnE;q|3B5Tx96zkjJ1zhR>+(1 z#-}q;N+~lHBMwJ78cU}F{%L;t*+mkt69F##%tdhu6~h-4W?I?w?0@x%jYnSR&#UHN z&^oLOT9XKzA)t+a_LQA1LpPLT?=`A^$K5>LyZ`G{3eM}HGyDH*d&F*0GV}x##h2Z~ zvGkF_R$b_|JCo<(xX~quF#rg??X6Nm6V+;q9Ba)9zX^yBk!u-jV`+F)H1H_~GPQOiCKT9kE}t8Xjof@X@Yt&B68kNWzw`#!ir|1KliK7vxs*NG;H;S${Z940cI_ zIy&r$8%L2<$@5qGKK$$ucz{(VFV{)kH=LZEq>LtElNgYlHI_O2E857O?)p(OOb9N5 z4W#`94xBGj8;;~J7%)@JfuodDy)^H0==K*3|nq`~(pzt*z{fT)ut@RPAQ^9>a~ONXQ; ztm3T!Uc_b%R^TW|{O6*(Hk5C>YdyZMABcV*RcS~Hs*Z7(b8@-I)GsQZIXMECGqe-k zm*L-q?QhxE8tc+%Cfc4_WJOWnCW=u4+aH?tDv)~A>+B^ZCHtW?z~qHE109*pg9AhN zT`Sh!ug^L8tBe>OQsbqwv?y>CHDzn*qJsf}ykie+v*hLGz7^=Q2Y}e{+Qq%O)Y)w( z%i}|s!VBqZuBs?R>g7#q@T|DpAwKMnn3Wm083NlTZAPMN$=F!-0QT*WUV|(_Zabw`iwBtXpzAWyJD@4;fm_~Qd@78(>e zhR)J#lifC8hB~eeO3(t2m2lmm(ju)`<5iliPw=(y@w?R^s7c#1AsA%kHj7BNkR9*_BZs zy54VfEVQaIRDY?sF;~6?!nQ6RpmWX>Dq-{=Kl!*vmy)8D2ok;br0xW8FhV7&{8kcX zdSGt;lH;Rk`PU#~l9dK*7hQ$CE&+@#DSNb53=797si>OUph~qeauk+tt-b8KVY)nl zLbk!alKF5r4;mX+k!c8vrQY$-{txXnKJA4tha-AM_jomd|B=`&iUKd~j9lb&7s-mv z>=6fV>Q7+|7t-7On^@6M6O^i)o*ioLICDVuw_`kk6W&g>IGW2xdc3vcZvDLonBIfq zK!_$C7s%tIoUcaxsf4EELX7re7M?&cJ+KAHBTEfCs>0r2=n01piXiMVPz)>yXl;IW z#E2?Q!~LzT;~sl1H53sf=#@Ef>wVz;iEuke#8Jshv}a05Hsb#pt>UcEgB zI*i@=nQ4J6`NZjD2=Z}(JRt6o{YCzVg{tJ+{Cc}J7eVE#M! zYQS{XpssLjxEZU(Emv<3tSe0Bds6GmLvV)-I(j8Df|Rb3wxr5K7{-FzvL+DmeWf1_ zP2I>!HybU4>m?9KS#TX#oV+}bv^#;Wb|aTsJgq4#s0ZR1t)mgb^LauNop)U}EGB6}B- zSxiMWO9Nm2=4Uqy`L_1Pq>YIGkEFv()~ ztNpMbgslNrAK*X8zW;tnS)_)iOwAge9Jz~@wCPO%4s;LfnIfe9WG67d>Ha9eY8Kjb zXPb}PshT?XYjzQ9XH74T5sVoeDGOG$37S#jV`yFXc6_ktj1y5AiIyj)PlXiTjGCQNF5Kv+Io!VvE0L#sR97 zBlD~V-Q@0U$D%GW5lLM32#4tXraaH+<6A<|z+p{9RULi6d&Q~`xt&kPg*Fz!}^#d$my!)|b)&=zld_#5c!9}8NY5Mo$>B-8KUI~b6fy!v-xKS;8wHUuq z@3HS{LRqufbt-G?v8bGITPzDDNtG(`iCj;Ro_ZRCR|Pj|7~I`BZk=mySfqtwO!|;d z7B!c$@9InSwF31|7Mu`xAih{7N+Y0YkZS;{ImkN7E7@bru|}P>_pTVAz&E^3L1HUo zO;5p1M~`l30EShk=8m>0l=YJThvzv}wbL9NzGr>|E3%ZU_`9MMStWm>fVUw9Uk}OD zt0>=)97CSCmgC$TNO=9HR*5G5qgC7zzSdEn*#9p`prvYV@fLUXmJDoz6LlfS&%-*= zu1=LZ5==tsY(Va%6xWNOPPWs-h+R^iZ>P1+*YI*TmBO-NXIUVg+v1K|diRa!?lxo1 ze$vmNG((nEzFsbnDWsOp1idb^`Rqx64V$JHSScwvL_L_{r!IXyiHqO4KJVEP`LixC z3QImE7i39(bb_tH{Pz~EglF89Rm$gd&47=KuM8mXJEY6pHIz_hK zG3-kV@58VHZ}}7JZtoUyT<>T9JBBYxkd1M~Rlxi}zp_ihc;%=rp%Fe&7deN(IzC)a zay!8)lD1cxa31b8Cw*#Nmf&oJ>j8vYzC%%H2ovgWJ~h9(KwQk}KahX$;cPH4&YuAT z!%h!I)q&{kKnB+N1Y$>rn)qfMECKwqJ}FE_RJFg~Icha}&IA+Ck>k&7?j3ol*r}*{ zw9PA-c3HY1$G%F@}k!MA~dr{A80Kzig z`4pAKhNjfdmf=|cy0?NK80a`3gj*15Ybq!@GFF@US=R${9$qd)wMbF&HjaHSx6Px+IIwAosR>2LC>{P< zT9p_!1u?Wmw6B3162DmJ2zpbsAM=r4n=d1;RQQuR=E3$H%HalJE4_t)Y`0mT|67wqQ2bCQK{=#m} z23T37A~-R}Me|m56OC9Nz%$=cDFCF^bG>3fxknx5^V7n<QVS;d=d$QaEeMt{l) zm^p#+J=1MGtitM|Qo1cC|A>-1%&P*E;qWz)7xI3hKf3K*`FRcLd$pNzC=ELfyYB(h zCkG;96kLabNJ0gav$*>4kQX^#FxbS@b%hemIKP(1a%95chAj+^QX{8thi)A0iM4!d zRK>l|p5_2C@L%!3VkD?jYHRYiO@kYz*Cx@Lj{4Ud7#g0YirfM@pdwhOBhf8vd&9vAx8m^v!_$DxfOjU3heR-)$yBA73ZJ)Tk%R(dn-+2e^ zLTQTxDQ(0E6^u_Cs;YG*+v+_AX$(=G%|oKFF%*YqZu5oDYXy(1mB!&-?R7X zr*8;G{!$2r#T@+bG8YzQG5iq)S&x)F$1VtF?B*dG<@op}F!@;A^-F|+_GR5ND&BQg z+p@&u^hP{7Bvi%dz9d@Gff@C`5KlvLdRL&EUk50QV(^5n$$HENrqmms`V zOrOLo2%uGRK{vcfxH(@_c5GgAP>Z#Y#1&Sld{KkAKqDTXeLk^s#RC04)=%!Av?Y6X za|*%srn(D^$F90OW6%ob*EkoVjXj13OFTw7brNI)f(&QwJhflxA_IS;EtS(9D*`tc z^8*dpq}$+N^YH~#tJ=TytFCOv?yvpPb~zAwE?JP*yhRQXA4D!o!HXG$`AdJ*xx;0$ zxW<16Q=!rsx-LbC(Rql&-h4I8F|xn*@*vHZ;HHGkde3|pG${8Bk1YA7{b(?Yt0U%9 zu|C$1&xnB!(cJ)H9_{P$k;7+*VN$=0nf5le??+CWS2PdXKUQ4N8eS;{$;PfPhW3{1 zZ+A%K1?q1W@mhV}g%P1s_@jA|uLZg_s&~8(Yr=U$0bD31AHh7V(|_hld)5Dm^NCW;aaRS}(pW!Sv9W~Tmldj*W4jV_jdLSE1nDx&o zTx06`h~O|^))h`pp*<=%nX{tjKO|i=z_rw5c9o0ainnz{_+Fw(5O)|6N=P1LZk20) zzhpWE9`+w!Ab_L2HAiw?2DF-_yxm=^Rsp@{+8pWWvmBpnG>Am&Z~i*h`rAa-B;BBvGvatvX|{)5+3M+|27|9n6e zxaAlT{UOfPcgU!LVUIv{YJVjuw<^y3j#&Jtktp=}(XzD*mk2K&lQ%E>}TH-hw>~|6T1Y(XIvfL~Xets}tAShStYZ2MgFl@_+Rt zxQYkCcg@M!mhMz#>LL%K;YNPhDooBl1Lc9D=d}s47fq^xvs=kG90G09eLX~w*3#J%x zwB9ToB|;LMVpB}m*q6TKTt{Kq8-2y9Gkv^(j1jM$2gZ|xHGl|W&;48cer?d(5R^~; z8Bo(061cAEJsf&j+q)8aVB(l$+*I;cllr zEDMF)iPA_mq=EzM^3nNq3q2Uu1&veT`b7{-54l74g5Y{(XSS%x>{RCvE1bJR+~O0U zIkPf;K9#PNII8aXL2Sr4PMKEt?4bo3`D>EpdZwLM zP&r;64{PB$Zg;Pbae!epjj~XD*N}9p5nlXyV8dX6Y!!=P=AhoZ9&98dHd_zfBUHqs zirKdy_l)EybKre;hki&^F<-x50+LBdf4#!$FIvhOU*#~Y#84n{KY`Ids>>0zAxjds zFM?8u8GHIP;S?NBHJpFh!es)iQ4}C54h~4zo>1q5G!x%t?H=}6=)h{Kn1yxJzT;XylZcO2H~oNd<1pdLl_o3o}XW7)Ut2q-I&XfXvWB z2BXYkQ|9yl1#Ykc>1)UBd|O?bT%v%pBggHL7nNVysY$-$13FV7VXo6%Z9dKfoST)W z-hG8x@q8=62Yj5!VNX%o8K0%*$6L=?=XxR$rR#5^Hm zIi|Q4fUvAb2EDH%;}eDF6VLx zcr?am4*e;rI@;}9}xmQ9WleA>1gm#9PM)#s5pxX7}Iyoh{&I?-KMeBvQt_=8=3%hN&yzo=J zER^`eI)T^n*YU%W+|O5=MEzSuKYRr(%8$gfvtE>8pZyyODTEQ6`*xDi12nj1&3#Xu z6wl<(%%ZU9=`SB{7UfH%&c@XxQ;Dp5lCvvxfQs52@+A5eX!=B}DBZ8`K*F`%F$(MN z+-2ysYQjZaH__$9trgZqyOyr;A4*Kvr4GU@XCNS?@01o@2ul}hEL)LJ;fALj8S>jLBR%04+v-iI)2cXK+v)3>Qk|%zr zm|cnp%s<6tlX8K0Z3JnD3jvGBb}ooz?be3SyrtR&-IdWtI4sKsYc=m3QOBIci?S7A z-sjTP-#K5-PAi69_&12P3GW{}Z~=MAC1oF#hG%r3jQ|$Tq3&dn^T`s;*OOY6yngMa z&tgQ2X>)R+QIvzu*v?9)RBMQ0`MO5lYZw7mxvoQ`bkDRA6g+#IbMSX!^Re&%GgsJP zQoYk(cPB27!hGZWWmDUo31)TU62gt}DPl5Vhes^x3)G&oAlD1gUaGgtx9Ai$!8dhy z*A_bF69+Knr~hM<;I{5>?N2yUO91x3zYC&q8a3itAjpbg`F>f#KG03}Hp&OCG+2+J zjf=S3PY%hoivqYH#SX_M=_x`Sl2`s|p<<`-SF%w#gBvw@^0C)Vox~F&hU%k9=NIP0 zZQ_#0x0uwh4yM=38pOpV4uuwcl7#L-`F8cF6wc;dBhEUpq zJI63|M|wkpfZyit&d8o#1Dj^Hln{tO8-t&VxvZD17al`mKxyjwzu34j<1+&ypbI`+_;X9uRpWjFQO6^TITe9x!xHL8cRXUVe;p*UsP&PHu zB+v`u01_`yc6cH@Sy>MQ`jcSD)}2%uTg^ui>|8kTqPv4&2<+?`q}6o|7<|SR)fubZ zMM?qqe#A@?)QZ;cEOwPhtc|eRZn8an+<}{>9ocGJfwN_0+^FLXT6NprE%qoLHv>L# zLOX1@iC9OG`oSL?i2wo1^RIf+6y0D-aHZkX#H`f+SbI0!UDOdyPpt#lAI^PVCeeXV zQ{5^-5V*f=L0caFr6uD)*9HdO#HJ#@LUa&<_smYG2^4cITvyf7;)Y0 z-&8}fiLoJ2?iNmh30NLf^4N3J`@#51dxf8?Ffm(XVM zsL+%m_naN=1^RHiUUFd=U7n~xHI{z0g5ht~E+y$z_A1cA*h&8zO1gAk>Q0ko4C-(^?`b@)El zw}np{U=6mlvC~ZT^MV4{Z?P(L3#u)|`o<1WJArK|t^8TR+LnadKQ2zNvDoXdGu>gm zrj;8toUO&+(0#2#j~rI#ATKU4r`Vn_kBi3_vN-6k`W7ARLyR%4Oo@|?kn6;eac#Y5 zOUalpm-Z;E=0r-Tku!(@DBU7T7qU)$1R-dY;qU$!9g|A0iE`3#-ax?#5y zQhn)*c@RF|(>?Cx4&DS86&Bggpw<(^OJ~b*gn(_<7;5;a5_+&+fk%zcP3y*;`fqmm z7dL7eBJ~|r3!CboC0NIrqzC^0XtyMe#qcfkuQMddK>6sUvn`7MK3C;?_$IW5{Uef% zNmB*>ie_wB<1d&K_%tu~gfig(``~J854-Dw8rmollR=C+2Y!=$=tu=RctFDAv(T(0!u4zvm=Jn?Efp^G*~bzGe9rE2YIg(kXp{*!xE66Hs%m##f%(-KNFV^e8a9XUt z;YSnv%gkjm%<}ylEbHWVVHfVKHEY{lLdIlX;rY0qR5mB=&FC2n6*8_5lwHz?nHgCS zOo0X7&Hx`FRwhX%NJ}kFZB8U6mv}b;o?rv=2`ddgNJ>{NeuM}Hh4^Jyh#`K{!yTxY zO|49!`FkEQ4Vw&?OCDqJe+BwkfBJKvX=Dx@R|OXHK9Dp6-5`D0&bmaVnWLWYryzG0 zu(vl5k(wC?cUY9~la1*{D1=?CflY+gN0Nmd!tR|BJ)k@j>z_woeYUwr-Hhpj)4UlV z!96o-^J&09$Z%Ua02wGH$|E~F*O1uWDMhCnU^!v_7_>yQk(PRgg~sw$#=C7<)Z z?faD+ob&$EDts~vZWZfMyc)UW>f>Q_qnh%e$)hDxzJKibE~irUFA6;P1ZLW{7Agy< z&e`WguCh{yMO$uKPDAHqsoPb9BL&~oEfP{M;|A|y2zx^D6MH%jH6cDd&v-#}zXI9< zzuxU6{Rr39P*Z~zl^zp}Qw2P$kq%m1f6^V(Txz;vAT*bDv~QQedO2JoxBLCS1I+d>tLSw_ zx|;y$UTc->-(i#^4La6oytnJr$4$99j@h&3uq8l?MxlVKqe}-(K0xSK#j6hYwyS@; z48ZfSqY=nYGa1q2PFk>3LYl z^%n-t*z2Aw;^x3OZ|K|@X?He;s>mPR*KQ-Crkcp<5hxc%3h$V6`gx>{fDb+cZfyLYV)e3&lKC6a4at3k_ViZ1k67DXJ_0mAfwCbWv&VlOK90lc`#)fo z(irASzMQOKNQQ0{^!?PkwJ;0w`{MDA(G4NbGF;}W^y6Aex7SNyee^ROaN9YY_$^JS zGb$th-sZUa6q0(Fz}3Klm(}x;^?+ybPc4f0FNB^Ixe~VTb|4w!ifXZpi`Md2;I*n1 zTyx2L1NI~@B}8b_ID~~E+uTl9UP~u;ZvIwd3Nj@>7=bx(IOB;=MI~{K7C5SRbWGU1S&PoueWjwu(a~mi`V8WsJGeX$)Aa~Yd_EB zN~aqNrix43R};bo3Nu3*N5!xCWO?!GS@UnYt{O5*a65RO3}Q?W?LlvoA_cTNNtC6B zm#JL!jn?!xEqjsN^|s79O6y|fRW#K`aO4x9u)Ep8nvg4Pw)h_>Z#dpuG4YaSZ#VA` zyI0GJiOZatJU5cHVX?6dLT}OE7Y~LqU0;N2p>9*?SS5`gw7E*{U1zWC?`s@Z=B=$y z>*xNFXBlAQM~9i%W2m&;qE5rYEiw z(Y*^atnrvq2ejx(ry{LWMG~g{)cY8X3*?~jB`G-a`dnzQOi+hq0C9S8XqV-+$cgX* z!1J@Vo-aofX@llT6G)2WcQgE-Pt?BYW8OB+Lp%)dN?OIFiU*eWZ9qlLbvXHSQVvDj z26~Y2bpHg;O(Y-=P1NK+DG7?75JXJ2ad7hv{0Z7_Oq2E_m?pbMPhKf?zcuqF`&(ot zf=or1@agPy9v*OMe9&}9?%v|_`e9a6Wzdf@1@$)Q!1x;`a!r@52ngOxiqx0t9n?D! z3MmkG&mR`~yz}=H;g@p7pl8_wkhIEk%(QD_wj;DL}C-#v9Q3-Ujw;5%!u3AwOh!F-+>Sk4hdox_Zed%z8@A#Gew>k*MIRg2|`VI;d zqyb~Z#MC0=-*h)wL353f^niu6BM3^XRrY>iD1Mv`3U+ljWnw@;5e=P;`}3%nZn_1I zuwm_#h~11s)FgoM3-}_3Qrjj(5Xs+V+$I1_6niOE>@&(j3i(#$;cio`=V7>Rh{VJS zW19)u&nQy{mCka>Nlb^kPTURk!4gcyt13Kc+W{&a;$L=*^U4asr)U$B0`p)+YD0&L zsTT~T3Y9DjT;HH$q18LyuLhkW+2;%uwn22cJ`z(pT) z>?${5+r>U4lqJ8h*d`{)p@htG*R{*|!6NsuyFgZ{XgA=(KoyPR(bjR_Uiu

    {O`Ocrw6g*ad9 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 Ubh^O-wkZG^K#72f`nOvQ~$Wwsl5=A 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 aps<*9VIS0Eknpo8urw?e{k8#|fB*oi^5s4N literal 0 HcmV?d00001 diff --git a/vendor/hachidori/extension/assets/preview-background-6.webp b/vendor/hachidori/extension/assets/preview-background-6.webp new file mode 100644 index 0000000000000000000000000000000000000000..238721da8c93af15da53b9c6c3bd211bb4c2b186 GIT binary patch literal 154272 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 literal 0 HcmV?d00001 diff --git a/vendor/hachidori/extension/assets/preview-background.webp b/vendor/hachidori/extension/assets/preview-background.webp new file mode 100644 index 0000000000000000000000000000000000000000..1d3e1c0ea41516e7136c047db2e53780c8ab8de5 GIT binary patch literal 147244 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 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 new file mode 100644 index 00000000..1ecdcd3a --- /dev/null +++ b/vendor/hachidori/extension/audio-content.js @@ -0,0 +1,327 @@ +// 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 new file mode 100644 index 00000000..9fb9b380 --- /dev/null +++ b/vendor/hachidori/extension/audio-offscreen.js @@ -0,0 +1,114 @@ +// 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 new file mode 100644 index 00000000..23897d8f --- /dev/null +++ b/vendor/hachidori/extension/audio-player.js @@ -0,0 +1,131 @@ +// 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 new file mode 100644 index 00000000..5cf65bfc --- /dev/null +++ b/vendor/hachidori/extension/audio-repository.js @@ -0,0 +1,71 @@ +// 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 new file mode 100644 index 00000000..c9b1d0ed --- /dev/null +++ b/vendor/hachidori/extension/audio-settings.js @@ -0,0 +1,177 @@ +// 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 new file mode 100644 index 00000000..e824296d --- /dev/null +++ b/vendor/hachidori/extension/audio-sources.js @@ -0,0 +1,40 @@ +// 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 new file mode 100644 index 00000000..f5c6d7f4 --- /dev/null +++ b/vendor/hachidori/extension/avif-sequence.js @@ -0,0 +1,135 @@ +// 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 new file mode 100644 index 00000000..3b9f136b --- /dev/null +++ b/vendor/hachidori/extension/background.js @@ -0,0 +1,3358 @@ +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 new file mode 100644 index 00000000..931f20b9 --- /dev/null +++ b/vendor/hachidori/extension/backup-archive.js @@ -0,0 +1,116 @@ +// 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 new file mode 100644 index 00000000..e524cf91 --- /dev/null +++ b/vendor/hachidori/extension/backup-automatic.js @@ -0,0 +1,130 @@ +// 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 new file mode 100644 index 00000000..ee85d76b --- /dev/null +++ b/vendor/hachidori/extension/backup-downloads.js @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 00000000..5d458acc --- /dev/null +++ b/vendor/hachidori/extension/backup-settings.js @@ -0,0 +1,277 @@ +// 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 new file mode 100644 index 00000000..5ba73c95 --- /dev/null +++ b/vendor/hachidori/extension/backup-state.js @@ -0,0 +1,115 @@ +// 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 new file mode 100644 index 00000000..1b99fa76 --- /dev/null +++ b/vendor/hachidori/extension/base64.js @@ -0,0 +1,39 @@ +// 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 new file mode 100644 index 00000000..4035f28b --- /dev/null +++ b/vendor/hachidori/extension/blob-download.js @@ -0,0 +1,15 @@ +// 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 new file mode 100644 index 00000000..ae33eaeb --- /dev/null +++ b/vendor/hachidori/extension/browser-api.js @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000..cd14ccc3 --- /dev/null +++ b/vendor/hachidori/extension/capture-audio-worklet.js @@ -0,0 +1,53 @@ +// 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 new file mode 100644 index 00000000..3a820cc7 --- /dev/null +++ b/vendor/hachidori/extension/capture-buffer.js @@ -0,0 +1,225 @@ +// 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 new file mode 100644 index 00000000..af5909e2 --- /dev/null +++ b/vendor/hachidori/extension/capture-content.js @@ -0,0 +1,721 @@ +// 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 new file mode 100644 index 00000000..15499483 --- /dev/null +++ b/vendor/hachidori/extension/capture-encoder-client.js @@ -0,0 +1,63 @@ +// 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 new file mode 100644 index 00000000..2e3914b3 --- /dev/null +++ b/vendor/hachidori/extension/capture-encoder-worker.js @@ -0,0 +1,39 @@ +// 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 new file mode 100644 index 00000000..fdd05049 --- /dev/null +++ b/vendor/hachidori/extension/capture-frame-client.js @@ -0,0 +1,48 @@ +// 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 new file mode 100644 index 00000000..64b40951 --- /dev/null +++ b/vendor/hachidori/extension/capture-frame-worker.js @@ -0,0 +1,43 @@ +// 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 new file mode 100644 index 00000000..08ad4a6c --- /dev/null +++ b/vendor/hachidori/extension/capture-host.js @@ -0,0 +1,683 @@ +// 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 new file mode 100644 index 00000000..7c6de0e3 --- /dev/null +++ b/vendor/hachidori/extension/capture-session.js @@ -0,0 +1,559 @@ +// 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 new file mode 100644 index 00000000..8bf9c7ed --- /dev/null +++ b/vendor/hachidori/extension/capture-speech.js @@ -0,0 +1,80 @@ +// 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 new file mode 100644 index 00000000..4721c8ad --- /dev/null +++ b/vendor/hachidori/extension/capture-timeline.js @@ -0,0 +1,192 @@ +// 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 new file mode 100644 index 00000000..2c057b64 --- /dev/null +++ b/vendor/hachidori/extension/capture.css @@ -0,0 +1,51 @@ +/* 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 new file mode 100644 index 00000000..d1618d22 --- /dev/null +++ b/vendor/hachidori/extension/capture.html @@ -0,0 +1,75 @@ + + + + + + + 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 new file mode 100644 index 00000000..35d2f912 --- /dev/null +++ b/vendor/hachidori/extension/capture.js @@ -0,0 +1,145 @@ +// 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 new file mode 100644 index 00000000..315c98c3 --- /dev/null +++ b/vendor/hachidori/extension/chrome-offscreen.js @@ -0,0 +1,44 @@ +// 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 new file mode 100644 index 00000000..770793e6 --- /dev/null +++ b/vendor/hachidori/extension/content.css @@ -0,0 +1,26 @@ +/* + * 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 new file mode 100644 index 00000000..a1b1118d --- /dev/null +++ b/vendor/hachidori/extension/content.js @@ -0,0 +1,4063 @@ +/* + * 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 new file mode 100644 index 0000000000000000000000000000000000000000..07e9a23861222dbe0a4701aae0e7bd60e77748a3 GIT binary patch literal 878 zcma)4%T60X5G){$0#cB2$tjUmmN-O$06rv0AxOl=k$4?q9^yFhj_r}WyJmL`?3Zvs zoZy1E=OgkTz&~)Iy4SFR6SB(F)76jao(0B_meGJi8=n6OVi=^z5#Tp0Z7$qhzX!W}RsQ&!Zp$>-#Y*@RzEPL#`pnKG$PE$kY$*x8}4 zQ4OsnHKfPm?j%mS@6hWRS!mkhUIH}flSzimu^LsVNVr+qXIJ&_GPj92ENMhtV`cxv z+cZXRA$@ApE0|+-f%L6&`iOdrzNbW$Gnbs#P9?KRJstL_@zor4^k_fNE&3X6HBtR- zX1siWG~01$=7Z37T24*h>rw z?V*TdRD`sGz!fD0hDB0u`LsffBHBY>_E2y3o!`Ayte5WCJ@@zfzVn^$oI|9`w2_D6 z6rn-9W$L3aDU>EN?IQ^%38yeHiB6FV_Z_&4g2~7cfhM%PcCwIztYkwp(;;93Gz3f$ zx&NEzg$IRtMNL)=B0Gipta$+6X>tQg;46TCAHL$kc@ih)cO##<0o*FU`n4>^j)IdA zD7%ROPXkwbQ{AsR=MEDBQAF2_c90*H<8)dmc!U;faE;a3F^@HZxE%yj^>i3#FYO{b zwE*)1E4YQsaPe_1xG#LJFvevoW%agRqKW^Us-ezK}O}y?C{bmT<{jsVh#alN%*N1Ewr1Q zWS}}Cx)p0*M&L)M23n)VkQQB9^wrMNGLy=Mx~Z0i!oA76AI_f1`kbyC2aAgrpBJOa zg^`u7lk1FQPuudxs#eB7Ifka+oOBlQWcfIMc|P-Qi)n6&74mayx2H;xP$uKaHf42L zz1dLH>C;c>x4p8|miL~pHlCjmBqneCQmrtVMy}tP*i-)g#Apdn12WNdo(!qpu+Uxw>FfpZsrZocwuK=~Y+9iOo_AAcWQZ@Rf(c5O=9 x$4r5xt{=mrv3$=1e>#$@fBR_a*`*gX-iGN7vcEIe`f#{=Yw_{u?Zs*q{{>;-)++!2 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..fb5320cfe0b719a195da949573387a4aeadd61cd GIT binary patch literal 1149 zcma)4T}V@57=AbLq}wS)GtR(hlNnP>=WZ%+wwklGHl1!LMv?ooR{66Z!v(>XvdkYE zWf=uFAp^-EiUdU{6UybTly1C9Ld&`eQOGNMzwgX4yXlN??|I+neV^y~zV84CgC$@D zH`u`oHe3NX2W9x?$0dam;DRPx1oLBb!g1`-VeLH55Tu5pKFpdiS3?e{pb(T8rBIES z2U-#H(%Ju=D@QIbG*V5%s7LKaxJ)@uAyWXfi23l%f~yJN+}PoUE0~e~3Y;hU7OZ-) z-U9p-!RoNbM^WO&fp`FMes2W-V?LS=WtOBv-N97KaS*gONGZpGY> zoxCSK<|aI8DU@Tq4Utxyb75}6e<#*#TU<5M3OQw>O4zito7@7g!ru|ktwPsi9fV@K z`2J=*vle7^;LP7U*P>4z+m0B~O9S?+@b28Ge&L_l&2Phk9ApO$oY;O}#pvY_bxm#{ zK+nK}TrT|aiaAgOrLYr308r<4`Z174$OlMH#GXX7C1U3;x!@XUs+>!+w#_^0A*q2G z<-%o*I&2RM!=gw^EgL!e)*OpWMMS$RnUyZd!kbLPjbrZp%m5e7SBJUicU9OJEmTKO z2H0=rmxfToP%)>vZ+qZM8`?{WiNNS-F%<{~x3DvZ(>ra`&S{r^$A>*o7t>@VvjI(M zcE>wd>r+})i^|7B*PYOWJ*uPxgh)J+@`O5VpWqm^w8y4*DICZlcp>IixLL_Uv?M&( zH`pQTos3MfiidGC+rhGQ45!H>;KT1jNl)a@x5ZaP&L}>x+%`WmQ&goenBuWvtD;bE zdbZ5|@O@pn{n=h$zp%F2k}90-k;W$(`I7FI|9bA3Y;FD*`Nq$UPXnQ~eRHpu#;ZIU z4^vrtw7<0?)pT_%FKb*koG1Dyime}6*VSkWszvEY((x>-A-2&?K7s7@O>i_@% literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..61477461011ff6da60ada67d95045ec181cbb3b2 GIT binary patch literal 1724 zcma)4O^6&-5Uz1yHwmud4?#ij(T89pGo2aGgxxpox_^mgvj#U2yd=}p{d(q+p6=d$ zJ)0Rd=okytNOmG z`l?<7j5!?UL4pSveq)Hhg%rBL;0Orx1bQxF0(_Xo_yWcn$Ry+yjVhB@1q&)rf`e!o zyo}f?v=K{n?*GkOL=7plbWf^y6nnQI)HRP`r7p}Nrtq|aZ-ghQm9L>k{AJ9i{wo+w z5lbM?BJ5SY-ou$z-uWL~a>e1gF@s7etWG>{?;eMTV~}(^|U7ApK4J3amp@p41g7 zKH7P4q%$$|iDRrtog12e3iB7C2#0m8IjxfJI{y#mjTWk0!QIeXgtC6A;v88r{2o_*_!VE@y{xBc25Z+v~#7^a(eVuIhWw`0WH90-)q8zwJ(+H^2#snzLffuRVCgG%h@rw(A3W^4F&y_twAZZEW=mY~K28>(9;Wz4iC0v3c&@-@!ay zxM}c?v7xbF8++NAR7W>7xd(W{ywFvu zYPpsZ$W&zv7s2hs8HJSa;>aIhW(ohyAI`qxb*CPOKDX-*S0?pPq-(Yg8U zC7xzZZ+)w`@h-RVh#vyDO&puZwn-d_!-JhjFBb>3axIIQaI;#a#-5U1Q~GYSAGk`~ lh-qC_E0>!Je>#Yxtf=H`VyaxB$p`<+t?iF+49syz^}oi;f4Tqw literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..eee40aee61e1c060b021f5bb49055862aaa77c0a GIT binary patch literal 1053 zcma)4T}V@57=GK zUE7Bc}z;S-d0EOaapz&Al2BoE6nd)g74%>P3RUaIeFh$8wbeR$D5~+rCBG)8h1n@%A z$YTKe^;|Qqf(d4&9|9o za#*&{vh9|Q)p@DRP7iNyoh~dVE|5LxT%4P_J|1>D7b|lsD`98Ld*ef~t2lA(=That ze0f?g4eqqp@34Kj_oPX9%vWD-{>psA5OT|8(C2yTc<1jp`Q~lB-^AI^^V|8{j5Cv3 zxYIT{Q-1YGZ;)%C{B1JL-;|rIbJqqNXSv}suDylj$m>$0;cIZTf9zCL*R!#2vC<9K z*sh!3J+nWG^Y^zGZ!N?+R<|jikG_~XXJ4xrkALl7TI;pf?LI#^dpFoOv4wU1015fs AZ2$lO literal 0 HcmV?d00001 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 new file mode 100644 index 00000000..cce6e891 --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/gen_fixture.py @@ -0,0 +1,128 @@ +#!/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 new file mode 100644 index 00000000..2c101cb8 --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/fixtures/yomitan/golden.sha256 @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..6117eb9cebd4d1e08d24f1908983fdfabb25f36f GIT binary patch literal 3164 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 + +#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 new file mode 100644 index 00000000..4485a86f --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/import_equivalence_test.cpp @@ -0,0 +1,237 @@ +// 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 new file mode 100644 index 00000000..51fcc2b8 --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/json_skip_test.cpp @@ -0,0 +1,83 @@ +// 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 new file mode 100644 index 00000000..c08bf715 --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/long_key_scan_test.cpp @@ -0,0 +1,243 @@ +// 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 new file mode 100644 index 00000000..620f886f --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/mdict_reader_test.cpp @@ -0,0 +1,300 @@ +// 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 new file mode 100644 index 00000000..3cb79b26 --- /dev/null +++ b/vendor/hachidori/third_party/hoshidicts/tests/mdict_test.cpp @@ -0,0 +1,237 @@ +// 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

    Y|) 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