PageSourceSearch

https://yomureader.com/yomu.user.js?v=2.0.7

js yomureader.com collected 2026-10-02 01:51:13 UTC 1,992,434 bytes, 48,458 lines download raw bytes

1// ==UserScript==
2// @name よむ
3// @namespace https://github.com/HRussellZFAC023/yomu-reader
4// @version 2.0.7
5// @author Henry Russell
6// @description Popup lookup and Study tools for 33 learning languages, with subtitles and OCR; Japanese adds furigana and pitch.
7// @license MIT
8// @icon https://yomureader.com/favicon-32x32.png
9// @homepage https://yomureader.com/
10// @downloadURL https://update.greasyfork.org/scripts/581653/%E3%82%88%E3%82%80.user.js
11// @updateURL https://update.greasyfork.org/scripts/581653/%E3%82%88%E3%82%80.meta.js
12// @match *://*/*
13// @match file:///*
14// @require https://yomureader.com/greasyfork/yomu-runtime.5a98b24d144d.user.js#sha256=WpiyTRRNVPqc35PrnbnEa2pZppxJQJf4/TFISDEgZO4=
15// @resource yomuCss  https://yomureader.com/yomu.6f1aad3cd06d.css#sha256=bxqtPNBt3m8QPMm3qqJrYtonmic+chuX3XhyASh/7MA=
16// @connect api.jiten.moe
17// @connect api.tatoeba.org
18// @connect tatoeba.org
19// @connect jpdb.io
20// @connect api.wanikani.com
21// @connect lens.google.com
22// @connect lensfrontend-pa.googleapis.com
23// @connect www.google.com
24// @connect yomureader.com
25// @connect bookwalker.jp
26// @connect viewer.bookwalker.jp
27// @connect c.bookwalker.jp
28// @connect bw-bv-epubs.bookwalker.jp
29// @connect *
30// @grant GM.deleteValue
31// @grant GM.getValue
32// @grant GM.listValues
33// @grant GM.openInTab
34// @grant GM.registerMenuCommand
35// @grant GM.setValue
36// @grant GM.xmlHttpRequest
37// @grant GM_addValueChangeListener
38// @grant GM_deleteValue
39// @grant GM_getResourceText
40// @grant GM_getValue
41// @grant GM_listValues
42// @grant GM_openInTab
43// @grant GM_registerMenuCommand
44// @grant GM_removeValueChangeListener
45// @grant GM_setValue
46// @grant GM_xmlhttpRequest
47// @inject-into content
48// @run-at document-start
49// ==/UserScript==
50(function () {
51'use strict';
52// yomu-generated-indent: compact
53
54const MANAGED_STORAGE_KEY_PREFIXES = [
55"yomu-",
56"yomu:",
57"yomu.",
58"__yomu",
59"jpdb-reader-",
60"jpdb-popup-reader-"
61];
62const MANAGED_STATE_SLOT_KEY_PREFIX = "yomu:state-slot:v1:";
63const MANAGED_WEB_STORAGE_SLOT_KEY_PREFIX = "yomu:web-storage-slot:v1:";
64const MANAGED_SLOT_KEY_PREFIXES = [
65MANAGED_STATE_SLOT_KEY_PREFIX,
66MANAGED_WEB_STORAGE_SLOT_KEY_PREFIX
67];
68function isManagedStorageKey(key) {
69return MANAGED_STORAGE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
70}
71function isPrivateManagedStorageKey(key) {
72return logicalManagedStorageKey(key)?.startsWith("yomu:private:") === true;
73}
74function isBridgeManagedStorageKey(key) {
75const logicalKey = logicalManagedStorageKey(key);
76return logicalKey !== null && isManagedStorageKey(logicalKey) && !isPrivateManagedStorageKey(logicalKey);
77}
78function logicalManagedStorageKey(key) {
79const prefix = MANAGED_SLOT_KEY_PREFIXES.find((candidate) => key.startsWith(candidate));
80if (!prefix) return key;
81const encoded = key.slice(prefix.length);
82const separator = encoded.indexOf(":");
83if (separator < 1 || separator === encoded.length - 1) return null;
84try {
85const logicalKey = decodeURIComponent(encoded.slice(separator + 1));
86return logicalKey && !isManagedStorageSlotKey(logicalKey) && isManagedStorageKey(logicalKey) ? logicalKey : null;
87} catch {
88return null;
89}
90}
91function isManagedStorageSlotKey(key) {
92return MANAGED_SLOT_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
93}
94function delay(ms) {
95return new Promise((resolve) => window.setTimeout(resolve, ms));
96}
97function isPromiseLike$3(value) {
98return Boolean(value && typeof value.then === "function");
99}
100function promiseWithTimeout(promise, timeoutMs, message) {
101let timeoutId = 0;
102const timeout = new Promise((_resolve, reject) => {
103timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
104});
105return Promise.race([
106promise,
107timeout
108]).finally(() => window.clearTimeout(timeoutId));
109}
110async function runLimited(items, concurrency, worker) {
111if (!items.length) return;
112const workerCount = Math.max(1, Math.min(items.length, Math.floor(concurrency) || 1));
113let nextIndex = 0;
114await Promise.all(Array.from({ length: workerCount }, async () => {
115while (nextIndex < items.length) {
116const index = nextIndex++;
117await worker(items[index], index);
118}
119}));
120}
121async function mapLimited(items, concurrency, worker) {
122const results = new Array(items.length);
123await runLimited(items, concurrency, async (item, index) => {
124results[index] = await worker(item, index);
125});
126return results;
127}
128class ConcurrencyGate {
129constructor(limit) {
130this.limit = limit;
131}
132active = 0;
133queue = [];
134async run(task) {
135if (this.active >= this.limit) {
136await new Promise((resolve) => this.queue.push(resolve));
137}
138this.active += 1;
139try {
140return await task();
141} finally {
142this.active -= 1;
143this.queue.shift()?.();
144}
145}
146}
147const FURIGANA_HIDE_STATE_GROUPS = ["known", "due", "failed", "learning", "new"];
148const WORD_COLOR_HIDE_STATE_GROUPS = [...FURIGANA_HIDE_STATE_GROUPS, "ignored"];
149const APP_NAME = "よむ";
150const APP_PUCK = "よむ";
151const ACADEMY_SRS_LABEL = "Academy";
152const APP_SLUG = "yomu";
153const APP_REPOSITORY_NAME = `${APP_SLUG}-reader`;
154const GITHUB_OWNER = "HRussellZFAC023";
155const GITHUB_PAGES_ORIGIN = `https://${GITHUB_OWNER.toLowerCase()}.github.io`;
156const DOCS_ORIGIN = "https://yomureader.com";
157const DOCS_BASE_URL = `${DOCS_ORIGIN}/`;
158const NEW_TAB_PAGE_URL = `${DOCS_BASE_URL}study/`;
159const VIDEO_PLAYER_PAGE_URL = `${DOCS_BASE_URL}video-player/`;
160const SUPPORT_COPY = "よむ is a free userscript for popup lookup, dictionaries, OCR, subtitles, study, and Anki.";
161const SUPPORT_COPY_EXTRA = "Donations are optional and help cover development, devices, services, maintenance, and API costs.";
162const USERSCRIPT_HTTP_BRIDGE_READY_EVENT = "yomu-userscript-http-bridge-ready";
163const USERSCRIPT_STORAGE_BRIDGE_READY_EVENT = "yomu-userscript-storage-bridge-ready";
164const INTERFACE_LANGUAGE_CHANGE_EVENT = "yomu-interface-language-change";
165const OPEN_SETTINGS_EVENT = "yomu-open-settings";
166const SETTINGS_CHANGE_EVENT = "yomu-settings-change";
167const JPDB_DEFINITION_SOURCE_ID = "__jpdb__";
168const JITEN_DEFINITION_SOURCE_ID = "__jiten__";
169const BUNPRO_DEFINITION_SOURCE_ID = "__bunpro__";
170const WANIKANI_DEFINITION_SOURCE_ID = "__wanikani__";
171const ANKI_SOURCE_ID = "__anki__";
172const STUDY_TRANSLATION_SOURCE_ID = "__study_translation__";
173const STUDY_GRAMMAR_SOURCE_ID = "__study_grammar__";
174const IMMERSION_KIT_SOURCE_ID = "__immersion_kit__";
175const DOCS_PREVIEW_HOST = "yomureader.localhost";
176const WEB_PROTOCOLS = new Set(["http:", "https:"]);
177const EXTENSION_PROTOCOLS = new Set(["chrome-extension:", "moz-extension:", "safari-web-extension:"]);
178const TRUSTED_HTTPS_ORIGIN_KINDS = new Map([
179[DOCS_ORIGIN, "docs"],
180[GITHUB_PAGES_ORIGIN, "github-pages"]
181]);
182const TRUSTED_WEB_HOST_KINDS = new Map([
183[DOCS_PREVIEW_HOST, "docs-preview"],
184["127.0.0.1", "loopback"],
185["localhost", "loopback"],
186["[::1]", "loopback"]
187]);
188const PRIVILEGED_LOCAL_DEVELOPMENT_ORIGINS = new Set([]);
189function isPrivilegedYomuLocalDevelopmentOrigin(origin) {
190return PRIVILEGED_LOCAL_DEVELOPMENT_ORIGINS.has(origin);
191}
192function readTrustedYomuUrl(value) {
193let url;
194try {
195url = new URL(value);
196} catch {
197return null;
198}
199if (url.username || url.password) return null;
200const path = normalizeYomuHostedPath(url.pathname);
201const originKind = trustedYomuOriginKind(url, path);
202return originKind ? { url, path, originKind } : null;
203}
204function normalizeYomuHostedPath(pathname) {
205const normalized = pathname.replace(/\/index\.html$/u, "/");
206return normalized.endsWith("/") ? normalized : `${normalized}/`;
207}
208function isYomuRepositoryPath(path) {
209return path === `/${APP_REPOSITORY_NAME}/` || path.startsWith(`/${APP_REPOSITORY_NAME}/`);
210}
211function trustedYomuOriginKind(url, path) {
212return trustedHttpsOriginKind(url, path) ?? trustedWebHostKind(url) ?? trustedExtensionOriginKind(url);
213}
214function trustedHttpsOriginKind(url, path) {
215const originKind = TRUSTED_HTTPS_ORIGIN_KINDS.get(url.origin);
216if (originKind !== "github-pages") return originKind ?? null;
217return isYomuRepositoryPath(path) ? originKind : null;
218}
219function trustedWebHostKind(url) {
220if (!WEB_PROTOCOLS.has(url.protocol)) return null;
221return TRUSTED_WEB_HOST_KINDS.get(url.hostname) ?? null;
222}
223function trustedExtensionOriginKind(url) {
224if (!EXTENSION_PROTOCOLS.has(url.protocol)) return null;
225return url.hostname ? "extension" : null;
226}
227const SETTINGS_PANEL_IDS = [
228"appearance",
229"backup",
230"api",
231"dictionaries",
232"media",
233"mining",
234"newTab",
235"shortcuts",
236"help"
237];
238const SETTINGS_PANEL_ID_SET = new Set(SETTINGS_PANEL_IDS);
239const STUDY_ROUTE_POLICIES = {
240docs: isYomuStudyRoutePath,
241"docs-preview": isYomuStudyRoutePath,
242extension: isYomuStudyRoutePath,
243"github-pages": isRepositoryStudyRoutePath,
244loopback: isLoopbackStudyRoutePath
245};
246function isYomuNewTabUrl(value) {
247const appUrl = readTrustedYomuUrl(value);
248return appUrl ? isTrustedStudyRoute(appUrl) : false;
249}
250function settingsPanelHash(panel) {
251return `#settings=${isSettingsPanelId(panel) ? panel : "appearance"}`;
252}
253function settingsPanelFromHash(hash) {
254const prefix = "#settings=";
255if (!hash.startsWith(prefix)) return null;
256const panel = hash.slice(prefix.length);
257return isSettingsPanelId(panel) ? panel : null;
258}
259function isSettingsPanelId(value) {
260return typeof value === "string" && SETTINGS_PANEL_ID_SET.has(value);
261}
262function isYomuStudyRoutePath(pathname) {
263const path = normalizeYomuHostedPath(pathname);
264return path === "/study/" || path === "/newtab/";
265}
266function isTrustedStudyRoute(appUrl) {
267const { originKind, path } = appUrl;
268return STUDY_ROUTE_POLICIES[originKind](path);
269}
270function isLoopbackStudyRoutePath(path) {
271return isYomuStudyRoutePath(path) || isRepositoryStudyRoutePath(path);
272}
273function isRepositoryStudyRoutePath(path) {
274return path === `/${APP_REPOSITORY_NAME}/study/` || path === `/${APP_REPOSITORY_NAME}/newtab/`;
275}
276const REPOSITORY_APP_POLICIES = {
277docs: () => true,
278"docs-preview": () => true,
279"github-pages": (appUrl) => isYomuRepositoryPath(appUrl.path),
280extension: (appUrl) => isYomuNewTabUrl(appUrl.url.href),
281loopback: (appUrl) => isYomuLocalAppPath(appUrl.path)
282};
283const PRIVILEGED_APP_POLICIES = {
284docs: isYomuActiveAppRoute,
285"github-pages": isYomuActiveAppRoute,
286extension: () => false,
287loopback: isPrivilegedLocalAppRoute,
288"docs-preview": isPrivilegedLocalAppRoute
289};
290function isYomuHostedAppUrl(value) {
291const appUrl = readTrustedYomuUrl(value);
292return appUrl ? isYomuHostedAppRoute(value, appUrl) : false;
293}
294function isYomuPrivilegedHostedAppUrl(value) {
295const appUrl = readTrustedYomuUrl(value);
296return appUrl ? PRIVILEGED_APP_POLICIES[appUrl.originKind](value, appUrl) : false;
297}
298function isYomuStorageBridgeHostedUrl(value) {
299const appUrl = readTrustedYomuUrl(value);
300if (!appUrl) return false;
301if (appUrl.originKind === "docs" || appUrl.originKind === "github-pages") return true;
302return isYomuPrivilegedHostedAppUrl(value);
303}
304function isYomuHostedPassivePage(value) {
305const appUrl = readTrustedYomuUrl(value);
306return appUrl ? isPassiveYomuRepositoryPage(value, appUrl) : false;
307}
308function isYomuHostedVideoPlayerPage(value) {
309const appUrl = readTrustedYomuUrl(value);
310return appUrl ? isYomuRepositoryAppUrl(appUrl) && isExactHostedAppPath(appUrl, "video-player") : false;
311}
312function isYomuHostedPdfReaderPage(value) {
313const appUrl = readTrustedYomuUrl(value);
314return appUrl ? isYomuRepositoryAppUrl(appUrl) && isExactHostedAppPath(appUrl, "pdf-reader") : false;
315}
316function isYomuHostedAcademyPage(value) {
317const appUrl = readTrustedYomuUrl(value);
318return appUrl ? isYomuRepositoryAppUrl(appUrl) && isExactHostedAppPath(appUrl, "academy") : false;
319}
320function isYomuHostedAppRoute(value, appUrl) {
321return isYomuActiveAppRoute(value, appUrl) || isYomuRepositoryAppUrl(appUrl);
322}
323function isPassiveYomuRepositoryPage(value, appUrl) {
324return isYomuRepositoryAppUrl(appUrl) && !isYomuActiveAppRoute(value, appUrl);
325}
326function isYomuActiveAppRoute(value, appUrl) {
327return isYomuNewTabUrl(value) || isExactHostedAppPath(appUrl, "video-player") || isExactHostedAppPath(appUrl, "pdf-reader") || isExactHostedAppPath(appUrl, "academy");
328}
329function isPrivilegedLocalAppRoute(value, appUrl) {
330return isPrivilegedYomuLocalDevelopmentOrigin(appUrl.url.origin) && isYomuActiveAppRoute(value, appUrl);
331}
332function isYomuRepositoryAppUrl(appUrl) {
333return REPOSITORY_APP_POLICIES[appUrl.originKind](appUrl);
334}
335function isExactHostedAppPath(appUrl, route) {
336if (appUrl.originKind === "github-pages") {
337return appUrl.path === `/${APP_REPOSITORY_NAME}/${route}/`;
338}
339return appUrl.path === `/${route}/` || appUrl.originKind === "loopback" && appUrl.path === `/${APP_REPOSITORY_NAME}/${route}/`;
340}
341function isYomuLocalAppPath(path) {
342return path === "/" || isYomuRepositoryPath(path) || path === "/study/" || path === "/newtab/" || path === "/video-player/" || path === "/pdf-reader/" || path === "/academy/";
343}
344const INSTALLED_READER_RUNTIME_MARKER_ID = "jpdb-reader-installed-runtime";
345function detectInstalledReaderRuntime(globals = globalThis) {
346if (globals.chrome?.runtime?.id || globals.browser?.runtime?.id) return "extension";
347if (globals === globalThis && typeof GM_getValue === "function" || typeof globals.GM_getValue === "function" || typeof globals.GM?.getValue === "function" || typeof globals.GM?.xmlHttpRequest === "function" || typeof globals.GM?.xmlhttpRequest === "function" || Boolean(globals.GM_info)) {
348return "userscript";
349}
350return null;
351}
352function announceInstalledReaderRuntime(globals = globalThis, root = document) {
353const kind = detectInstalledReaderRuntime(globals);
354if (!kind) return null;
355markInstalledReaderRuntime(kind, root);
356return kind;
357}
358function markInstalledReaderRuntime(kind, root = document) {
359const existing = root.getElementById(INSTALLED_READER_RUNTIME_MARKER_ID);
360const marker2 = existing instanceof HTMLElement ? existing : root.createElement("meta");
361marker2.id = INSTALLED_READER_RUNTIME_MARKER_ID;
362if (marker2.dataset.yomuInstalledRuntimeKind !== "extension") marker2.dataset.yomuInstalledRuntimeKind = kind;
363if (!marker2.isConnected) appendInstalledRuntimeMarker(marker2, root);
364}
365function announcedInstalledReaderRuntime(root = document) {
366const kind = root.getElementById(INSTALLED_READER_RUNTIME_MARKER_ID)?.dataset?.yomuInstalledRuntimeKind;
367return kind === "extension" || kind === "userscript" ? kind : null;
368}
369function isHostedReaderRuntime() {
370return document.documentElement?.dataset.yomuHosted !== void 0;
371}
372function shouldInstallHostedReaderRuntime(forceLocalRuntime = false, root = document) {
373return forceLocalRuntime || !root.getElementById(INSTALLED_READER_RUNTIME_MARKER_ID);
374}
375function appendInstalledRuntimeMarker(marker2, root) {
376const parent = root.head || root.documentElement;
377if (parent) {
378parent.append(marker2);
379return;
380}
381const observer = new MutationObserver(() => {
382const readyParent = root.head || root.documentElement;
383if (!readyParent) return;
384readyParent.append(marker2);
385observer.disconnect();
386});
387observer.observe(root, { childList: true, subtree: true });
388}
389function createBridgeOwnerId(prefix) {
390return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
391}
392function mayClaimBridge(dataset, keys, kind, ownOwnerId) {
393if (kind === "userscript" && announcedInstalledReaderRuntime() === "extension") return false;
394const existing = dataset[keys.owner];
395if (!existing || existing === ownOwnerId) return true;
396return kind === "extension" && dataset[keys.kind] !== "extension";
397}
398function expectedBridgeKind(trustedPage) {
399if (detectInstalledReaderRuntime()) return null;
400const announced = announcedInstalledReaderRuntime();
401return announced && trustedPage() ? announced : null;
402}
403function readyBridgeOwner(dataset, keys, expected) {
404if (dataset?.[keys.ready] !== "true") return null;
405const kind = dataset[keys.kind];
406if (kind === "extension" || kind === "userscript") {
407if (expected && kind !== expected && kind !== "extension") return null;
408return { ownerId: dataset[keys.owner], kind };
409}
410return { ownerId: dataset[keys.owner], kind: expected ?? "userscript" };
411}
412function bridgeRequestAddressedTo(dataset, keys, ownerId, requestOwnerId) {
413if (dataset?.[keys.owner] !== ownerId) return false;
414return requestOwnerId === void 0 || requestOwnerId === ownerId;
415}
416function bridgeEventId(event) {
417return safeReadString(normalizedBridgeEventDetail(event), "id");
418}
419function bridgeEventOwnerId(event) {
420return safeReadString(normalizedBridgeEventDetail(event), "ownerId");
421}
422function bridgeRequestDetail(event) {
423const detail = normalizedBridgeEventDetail(event);
424const id = bridgeEventId(event);
425const options = safeReadProperty(detail, "options");
426return id && options ? { id, options } : void 0;
427}
428function bridgeResponseEventDetail(event) {
429const detail = normalizedBridgeEventDetail(event);
430const id = safeReadString(detail, "id");
431const kind = safeReadString(detail, "kind");
432if (!id || kind !== "load" && kind !== "error" && kind !== "timeout") return void 0;
433return {
434id,
435kind,
436response: safeReadProperty(detail, "response"),
437message: safeReadString(detail, "message")
438};
439}
440function bridgeResponseDetail(id, kind, response, message) {
441return {
442id,
443kind,
444response: response ? bridgeResponse(response) : void 0,
445message
446};
447}
448function bridgeRequestOptions(options) {
449return {
450...options,
451headers: options.headers ? { ...options.headers } : void 0,
452data: bridgeRequestBody(options.data)
453};
454}
455function bridgeEventDetail(detail) {
456if (detail === void 0) return void 0;
457const json = bridgeEventJsonDetail(detail);
458return json ?? detail;
459}
460function bridgeResponse(response) {
461return {
462status: safeReadNumber(response, "status") ?? 0,
463response: bridgeBody(safeReadProperty(response, "response")),
464responseText: safeReadString(response, "responseText"),
465finalUrl: safeReadString(response, "finalUrl")
466};
467}
468function bridgeBody(value) {
469if (value instanceof ArrayBuffer) return value.slice(0);
470if (ArrayBuffer.isView(value)) {
471const bytes = value;
472return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
473}
474if (value instanceof Blob) return value.slice(0, value.size, value.type);
475return value;
476}
477function bridgeRequestBody(value) {
478return bridgeBody(value);
479}
480function bridgeEventJsonDetail(detail) {
481let unsupported = false;
482try {
483const json = JSON.stringify(detail, (_key, value) => {
484if (isUnsupportedBridgeJsonValue(value)) {
485unsupported = true;
486return void 0;
487}
488return value;
489});
490return unsupported || typeof json !== "string" ? void 0 : json;
491} catch {
492return void 0;
493}
494}
495function normalizedBridgeEventDetail(event) {
496const detail = safeEventDetail(event);
497if (typeof detail !== "string") return detail;
498try {
499return JSON.parse(detail);
500} catch {
501return detail;
502}
503}
504function isUnsupportedBridgeJsonValue(value) {
505return isUnsupportedPrimitiveBridgeJsonValue(value) || isArrayBufferBridgeJsonValue(value) || isBlobBridgeJsonValue(value) || isFormDataBridgeJsonValue(value);
506}
507function isUnsupportedPrimitiveBridgeJsonValue(value) {
508return typeof value === "function" || typeof value === "symbol";
509}
510function isArrayBufferBridgeJsonValue(value) {
511if (typeof ArrayBuffer === "undefined") return false;
512return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
513}
514function isBlobBridgeJsonValue(value) {
515return typeof Blob !== "undefined" && value instanceof Blob;
516}
517function isFormDataBridgeJsonValue(value) {
518return typeof FormData !== "undefined" && value instanceof FormData;
519}
520function safeEventDetail(event) {
521try {
522return event.detail;
523} catch {
524return void 0;
525}
526}
527function safeReadProperty(source, key) {
528if (!source || typeof source !== "object" && typeof source !== "function") return void 0;
529try {
530return source[key];
531} catch {
532return void 0;
533}
534}
535function safeReadString(source, key) {
536const value = safeReadProperty(source, key);
537return typeof value === "string" ? value : void 0;
538}
539function safeReadNumber(source, key) {
540const value = safeReadProperty(source, key);
541return typeof value === "number" ? value : void 0;
542}
543let recorder = () => void 0;
544function setAttemptRecorder(next) {
545recorder = next;
546}
547function record(label, error) {
548recorder(label, error);
549}
550function attempt(fn, fallback, label) {
551try {
552return fn();
553} catch (error) {
554record(label, error);
555return fallback;
556}
557}
558function attemptVoid(fn, label) {
559try {
560fn();
561} catch (error) {
562record(label, error);
563}
564}
565function parseJson(text2, fallback, label = "parseJson") {
566if (text2 === null || text2 === void 0 || text2 === "") return fallback;
567try {
568return JSON.parse(text2);
569} catch (error) {
570record(label, error);
571return fallback;
572}
573}
574let initialWindowDispatchEvent = initialWindowMethod("dispatchEvent");
575let initialWindowAddEventListener = initialWindowMethod("addEventListener");
576let initialWindowRemoveEventListener = initialWindowMethod("removeEventListener");
577function createWindowCustomEvent(type, detail, init = {}) {
578const eventInit = { ...init, detail: cloneCustomEventDetail(detail) };
579const documentEvent = createDocumentCustomEvent(type, eventInit);
580if (documentEvent) return documentEvent;
581const CustomEventConstructor = eventConstructor(window, "CustomEvent") ?? eventConstructor(globalThis, "CustomEvent");
582if (CustomEventConstructor) {
583try {
584return new CustomEventConstructor(type, eventInit);
585} catch {
586}
587}
588throw new Error(`Unable to create window custom event: ${type}`);
589}
590function cloneCustomEventDetail(detail) {
591if (detail === void 0 || typeof window === "undefined") return detail;
592const cloneInto = readMethod(globalThis, "cloneInto");
593if (!cloneInto) return detail;
594try {
595return cloneInto(detail, window, { cloneFunctions: false, wrapReflectors: true });
596} catch {
597try {
598return JSON.stringify(detail);
599} catch {
600return void 0;
601}
602}
603}
604function dispatchWindowEvent(event) {
605const target = window;
606const directDispatch = readMethod(target, "dispatchEvent");
607const directResult = callEventTargetMethod(directDispatch, target, event);
608if (directResult.called) return directResult.result;
609const initialResult = initialWindowDispatchEvent === directDispatch ? { called: false } : callEventTargetMethod(initialWindowDispatchEvent, target, event);
610if (initialResult.called) return initialResult.result;
611const prototypeResult = dispatchWithPrototypeMethod(target, directDispatch, event);
612if (prototypeResult.called) return prototypeResult.result;
613const unshadowedResult = callWithUnshadowedWindowDispatch(event);
614if (unshadowedResult.called) return unshadowedResult.result;
615return false;
616}
617function addWindowEventListener(type, listener, options) {
618const target = window;
619const directAdd = readMethod(target, "addEventListener");
620const directResult = callAddEventListener$2(directAdd, target, type, listener, options);
621if (directResult.called) return true;
622const initialResult = initialWindowAddEventListener === directAdd ? { called: false } : callAddEventListener$2(initialWindowAddEventListener, target, type, listener, options);
623if (initialResult.called) return true;
624const prototypeResult = addListenerWithPrototypeMethod(target, directAdd, type, listener, options);
625if (prototypeResult.called) return true;
626const unshadowedResult = callWithUnshadowedWindowAddEventListener(type, listener, options);
627if (unshadowedResult.called) return true;
628return false;
629}
630function removeWindowEventListener(type, listener, options) {
631const target = window;
632const directRemove = readMethod(target, "removeEventListener");
633const directResult = callRemoveEventListener$2(directRemove, target, type, listener, options);
634if (directResult.called) return true;
635const initialResult = initialWindowRemoveEventListener === directRemove ? { called: false } : callRemoveEventListener$2(initialWindowRemoveEventListener, target, type, listener, options);
636if (initialResult.called) return true;
637const prototypeResult = removeListenerWithPrototypeMethod(target, directRemove, type, listener, options);
638if (prototypeResult.called) return true;
639const unshadowedResult = callWithUnshadowedWindowRemoveEventListener(type, listener, options);
640if (unshadowedResult.called) return true;
641return false;
642}
643function initialWindowMethod(key) {
644if (typeof window === "undefined") return void 0;
645return readMethod(window, key);
646}
647function dispatchWithPrototypeMethod(target, directDispatch, event) {
648for (const prototypeDispatch of eventTargetPrototypeMethods(target, "dispatchEvent")) {
649if (prototypeDispatch === directDispatch) continue;
650const result = callEventTargetMethod(prototypeDispatch, target, event);
651if (result.called) return result;
652}
653return { called: false };
654}
655function addListenerWithPrototypeMethod(target, directAdd, type, listener, options) {
656for (const prototypeAdd of eventTargetPrototypeMethods(target, "addEventListener")) {
657if (prototypeAdd === directAdd) continue;
658const result = callAddEventListener$2(prototypeAdd, target, type, listener, options);
659if (result.called) return result;
660}
661return { called: false };
662}
663function removeListenerWithPrototypeMethod(target, directRemove, type, listener, options) {
664for (const prototypeRemove of eventTargetPrototypeMethods(target, "removeEventListener")) {
665if (prototypeRemove === directRemove) continue;
666const result = callRemoveEventListener$2(prototypeRemove, target, type, listener, options);
667if (result.called) return result;
668}
669return { called: false };
670}
671function eventConstructor(source, key) {
672const value = readProperty(source, key);
673return typeof value === "function" ? value : void 0;
674}
675function createDocumentCustomEvent(type, init) {
676if (typeof document === "undefined" || typeof document.createEvent !== "function") return void 0;
677try {
678const event = document.createEvent("CustomEvent");
679event.initCustomEvent(type, Boolean(init.bubbles), Boolean(init.cancelable), init.detail);
680return event;
681} catch {
682return void 0;
683}
684}
685function eventTargetPrototypeMethods(target, key) {
686const methods = [];
687const add = (method) => {
688if (method && !methods.includes(method)) methods.push(method);
689};
690let prototype = Object.getPrototypeOf(target);
691while (prototype) {
692add(readOwnMethod(prototype, key));
693prototype = Object.getPrototypeOf(prototype);
694}
695const WindowEventTarget = readProperty(window, "EventTarget");
696add(readMethod(WindowEventTarget?.prototype, key));
697if (typeof EventTarget !== "undefined") add(readMethod(EventTarget.prototype, key));
698return methods;
699}
700function readMethod(source, key) {
701const value = readProperty(source, key);
702return typeof value === "function" ? value : void 0;
703}
704function readOwnMethod(source, key) {
705if (!source || typeof source !== "object" && typeof source !== "function") return void 0;
706if (!Object.prototype.hasOwnProperty.call(source, key)) return void 0;
707return readMethod(source, key);
708}
709function readProperty(source, key) {
710if (!source || typeof source !== "object" && typeof source !== "function") return void 0;
711return attempt(() => source[key], void 0, "window-events.readProperty");
712}
713function callEventTargetMethod(method, target, event) {
714if (!method) return { called: false };
715try {
716return { called: true, result: method.call(target, event) };
717} catch (error) {
718return { called: false, error };
719}
720}
721function callAddEventListener$2(method, target, type, listener, options) {
722if (!method) return { called: false };
723try {
724method.call(target, type, listener, options);
725return { called: true };
726} catch (error) {
727return { called: false, error };
728}
729}
730function callRemoveEventListener$2(method, target, type, listener, options) {
731if (!method) return { called: false };
732try {
733method.call(target, type, listener, options);
734return { called: true };
735} catch (error) {
736return { called: false, error };
737}
738}
739function callWithUnshadowedWindowDispatch(event) {
740const target = window.wrappedJSObject || window;
741const descriptor = safeWindowPropertyDescriptor("dispatchEvent");
742if (!shouldTemporarilyUnshadowWindowProperty(descriptor)) return { called: false };
743try {
744if (!Reflect.deleteProperty(target, "dispatchEvent")) return { called: false };
745return callEventTargetMethod(readMethod(window, "dispatchEvent"), window, event);
746} catch (error) {
747return { called: false, error };
748} finally {
749restoreWindowProperty("dispatchEvent", descriptor);
750}
751}
752function callWithUnshadowedWindowAddEventListener(type, listener, options) {
753const target = window.wrappedJSObject || window;
754const descriptor = safeWindowPropertyDescriptor("addEventListener");
755if (!shouldTemporarilyUnshadowWindowProperty(descriptor)) return { called: false };
756try {
757if (!Reflect.deleteProperty(target, "addEventListener")) return { called: false };
758return callAddEventListener$2(readMethod(window, "addEventListener"), window, type, listener, options);
759} catch (error) {
760return { called: false, error };
761} finally {
762restoreWindowProperty("addEventListener", descriptor);
763}
764}
765function callWithUnshadowedWindowRemoveEventListener(type, listener, options) {
766const target = window.wrappedJSObject || window;
767const descriptor = safeWindowPropertyDescriptor("removeEventListener");
768if (!shouldTemporarilyUnshadowWindowProperty(descriptor)) return { called: false };
769try {
770if (!Reflect.deleteProperty(target, "removeEventListener")) return { called: false };
771return callRemoveEventListener$2(readMethod(window, "removeEventListener"), window, type, listener, options);
772} catch (error) {
773return { called: false, error };
774} finally {
775restoreWindowProperty("removeEventListener", descriptor);
776}
777}
778function restoreWindowProperty(key, descriptor) {
779attemptVoid(() => {
780const target = window.wrappedJSObject || window;
781Object.defineProperty(target, key, pageCompartmentDescriptor(normalizedPropertyDescriptor(descriptor), target));
782}, "window-events.restoreWindowProperty");
783}
784function pageCompartmentDescriptor(descriptor, _target) {
785return pageCompartmentValue(descriptor, { cloneFunctions: true, wrapReflectors: true });
786}
787function pageCompartmentValue(value, options = {}) {
788const cloneInto = readMethod(globalThis, "cloneInto");
789if (!cloneInto || typeof window === "undefined") return value;
790try {
791return cloneInto(value, window, options);
792} catch {
793return value;
794}
795}
796function safeWindowPropertyDescriptor(key) {
797try {
798const target = window.wrappedJSObject || window;
799return Object.getOwnPropertyDescriptor(target, key);
800} catch {
801return void 0;
802}
803}
804function shouldTemporarilyUnshadowWindowProperty(descriptor) {
805if (!descriptor) return false;
806return attempt(() => typeof descriptor.value !== "function", false, "window-events.shouldTemporarilyUnshadowWindowProperty");
807}
808function normalizedPropertyDescriptor(descriptor) {
809const hasDataShape = Object.prototype.hasOwnProperty.call(descriptor, "value") || Object.prototype.hasOwnProperty.call(descriptor, "writable");
810const hasAccessorShape = Object.prototype.hasOwnProperty.call(descriptor, "get") || Object.prototype.hasOwnProperty.call(descriptor, "set");
811if (!hasDataShape || !hasAccessorShape) return descriptor;
812try {
813return {
814configurable: descriptor.configurable,
815enumerable: descriptor.enumerable,
816value: descriptor.value,
817writable: descriptor.writable
818};
819} catch {
820return {
821configurable: true,
822value: void 0,
823writable: true
824};
825}
826}
827async function legacyExtensionManagedStorageKeys(root = globalThis) {
828const storage = extensionStorageArea$1(root);
829if (!storage) return [];
830const values = await storage.get(null);
831return Object.keys(values).filter(isManagedStorageKey).sort();
832}
833async function clearLegacyExtensionManagedStorage(root = globalThis, assertActive = () => void 0) {
834const storage = extensionStorageArea$1(root);
835if (!storage) return 0;
836const keys = await legacyExtensionManagedStorageKeys(root);
837await removeStorageKeys(storage, keys, assertActive);
838assertAllStorageKeysRemoved(await retainedStorageKeys(storage, keys));
839return keys.length;
840}
841function legacyExtensionManagedStorageAvailable(root = globalThis) {
842return extensionStorageArea$1(root) !== null;
843}
844function extensionStorageArea$1(root) {
845const candidate = root;
846return activeExtensionStorageArea(candidate.browser) ?? activeExtensionStorageArea(candidate.chrome);
847}
848function activeExtensionStorageArea(api) {
849if (!hasActiveExtensionRuntime(api)) return null;
850return localStorageArea(api);
851}
852function hasActiveExtensionRuntime(api) {
853if (!api) return false;
854if (!api.runtime) return false;
855return Boolean(api.runtime.id);
856}
857function localStorageArea(api) {
858if (!api.storage) return null;
859return api.storage.local ?? null;
860}
861async function removeStorageKeys(storage, keys, assertActive) {
862for (const key of keys) {
863assertActive();
864await storage.remove(key);
865}
866}
867async function retainedStorageKeys(storage, keys) {
868const retained = [];
869for (const key of keys) {
870if (Object.hasOwn(await storage.get(key), key)) retained.push(key);
871}
872return retained;
873}
874function assertAllStorageKeysRemoved(retained) {
875if (!retained.length) return;
876throw new Error(`Unprefixed managed extension storage retained: ${retained.join(", ")}`);
877}
878const GM_STORAGE_OPS = new Set([
879"get",
880"set",
881"delete",
882"list",
883"clear-private-managed",
884"clear-legacy-extension-managed"
885]);
886const BRIDGE_REQUEST_EVENT$1 = "yomu-userscript-storage-request";
887const BRIDGE_RESPONSE_EVENT$1 = "yomu-userscript-storage-response";
888const BRIDGE_MARKER$1 = "yomuUserscriptStorageBridge";
889const BRIDGE_OWNER = "yomuStorageBridgeOwner";
890const BRIDGE_KIND = "yomuStorageBridgeKind";
891const BRIDGE_KEYS$1 = { ready: BRIDGE_MARKER$1, owner: BRIDGE_OWNER, kind: BRIDGE_KIND };
892const EXTENSION_STORAGE_BRIDGE_MARKER = "yomuExtensionStorageBridge";
893const EXTENSION_STORAGE_TARGET = "extension-storage";
894const BRIDGE_TIMEOUT_MS$1 = 1e4;
895let bridgeRequestListenerCleanup;
896let bridgeOwnerId$1;
897let clientOwner$1;
898function getUserscriptGmStorage() {
899if (!storageBridgeClientAvailable()) return void 0;
900clientOwner$1 ??= pinnableOwner(readyBridgeOwner(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, expectedStorageBridgeKind()));
901const request = (detail) => storageBridgeRequest(detail);
902return {
903getValue: (key, fallback) => request({ op: "get", key }).then((detail) => detail.found ? detail.value : fallback),
904setValue: (key, value) => request({ op: "set", key, value }).then(() => void 0),
905deleteValue: (key) => request({ op: "delete", key }).then(() => void 0),
906listValues: () => request({ op: "list" }).then((detail) =>
906 detail.keys ?? []),
907clearPrivateManagedValues: () => request({ op: "clear-private-managed" }).then(() => void 0),
908clearLegacyExtensionManagedValues: () => extensionStorageBridgeAdvertised() ? request({
909op: "clear-legacy-extension-managed",
910target: EXTENSION_STORAGE_TARGET
911}).then(() => void 0) : Promise.resolve()
912};
913}
914function storageBridgeClientAvailable() {
915if (typeof window === "undefined" || typeof document === "undefined") return false;
916return Boolean(clientOwner$1 || expectedStorageBridgeKind() || readyBridgeOwner(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, null));
917}
918function expectedStorageBridgeKind() {
919return expectedBridgeKind(shouldInstallUserscriptStorageBridge);
920}
921function userscriptGmStorageOwnerKind() {
922if (!storageBridgeClientAvailable()) return void 0;
923return clientOwner$1?.kind ?? expectedStorageBridgeKind() ?? readyBridgeOwner(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, null)?.kind;
924}
925function installedStorageResponderReady() {
926return readyBridgeOwner(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, expectedStorageBridgeKind()) !== null;
927}
928function installUserscriptGmStorageBridge() {
929const installation = userscriptStorageBridgeInstallation();
930if (!installation) return;
931const kind = detectInstalledReaderRuntime() === "extension" ? "extension" : "userscript";
932if (!mayClaimBridge(installation.markerDataset, BRIDGE_KEYS$1, kind, bridgeOwnerId$1)) return;
933advertiseExtensionStorageBridge(installation.markerDataset);
934if (hasInstalledUserscriptStorageBridge(installation.markerDataset)) {
935dispatchStorageBridgeReady();
936return;
937}
938bridgeRequestListenerCleanup?.();
939const ownerId = createBridgeOwnerId("yomu-storage");
940bridgeOwnerId$1 = ownerId;
941installation.markerDataset[BRIDGE_OWNER] = ownerId;
942installation.markerDataset[BRIDGE_KIND] = kind;
943installation.markerDataset[BRIDGE_MARKER$1] = "true";
944const assertActive = () => {
945if (bridgeOwnerId$1 !== ownerId || bridgeMarkerDataset$1()?.[BRIDGE_OWNER] !== ownerId) {
946throw new Error("Storage bridge authority changed during the request.");
947}
948};
949const handledRequestIds = new Set();
950bridgeRequestListenerCleanup = addBridgeEventListener$1(BRIDGE_REQUEST_EVENT$1, (event) => {
951const detail = storageBridgeRequestDetail(event);
952if (!detail || !bridgeRequestAddressedTo(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, ownerId, detail.ownerId) || !storageBridgeResponderAccepts(detail) || handledRequestIds.has(detail.id)) return;
953rememberBridgeRequestId$1(handledRequestIds, detail.id);
954void handleStorageBridgeRequest(detail, installation.accessors, assertActive);
955});
956dispatchStorageBridgeReady();
957}
958function userscriptStorageBridgeInstallation() {
959const accessors = installableGmStorageAccessors();
960if (!accessors) return void 0;
961const markerDataset = bridgeMarkerDataset$1();
962return markerDataset ? { accessors, markerDataset } : void 0;
963}
964function installableGmStorageAccessors() {
965if (!userscriptStorageBridgeEnvironmentReady()) return void 0;
966return shouldInstallUserscriptStorageBridge() ? gmStorageAccessors() : void 0;
967}
968function userscriptStorageBridgeEnvironmentReady() {
969return typeof window !== "undefined" && typeof document !== "undefined";
970}
971function advertiseExtensionStorageBridge(markerDataset) {
972if (!legacyExtensionManagedStorageAvailable()) return;
973markerDataset[EXTENSION_STORAGE_BRIDGE_MARKER] = "true";
974}
975function installUserscriptGmStorageBridgeWhenReady() {
976installUserscriptGmStorageBridge();
977if (typeof window === "undefined" || typeof document === "undefined") return;
978if (!shouldInstallUserscriptStorageBridge()) return;
979if (hasInstalledUserscriptStorageBridge()) return;
980scheduleUserscriptStorageBridgeRetry();
981}
982async function handleStorageBridgeRequest(detail, accessors, assertActive) {
983const send = (response) => dispatchBridgeEvent$1(BRIDGE_RESPONSE_EVENT$1, { id: detail.id, ...response });
984try {
985send(await storageBridgeOperationResponse(detail, accessors, assertActive));
986} catch (error) {
987send({ ok: false, found: false, message: error instanceof Error ? error.message : String(error) });
988}
989}
990async function storageBridgeOperationResponse(detail, accessors, assertActive) {
991const maintenanceResponse = await storageBridgeMaintenanceResponse(detail.op, accessors, assertActive);
992if (maintenanceResponse) return maintenanceResponse;
993assertActive();
994if (!detail.key || !isBridgeManagedStorageKey(detail.key)) {
995return { ok: false, found: false, message: "Unmanaged storage key." };
996}
997return managedStorageBridgeResponse(detail.op, detail.key, detail.value, accessors);
998}
999async function storageBridgeMaintenanceResponse(op, accessors, assertActive) {
1000if (op === "list") {
1001return { ok: true, keys: (await accessors.listValues()).filter(isBridgeManagedStorageKey) };
1002}
1003if (op === "clear-private-managed") {
1004await clearPrivateManagedStorage(accessors, assertActive);
1005return { ok: true };
1006}
1007if (op === "clear-legacy-extension-managed") {
1008await clearLegacyExtensionStorageFromAuthoritativeResponder(assertActive);
1009return { ok: true };
1010}
1011return void 0;
1012}
1013async function clearLegacyExtensionStorageFromAuthoritativeResponder(assertActive) {
1014if (!legacyExtensionManagedStorageAvailable()) {
1015throw new Error("Extension storage authority is unavailable.");
1016}
1017await clearLegacyExtensionManagedStorage(globalThis, assertActive);
1018}
1019async function clearPrivateManagedStorage(accessors, assertActive) {
1020const privateKeys = (await accessors.listValues()).filter(isPrivateManagedStorageKey);
1021for (const key of privateKeys) {
1022assertActive();
1023await accessors.deleteValue(key);
1024}
1025const remaining = (await accessors.listValues()).filter(isPrivateManagedStorageKey);
1026if (remaining.length) throw new Error("Private managed storage could not be cleared.");
1027}
1028async function managedStorageBridgeResponse(op, key, value, accessors) {
1029if (op === "get") return readStorageBridgeResponse(key, accessors);
1030if (op === "set") {
1031await accessors.setValue(key, value);
1032return { ok: true };
1033}
1034await accessors.deleteValue(key);
1035return { ok: true };
1036}
1037async function readStorageBridgeResponse(key, accessors) {
1038const value = await accessors.getValue(key, MISSING$1);
1039return value?.__yomuStorageBridgeMissing === true ? { ok: true, found: false } : { ok: true, found: true, value };
1040}
1041function storageBridgeRequest(request) {
1042return new Promise((resolve, reject) => {
1043const id = `yomu-store-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1044let pinned;
1045let cleanupListeners = noop$1;
1046const cleanup = () => {
1047window.clearTimeout(timeout);
1048cleanupListeners();
1049};
1050const fail = (message) => {
1051cleanup();
1052reject(new Error(message));
1053};
1054const timeout = window.setTimeout(() => {
1055if (!pinned) fail("Installed Yomu storage did not connect; reload to reconnect.");
1056else if (!pinnedOwnerCurrent(pinned)) fail("Storage bridge authority changed during the request.");
1057else fail("Storage bridge request timed out.");
1058}, BRIDGE_TIMEOUT_MS$1);
1059const onResponse = (event) => {
1060const detail = storageBridgeResponseDetail(event);
1061if (!detail || detail.id !== id) return;
1062if (!pinned || !pinnedOwnerCurrent(pinned)) return fail("Storage bridge authority changed during the request.");
1063cleanup();
1064if (detail.ok) resolve(detail);
1065else reject(new Error(detail.message || "Storage bridge request failed."));
1066};
1067const send = (owner) => {
1068pinned = owner;
1069cleanupListeners();
1070cleanupListeners = addBridgeEventListener$1(BRIDGE_RESPONSE_EVENT$1, onResponse);
1071dispatchBridgeEvent$1(BRIDGE_REQUEST_EVENT$1, { id, ownerId: owner.ownerId, ...request });
1072};
1073const connect = () => {
1074if (clientOwner$1) {
1075if (pinnedOwnerCurrent(clientOwner$1)) send(clientOwner$1);
1076else fail("Storage bridge authority changed; reload to reconnect.");
1077return true;
1078}
1079const owner = readyBridgeOwner(bridgeMarkerDataset$1(), BRIDGE_KEYS$1, expectedStorageBridgeKind());
1080if (!owner) return false;
1081clientOwner$1 = pinnableOwner(owner);
1082send(owner);
1083return true;
1084};
1085if (connect()) return;
1086cleanupListeners = addBridgeEventListener$1(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, () => {
1087if (!pinned) connect();
1088});
1089});
1090}
1091function pinnableOwner(owner) {
1092return owner?.ownerId === void 0 ? void 0 : owner;
1093}
1094function pinnedOwnerCurrent(owner) {
1095const dataset = bridgeMarkerDataset$1();
1096return dataset?.[BRIDGE_MARKER$1] === "true" && dataset[BRIDGE_OWNER] === owner.ownerId;
1097}
1098const MISSING$1 = { __yomuStorageBridgeMissing: true };
1099function gmStorageAccessors() {
1100const getValue = directGmGetValue$1();
1101const setValue = directGmSetValue$1();
1102const deleteValue = directGmDeleteValue$1();
1103const listValues = directGmListValues$1();
1104if (!getValue || !setValue) return null;
1105return {
1106getValue,
1107setValue,
1108deleteValue: deleteValue ?? (() => void 0),
1109listValues: listValues ?? (() => {
1110throw new Error("GM_listValues is unavailable.");
1111})
1112};
1113}
1114function directGmGetValue$1() {
1115const modern = globalThis.GM?.getValue;
1116if (typeof modern === "function") return modern.bind(globalThis.GM);
1117return typeof GM_getValue === "function" ? GM_getValue : null;
1118}
1119function directGmSetValue$1() {
1120if (typeof GM_setValue === "function") return GM_setValue;
1121const modern = globalThis.GM?.setValue;
1122return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
1123}
1124function directGmDeleteValue$1() {
1125if (typeof GM_deleteValue === "function") return GM_deleteValue;
1126const modern = globalThis.GM?.deleteValue;
1127return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
1128}
1129function directGmListValues$1() {
1130const modern = globalThis.GM?.listValues;
1131if (typeof modern === "function") return modern.bind(globalThis.GM);
1132if (typeof GM_listValues === "function") return GM_listValues;
1133const direct = globalThis.GM_listValues;
1134if (typeof direct === "function") return direct;
1135return null;
1136}
1137function shouldInstallUserscriptStorageBridge() {
1138try {
1139return typeof location !== "undefined" && isYomuStorageBridgeHostedUrl(location.href);
1140} catch {
1141return false;
1142}
1143}
1144function scheduleUserscriptStorageBridgeRetry() {
1145const retry = () => {
1146if (hasInstalledUserscriptStorageBridge()) return;
1147installUserscriptGmStorageBridge();
1148};
1149if (typeof queueMicrotask === "function") {
1150queueMicrotask(retry);
1151} else {
1152void Promise.resolve().then(retry);
1153}
1154window.setTimeout(retry, 0);
1155window.setTimeout(retry, 250);
1156if (document.readyState === "loading") {
1157document.addEventListener("DOMContentLoaded", retry, { once: true });
1158}
1159}
1160function hasInstalledUserscriptStorageBridge(markerDataset = bridgeMarkerDataset$1()) {
1161return Boolean(markerDataset?.[BRIDGE_MARKER$1] === "true" && bridgeRequestListenerCleanup);
1162}
1163function dispatchStorageBridgeReady() {
1164dispatchBridgeEvent$1(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT);
1165}
1166function storageBridgeRequestDetail(event) {
1167const record2 = storageBridgeRequestRecord(event);
1168if (!record2) return void 0;
1169const target = storageBridgeTarget(record2.target);
1170if (!validStorageBridgeTarget(record2.target, target)) return void 0;
1171return {
1172id: record2.id,
1173ownerId: typeof record2.ownerId === "string" ? record2.ownerId : void 0,
1174op: record2.op,
1175target,
1176key: storageBridgeRequestKey(record2.key),
1177value: record2.value
1178};
1179}
1180function storageBridgeRequestRecord(event) {
1181const record2 = storageBridgeEventRecord(event);
1182if (!record2) return void 0;
1183if (!validStorageBridgeRequestIdentity(record2)) return void 0;
1184return record2;
1185}
1186function storageBridgeEventRecord(event) {
1187const detail = normalizedBridgeEventDetail(event);
1188if (!detail) return void 0;
1189if (typeof detail !== "object") return void 0;
1190return detail;
1191}
1192function validStorageBridgeRequestIdentity(record2) {
1193if (typeof record2.id !== "string") return false;
1194return isGmStorageOp(record2.op);
1195}
1196function validStorageBridgeTarget(value, target) {
1197return value === void 0 || target !== void 0;
1198}
1199function storageBridgeRequestKey(value) {
1200return typeof value === "string" ? value : void 0;
1201}
1202function storageBridgeResponseDetail(event) {
1203const detail = normalizedBridgeEventDetail(event);
1204if (!detail || typeof detail !== "object") return void 0;
1205const record2 = detail;
1206if (typeof record2.id !== "string" || typeof record2.ok !== "boolean") return void 0;
1207return {
1208id: record2.id,
1209ok: record2.ok,
1210found: typeof record2.found === "boolean" ? record2.found : void 0,
1211value: record2.value,
1212keys: Array.isArray(record2.keys) ? record2.keys.filter((key) => typeof key === "string") : void 0,
1213message: typeof record2.message === "string" ? record2.message : void 0
1214};
1215}
1216function isGmStorageOp(value) {
1217return typeof value === "string" && GM_STORAGE_OPS.has(value);
1218}
1219function storageBridgeTarget(value) {
1220return value === EXTENSION_STORAGE_TARGET ? value : void 0;
1221}
1222function storageBridgeResponderAccepts(detail) {
1223return detail.target !== EXTENSION_STORAGE_TARGET || legacyExtensionManagedStorageAvailable();
1224}
1225function extensionStorageBridgeAdvertised() {
1226return bridgeMarkerDataset$1()?.[EXTENSION_STORAGE_BRIDGE_MARKER] === "true";
1227}
1228function addBridgeEventListener$1(type, listener) {
1229const cleanups = [];
1230if (addWindowEventListener(type, listener)) {
1231cleanups.push(() => removeWindowEventListener(type, listener));
1232}
1233const documentTarget = bridgeDocumentTarget$1();
1234if (documentTarget && callAddEventListener$1(documentTarget, type, listener)) {
1235cleanups.push(() => callRemoveEventListener$1(documentTarget, type, listener));
1236}
1237return () => {
1238for (const cleanup of cleanups) cleanup();
1239};
1240}
1241function dispatchBridgeEvent$1(type, detail) {
1242const eventDetail = bridgeEventDetail(detail);
1243let dispatched = dispatchWindowEvent(createWindowCustomEvent(type, eventDetail));
1244const documentTarget = bridgeDocumentTarget$1();
1245if (documentTarget) {
1246dispatched = callDispatchEvent$1(documentTarget, createWindowCustomEvent(type, eventDetail)) || dispatched;
1247}
1248return dispatched;
1249}
1250function bridgeDocumentTarget$1() {
1251if (typeof document === "undefined") return void 0;
1252return document.documentElement instanceof HTMLElement ? document.documentElement : void 0;
1253}
1254function bridgeMarkerDataset$1() {
1255if (typeof document === "undefined") return void 0;
1256const root = document.documentElement;
1257return root?.dataset;
1258}
1259function callAddEventListener$1(target, type, listener) {
1260try {
1261target.addEventListener(type, listener);
1262return true;
1263} catch {
1264return false;
1265}
1266}
1267function callRemoveEventListener$1(target, type, listener) {
1268try {
1269target.removeEventListener(type, listener);
1270} catch {
1271}
1272}
1273function callDispatchEvent$1(target, event) {
1274try {
1275return target.dispatchEvent(event);
1276} catch {
1277return false;
1278}
1279}
1280function rememberBridgeRequestId$1(ids, id) {
1281ids.add(id);
1282if (ids.size <= 200) return;
1283const oldest = ids.values().next().value;
1284if (oldest) ids.delete(oldest);
1285}
1286function noop$1() {
1287}
1288const entries = [];
1289const registeredEntryIndexes = new Map();
1290let resetWritesSuppressed = false;
1291function registerManagedState(entry) {
1292const identity = managedStateIdentity(entry);
1293const existingIndex = registeredEntryIndexes.get(identity);
1294if (existingIndex !== void 0) {
1295const existing = entries[existingIndex];
1296if (existing.owner !== entry.owner) {
1297throw new Error(`Managed state ${identity} has conflicting owners: ${existing.owner}, ${entry.owner}.`);
1298}
1299if (existing.enumerate && entry.enumerate && existing.enumerate !== entry.enumerate) {
1300throw new Error(`Managed state ${identity} has conflicting enumerators.`);
1301}
1302if (!existing.enumerate && entry.enumerate) entries[existingIndex] = { ...existing, enumerate: entry.enumerate };
1303return;
1304}
1305registeredEntryIndexes.set(identity, entries.length);
1306entries.push(entry);
1307}
1308function registerManagedStates(list) {
1309for (const entry of list) registerManagedState(entry);
1310}
1311function managedStateIdentity(entry) {
1312return `${entry.kind}:${entry.key ?? ""}:${entry.prefix ?? ""}`;
1313}
1314function managedStateEntries() {
1315return entries;
1316}
1317function registeredManagedStorageKeys() {
1318const keys = new Set();
1319for (const entry of entries) {
1320if (entry.kind !== "idb" && entry.key) keys.add(entry.key);
1321}
1322return [...keys];
1323}
1324function registeredManagedIndexedDbNames() {
1325const names = new Set();
1326for (const entry of entries) {
1327if (entry.kind === "idb" && entry.key) names.add(entry.key);
1328}
1329return [...names];
1330}
1331function managedStateWritesSuppressed() {
1332return resetWritesSuppressed;
1333}
1334let sandboxCompanions = {};
1335function yomuSettingsDialogController() {
1336return yomuCompanions().settings?.SettingsDialogController;
1337}
1338function yomuOnboardingController() {
1339return yomuCompanions().settings?.OnboardingController;
1340}
1341function yomuSettingsSurfaceCompanion() {
1342return yomuCompanions().settings;
1343}
1344function yomuAnkiCompanion() {
1345return yomuCompanions().anki;
1346}
1347function yomuAudioCompanion() {
1348return yomuCompanions().audio;
1349}
1350function yomuWanikaniCompanion() {
1351return yomuCompanions().wanikani;
1352}
1353function yomuJpdbCompanion() {
1354return yomuCompanions().jpdb;
1355}
1356function yomuJitenCompanion() {
1357return yomuCompanions().jiten;
1358}
1359function yomuVideoCompanionSlot() {
1360return yomuCompanions().video;
1361}
1362function yomuAnnotationsCompanion() {
1363return yomuCompanions().annotations;
1364}
1365function yomuSubtitlePlayerController() {
1366return yomuCompanions().video?.SubtitlePlayerController;
1367}
1368function yomuYoutubeImmersionFilter() {
1369return yomuCompanions().video?.YoutubeImmersionFilter;
1370}
1371function yomuImageOcrController() {
1372return yomuCompanions().ocr?.ImageOcrController;
1373}
1374function yomuNormalizeOcrRenderedText() {
1375return yomuCompanions().ocr?.normalizeOcrRenderedText;
1376}
1377function yomuLocalDictionaries() {
1378return yomuCompanions().localDictionaries;
1379}
1380function yomuLearningTargetRuntime() {
1381return yomuCompanions().learningTargets;
1382}
1383function yomuI18nCompanion() {
1384return yomuCompanions().i18n;
1385}
1386function yomuBunproCompanion() {
1387return yomuCompanions().bunpro;
1388}
1389function yomuKanjiStudyCompanion() {
1390return yomuCompanions().kanjiStudy;
1391}
1392function yomuCompanions() {
1393return readYomuCompanions(globalThis) ?? sandboxCompanions ?? (typeof window === "undefined" ? void 0 : readYomuCompanions(window)) ?? {};
1394}
1395function readYomuCompanions(target) {
1396if (!target || typeof target !== "object" && typeof target !== "function") return void 0;
1397try {
1398return target.__yomuCompanions;
1399} catch {
1400return void 0;
1401}
1402}
1403async function enumerateDictionaryArchiveStorageKeys() {
1404const enumerate = yomuLocalDictionaries()?.enumerateDictionaryArchiveStorageKeys;
1405if (!enumerate) throw new Error("The local-dictionary companion cannot enumerate archive storage.");
1406return enumerate();
1407}
1408const MANAGED_STATE_MANIFEST = [
1409{ owner: "settings", kind: "gm", key: "jpdb-popup-reader-settings" },
1410{ owner: "settings (legacy)", kind: "gm", key: "jpdb-reader-settings" },
1411{ owner: "settings (legacy)", kind: "gm", key: "yomu-reader-settings" },
1412{ owner: "settings (legacy)", kind: "gm", key: "yomu-settings" },
1413{ owner: "settings", kind: "gm", key: "yomu:prefer-japanese-site-language:v1" },
1414{ owner: "settings (pre-ledger pins)", kind: "gm", key: "yomu:explicit-user-settings:v1" },
1415{ owner: "settings/intent-ledger", kind: "gm", key: "yomu:settings-intent:v2" },
1416{ owner: "settings (retired promotion marker; purge only)", kind: "gm", key: "yomu:extension-study-legacy-promotion:v1" },
1417{ owner: "settings/dialog-controller", kind: "gm", key: "yomu:private:cloud-settings-sync-pending:v1" },
1418{ owner: "settings/dialog-controller (legacy)", kind: "gm", key: "__yomu_cloud_settings_sync_pending_action" },
1419{ owner: "app/storage", kind: "gm", key: "yomu:factory-reset-signal" },
1420{ owner: "app/storage epoch", kind: "gm", key: "yomu:state-epoch" },
1421{ owner: "app/storage epoch slots", kind: "gm", prefix: "yomu:state-slot:v1:" },
1422{ owner: "app/storage epoch lease", kind: "gm", prefix: "yomu:state-epoch-lease:v1:" },
1423{ owner: "app/managed-web-storage", kind: "local", key: "yomu:web-storage-epoch:v1:local" },
1424{ owner: "app/managed-web-storage", kind: "session", key: "yomu:web-storage-epoch:v1:session" },
1425{ owner: "app/managed-web-storage", kind: "local", prefix: "yomu:web-storage-slot:v1:" },
1426{ owner: "app/managed-web-storage", kind: "session", prefix: "yomu:web-storage-slot:v1:" },
1427{ owner: "app/managed-web-storage", kind: "local", prefix: "yomu:web-owner:v2:" },
1428{ owner: "app/managed-web-storage", kind: "session", prefix: "yomu:web-owner:v2:" },
1429{ owner: "app/storage local provenance", kind: "local", key: "yomu:local-storage-provenance:v1" },
1430{ owner: "app/card-state-signal", kind: "gm", key: "yomu:card-state-signal" },
1431{ owner: "app/storage leases", kind: "gm", prefix: "yomu:lease:" },
1432{ owner: "srs/account-sync", kind: "gm", key: "yomu:private:academy-device:v1" },
1433{ owner: "srs/account-sync", kind: "gm", key: "yomu:private:academy-device-pending:v1" },
1434{ owner: "app/logger", kind: "gm", key: "yomu:enable-logs" },
1435{ owner: "app/main", kind: "local", key: "yomu:jpdb-review-examples-visible:v1" },
1436{ owner: "core/hosted-appearance-boot", kind: "local", key: "yomu-page-theme" },
1437{ owner: "app/preferred-site-language", kind: "local", key: "yomu:prefer-japanese-site-language" },
1438{ owner: "app/preferred-site-language", kind: "session", key: "yomu:jps" },
1439{ owner: "app/preferred-site-language", kind: "session", key: "yomu:jps:hosts" },
1440{ owner: "srs/local-yomu-store (legacy)", kind: "gm", key: "yomu:srs-local:v1" },
1441{ owner: "srs/local-yomu-store", kind: "gm", prefix: "yomu:srs-local:v2:" },
1442{ owner: "anki/status-index", kind: "gm", key: "yomu:anki-status-index:v1" },
1443{ owner: "anki/status-index", kind: "gm", key: "yomu:anki-status-index-rebuild:v1" },
1444{ owner: "anki/status-index", kind: "idb", key: "yomu-anki-status-index" },
1445{ owner: "bunpro/word-states", kind: "gm", key: "yomu:bunpro-word-states:v1" },
1446{ owner: "jpdb/jpdb-public-cache", kind: "local", key: "yomu:jpdb-cache:v1" },
1447{ owner: "dictionaries/jiten-public-cache (legacy)", kind: "gm", key: "yomu:jiten-public-cache:v1" },
1448{ owner: "dictionaries/jiten-public-cache", kind: "local", key: "yomu:jiten-public-cache:v2" },
1449{ owner: "dictionaries/jiten-stats-cache", kind: "gm", key: "jpdb-reader-jiten-daily-stats" },
1450{ owner: "dictionaries/yomitan", kind: "idb", key: "jpdb-popup-reader-yomitan" },
1451{ owner: "dictionaries/archive-cache", kind: "gm", key: "yomu-dictionary-archives" },
1452{
1453owner: "dictionaries/archive-cache",
1454kind: "gm",
1455prefix: "yomu-dictionary-archive:",
1456enumerate: enumerateDictionaryArchiveStorageKeys
1457},
1458{ owner: "dictionaries/replication (legacy)", kind: "local", key: "yomu-dictionary-replication-state" },
1459{ owner: "dictionaries/replica-purge", kind: "gm", key: "yomu:dictionary-replica-purge:v1" },
1460{ owner: "dictionaries/replica-purge", kind: "local", key: "yomu:dictionary-replica-purged:v1" },
1461{ owner: "ocr/ocr-cache-store", kind: "local", key: "yomu-ocr-cache-v1" },
1462{ owner: "ocr/ocr-cache-store", kind: "local", key: "yomu-ocr-cache-v2" },
1463{ owner: "ocr/canvas-mirror", kind: "session", key: "yomu:bw:mirror-loadguard" },
1464{ owner: "ocr/reader-canvas-tap-hint", kind: "gm", prefix: "yomu:private:ocr-canvas-tap-hint-seen:v1:" },
1465{ owner: "styles/index", kind: "gm", key: "yomu:reader-css-cache:v3" },
1466{ owner: "styles/index (legacy)", kind: "gm", prefix: "yomu:reader-css-cache:v2:" },
1467{ owner: "study/grammar-knowledge", kind: "gm", key: "yomu.grammarPreferences.v1" },
1468{ owner: "study/grammar-knowledge", kind: "gm", prefix: "yomu.grammarPreferences.v1:" },
1469{ owner: "study/mining-context", kind: "gm", prefix: "yomu-mining-context:" },
1470{ owner: "dictionaries/uchisen-carousel (retired)", kind: "gm", prefix: "yomu-jpdb-uchisen-index:" },
1471{ owner: "popup/shell", kind: "gm", key: "jpdb-reader-sheet-height-ratio" },
1472{ owner: "popup/shell", kind: "gm", key: "jpdb-reader-settings-drawer-height-ratio" },
1473{ owner: "sources/state", kind: "gm", key: "jpdb-reader-source-open-state" },
1474{ owner: "subtitles/subtitle-layout", kind: "gm", key: "jpdb-reader-transcript-panel-size" },
1475{ owner: "subtitles/subtitle-layout", kind: "gm", key: "jpdb-reader-subtitle-drag-offset" },
1476{ owner: "subtitles/subtitle-layout", kind: "gm", key: "jpdb-reader-subtitle-control-rail-position" },
1477{ owner: "subtitles/youtube", kind: "gm", key: "yomu:youtube-all-subscribed:v1" },
1478{ owner: "subtitles/youtube", kind: "session", prefix: "yomu:youtube-oembed-title:v1:" },
1479{ owner: "subtitles/controller", kind: "session", prefix: "yomu:subtitle-parse:v" },
1480{ owner: "study/practice-session", kind: "idb", key: "yomu-practice-sessions-v1" },
1481{ owner: "study/practice-session", kind: "session", key: "yomu:practice-session-tab:v1" },
1482{ owner: "newtab/state", kind: "gm", key: "jpdb-reader-newtab-ui" },
1483{ owner: "newtab/cache", kind: "gm", key: "jpdb-reader-newtab-card-cache" },
1484{ owner: "newtab/controller-config", kind: "gm", key: "jpdb-reader-newtab-grade-queue" },
1485{ owner: "newtab/grade-queue", kind: "gm", key: "yomu:newtab-live-review:v1" },
1486{ owner: "newtab/review-queue-owner", kind: "gm", key: "yomu:private:review-delivery:v2" },
1487{ owner: "newtab/packaged-review-queue-client", kind: "session", key: "yomu:review-action-draft:v2" },
1488{ owner: "newtab/controller-config", kind: "gm", key: "jpdb-reader-newtab-current-word" },
1489{ owner: "newtab/controller-config", kind: "session", key: "jpdb-reader-newtab-current-word" },
1490{ owner: "newtab/controller-config", kind: "gm", key: "jpdb-reader-newtab-jpdb-stats-history" },
1491{ owner: "newtab/controller-config", kind: "gm", key: "jpdb-reader-newtab-disabled-anki-decks" },
1492{ owner: "newtab/session-progress", kind: "local", key: "jpdb-reader-newtab-daily-study-time" },
1493{ owner: "newtab/controller", kind: "local", key: "yomu-newtab-support-banner-dismissed" },
1494{ owner: "newtab/pitch-srs", kind: "gm", key: "yomu-pitch-items:v1" },
1495{ owner: "newtab/pitch-srs", kind: "gm", key: "yomu-pitch-history:v1" }
1496];
1497let manifestRegistered = false;
1498function registerManagedStateManifest() {
1499if (manifestRegistered) return;
1500manifestRegistered = true;
1501registerManagedStates(MANAGED_STATE_MANIFEST);
1502}
1503registerManagedStateManifest();
1504const MANAGED_STATE_EPOCH_KEY = "yomu:state-epoch";
1505const MANAGED_STATE_ENVELOPE_VERSION = 1;
1506const MANAGED_STATE_EPOCH_SESSION_SLOT = Symbol.for("yomu.managed-state-epoch-session.v1");
1507const MANAGED_STATE_EPOCH_CANONICAL_SESSION_SLOT = Symbol.for("yomu.managed-state-epoch-canonical-session.v1");
1508const INITIAL_MANAGED_STATE_EPOCH = Object.freeze({
1509version: 1,
1510generation: 0,
1511resetId: "legacy",
1512committedAt: 0
1513});
1514class StaleManagedStateEpochError extends Error {
1515constructor(expected, actual) {
1516super(`Managed state belongs to epoch ${managedStateEpochToken(expected)}, but the current epoch is ${managedStateEpochToken(actual)}.`);
1517this.expected = expected;
1518this.actual = actual;
1519this.name = "StaleManagedStateEpochError";
1520}
1521code = "YOMU_STALE_MANAGED_STATE_EPOCH";
1522}
1523function isStaleManagedStateEpochError(error) {
1524return Boolean(error && typeof error === "object" && error.code === "YOMU_STALE_MANAGED_STATE_EPOCH");
1525}
1526class ManagedStateEpochSession {
1527captured;
1528captureInFlight;
1529current() {
1530return this.captured;
1531}
1532async capture(readEpoch) {
1533if (this.captured) return this.captured;
1534if (!this.captureInFlight) {
1535this.captureInFlight = readEpoch().then(parseManagedStateEpoch).then((epoch) => {
1536this.captured = epoch;
1537return epoch;
1538}).finally(() => {
1539this.captureInFlight = void 0;
1540});
1541}
1542return this.captureInFlight;
1543}
1544captureSync(rawEpoch) {
1545const epoch = parseManagedStateEpoch(rawEpoch);
1546this.captured ??= epoch;
1547return this.captured;
1548}
1549async assertCurrent(readEpoch) {
1550const expected = await this.capture(readEpoch);
1551const actual = parseManagedStateEpoch(await readEpoch());
1552assertManagedStateEpoch(expected, actual);
1553return expected;
1554}
1555assertCurrentSync(rawEpoch) {
1556const expected = this.captureSync(rawEpoch);
1557const actual = parseManagedStateEpoch(rawEpoch);
1558assertManagedStateEpoch(expected, actual);
1559return expected;
1560}
1561/** Test-only lifecycle support for Vitest's reused JavaScript realm. */
1562resetForTests() {
1563this.captured = void 0;
1564this.captureInFlight = void 0;
1565}
1566}
1567function managedStateEpochSessionForRealm(root = globalThis) {
1568const slots = root;
1569const existing = slots[MANAGED_STATE_EPOCH_SESSION_SLOT];
1570if (isManagedStateEpochSession(existing)) return existing;
1571const session = new ManagedStateEpochSession();
1572slots[MANAGED_STATE_EPOCH_SESSION_SLOT] = session;
1573slots[MANAGED_STATE_EPOCH_CANONICAL_SESSION_SLOT] ??= session;
1574return session;
1575}
1576function parseManagedStateEpoch(value) {
1577if (value === void 0 || value === null) return INITIAL_MANAGED_STATE_EPOCH;
1578if (!isPlainRecord$2(value) || value.version !== 1 || !Number.isSafeInteger(value.generation) || value.generation < 1 || typeof value.resetId !== "string" || !value.resetId.trim() || typeof value.committedAt !== "number" || !Number.isFinite(value.committedAt) || value.committedAt <= 0) {
1579throw new Error("The managed-state epoch is malformed.");
1580}
1581return {
1582version: 1,
1583generation: value.generation,
1584resetId: value.resetId,
1585committedAt: value.committedAt
1586};
1587}
1588function nextManagedStateEpoch(current, resetId, committedAt = Date.now()) {
1589if (!resetId.trim()) throw new TypeError("A reset id is required to advance managed state.");
1590if (!Number.isSafeInteger(current.generation + 1)) throw new Error("The managed-state epoch cannot advance further.");
1591return {
1592version: 1,
1593generation: current.generation + 1,
1594resetId,
1595committedAt
1596};
1597}
1598function managedStateStoredValue(value, epoch) {
1599if (epoch.generation === 0) return value;
1600const envelope2 = {
1601__yomuManagedStateEnvelope: MANAGED_STATE_ENVELOPE_VERSION,
1602epoch: managedStateEpochToken(epoch),
1603value
1604};
1605return envelope2;
1606}
1607function managedStateLogicalValue(stored, epoch, fallback) {
1608if (epoch.generation === 0) {
1609if (!isManagedStateEnvelope(stored)) return stored;
1610return stored.epoch === managedStateEpochToken(epoch) ? stored.value : fallback;
1611}
1612if (!isManagedStateEnvelope(stored)) return fallback;
1613return stored.epoch === managedStateEpochToken(epoch) ? stored.value : fallback;
1614}
1615function managedStateResetEnumerationValue(stored) {
1616if (!isPlainRecord$2(stored) || !Object.hasOwn(stored, "__yomuManagedStateEnvelope")) {
1617return stored;
1618}
1619if (!isManagedStateEnvelope(stored)) {
1620throw new Error("The managed-state envelope is malformed.");
1621}
1622return stored.value;
1623}
1624function managedStateEpochToken(epoch) {
1625return `${epoch.generation}:${epoch.resetId}`;
1626}
1627function managedStateEpochTokenRelation(storedToken, current) {
1628if (storedToken === managedStateEpochToken(current)) return "same";
1629const separator = storedToken.indexOf(":");
1630if (separator <= 0) return "malformed";
1631const generationText = storedToken.slice(0, separator);
1632if (!/^(?:0|[1-9]\d*)$/u.test(generationText)) return "malformed";
1633const storedGeneration = Number(generationText);
1634if (!Number.isSafeInteger(storedGeneration)) return "malformed";
1635if (storedGeneration < current.generation) return "older";
1636if (storedGeneration > current.generation) return "newer";
1637return "conflict";
1638}
1639function sameManagedStateEpoch(left, right) {
1640return left.generation === right.generation && left.resetId === right.resetId;
1641}
1642function assertManagedStateEpoch(expected, actual) {
1643if (!sameManagedStateEpoch(expected, actual)) throw new StaleManagedStateEpochError(expected, actual);
1644}
1645function isManagedStateEnvelope(value) {
1646return isPlainRecord$2(value) && value.__yomuManagedStateEnvelope === MANAGED_STATE_ENVELOPE_VERSION && typeof value.epoch === "string" && Object.hasOwn(value, "value");
1647}
1648function isManagedStateEpochSession(value) {
1649return Boolean(value && typeof value === "object" && typeof value.current === "function" && typeof value.capture === "function" && typeof value.assertCurrent === "function" && typeof value.resetForTests === "function");
1650}
1651function isPlainRecord$2(value) {
1652return Boolean(value && typeof value === "object" && !Array.isArray(value));
1653}
1654const MISSING = { __yomuStorageValueMissing: true };
1655function isMissingSentinel(value) {
1656if (value === MISSING) return true;
1657return Boolean(value && typeof value === "object" && !Array.isArray(value) && value.__yomuStorageValueMissing === true);
1658}
1659async function rawAuthoritativeManagedStateEpoch(getValue) {
1660const stored = await getValue(MANAGED_STATE_EPOCH_KEY, MISSING);
1661return isMissingSentinel(stored) ? void 0 : stored;
1662}
1663async function authoritativeManagedStateEpoch(getValue) {
1664return parseManagedStateEpoch(await rawAuthoritativeManagedStateEpoch(getValue));
1665}
1666function managedStateStorageKey(key, epoch) {
1667if (epoch.generation === 0) return key;
1668return `${MANAGED_STATE_SLOT_KEY_PREFIX}${encodeURIComponent(managedStateEpochToken(epoch))}:${encodeURIComponent(key)}`;
1669}
1670async function readManagedGmValue(getValue, key, epoch) {
1671const storageKey = managedStateStorageKey(key, epoch);
1672const scoped = await getValue(storageKey, MISSING);
1673const readFromCurrentSlot = !isMissingSentinel(scoped);
1674const stored = readFromCurrentSlot || storageKey === key ? scoped : await getValue(key, MISSING);
1675if (isMissingSentinel(stored)) return { kind: "missing" };
1676const unreadable = Symbol("unreadable-managed-state");
1677const logical = managedStateLogicalValue(stored, epoch, unreadable);
1678if (logical === unreadable) return readFromCurrentSlot ? { kind: "deleted" } : { kind: "missing" };
1679if (isMissingSentinel(logical)) return { kind: "deleted" };
1680return { kind: "found", value: logical };
1681}
1682async function managedGmValue(getValue, key, fallback, epoch) {
1683const read = await readManagedGmValue(getValue, key, epoch);
1684return read.kind === "found" ? read.value : fallback;
1685}
1686const HOSTED_SETTINGS_BLOB_KEY = "jpdb-popup-reader-settings";
1687function isHostedSettingsStorageKey(key) {
1688return key === HOSTED_SETTINGS_BLOB_KEY;
1689}
1690function isHostedYomuLocation(origin, hostname, pathname) {
1691if (origin === DOCS_ORIGIN) return true;
1692if (isHostedGithubPagesLocation(hostname, pathname)) return true;
1693return isHostedLocalDevelopmentLocation(origin, pathname);
1694}
1695function isHostedYomuOrigin() {
1696try {
1697return isHostedYomuLocation(location.origin, location.hostname, location.pathname);
1698} catch {
1699return false;
1700}
1701}
1702function isHostedGithubPagesLocation(hostname, pathname) {
1703return hostname === "hrussellzfac023.github.io" && pathname.startsWith("/yomu-reader/");
1704}
1705function isHostedLocalDevelopmentLocation(origin, pathname) {
1706if (!isPrivilegedYomuLocalDevelopmentOrigin(origin)) return false;
1707return pathname.includes("/study/") || pathname.includes("/newtab/");
1708}
1709const OWNER_PREFIX = "yomu:web-owner:v2:";
1710let selectedOwner;
1711function selectOwner(owner) {
1712if (selectedOwner && selectedOwner !== owner) throw new Error("Managed web storage owner changed; reload to reconnect.");
1713selectedOwner = owner;
1714}
1715function ownedKey(key) {
1716return selectedOwner && selectedOwner !== "standalone" ? `${OWNER_PREFIX}${selectedOwner}:${key}` : key;
1717}
1718function belongsToOwner(key) {
1719return selectedOwner && selectedOwner !== "standalone" ? key.startsWith(`${OWNER_PREFIX}${selectedOwner}:`) : !key.startsWith(OWNER_PREFIX) && isManagedStorageKey(key);
1720}
1721const AREA_MARKER_KEYS = {
1722local: "yomu:web-storage-epoch:v1:local",
1723session: "yomu:web-storage-epoch:v1:session"
1724};
1725const PRESERVED_LOCAL_CONTROL_KEYS = new Set([
1726MANAGED_STATE_EPOCH_KEY,
1727AREA_MARKER_KEYS.local
1728]);
1729let certifiedEpoch;
1730let reconciliation;
1731let reconciliationToken;
1732function ensureManagedWebStorageEpochCurrent(epoch, owner = "standalone") {
1733selectOwner(owner);
1734if (certifiedEpoch) {
1735if (managedStateEpochToken(certifiedEpoch) !== managedStateEpochToken(epoch)) {
1736return Promise.reject(new Error("Managed web storage is already certified for another epoch."));
1737}
1738try {
1739assertAreaCertificate("local", epoch);
1740assertAreaCertificate("session", epoch);
1741return Promise.resolve(epoch);
1742} catch {
1743certifiedEpoch = void 0;
1744}
1745}
1746const expectedToken = managedStateEpochToken(epoch);
1747if (reconciliation) {
1748return reconciliationToken === expectedToken ? reconciliation : Promise.reject(new Error("Managed web storage is reconciling another epoch."));
1749}
1750reconciliationToken = expectedToken;
1751reconciliation = Promise.resolve().then(() => ensureManagedWebStorageEpochCurrentSync(epoch, owner)).finally(() => {
1752reconciliation = void 0;
1753reconciliationToken = void 0;
1754});
1755return reconciliation;
1756}
1757function ensureManagedWebStorageEpochCurrentSync(epoch, owner = "standalone") {
1758selectOwner(owner);
1759if (certifiedEpoch) {
1760if (managedStateEpochToken(certifiedEpoch) !== managedStateEpochToken(epoch)) {
1761throw new Error("Managed web storage is already certified for another epoch.");
1762}
1763try {
1764assertAreaCertificate("local", epoch);
1765assertAreaCertificate("session", epoch);
1766return epoch;
1767} catch {
1768certifiedEpoch = void 0;
1769}
1770}
1771reconcileArea("local", epoch);
1772reconcileArea("session", epoch);
1773certifiedEpoch = epoch;
1774return epoch;
1775}
1776function reconcileArea(area, epoch) {
1777const storage = storageArea(area);
1778const markerKey = ownedKey(AREA_MARKER_KEYS[area]);
1779const expectedToken = managedStateEpochToken(epoch);
1780const marker2 = readStorageValue(storage, markerKey, `${area}Storage epoch marker`);
1781if (marker2 === expectedToken) return;
1782if (marker2 !== null) {
1783const relation = managedStateEpochTokenRelation(marker2, epoch);
1784if (relation === "newer" || relation === "conflict" || relation === "malformed") {
1785throw new Error(`${area}Storage belongs to a newer or conflicting managed-state epoch.`);
1786}
1787}
1788if (epoch.generation > 0) purgeManagedArea(storage, area);
1789writeAndVerify(storage, markerKey, expectedToken, `${area}Storage epoch marker`);
1790}
1791function purgeManagedArea(storage, area) {
1792const logicalPreserved = area === "local" ? PRESERVED_LOCAL_CONTROL_KEYS : new Set([AREA_MARKER_KEYS.session]);
1793const preserved = new Set([...logicalPreserved].map(ownedKey));
1794const keys = enumerateStorageKeys(storage, `${area}Storage`);
1795const managedKeys = keys.filter((key) => belongsToOwner(key) && !preserved.has(key));
1796for (const key of managedKeys) {
1797removeStorageValue(storage, key, `${area}Storage key "${key}"`);
1798if (readStorageValue(storage, key, `${area}Storage key "${key}"`) !== null) {
1799throw new Error(`${area}Storage retained managed key "${key}".`);
1800}
1801}
1802const remaining = enumerateStorageKeys(storage, `${area}Storage`).filter((key) => belongsToOwner(key) && !preserved.has(key));
1803if (remaining.length) throw new Error(`${area}Storage retained managed keys: ${remaining.join(", ")}.`);
1804}
1805function enumerateStorageKeys(storage, label) {
1806let length;
1807try {
1808length = storage.length;
1809} catch (error) {
1810throw new Error(`${label} could not be enumerated.`, { cause: error });
1811}
1812if (!Number.isSafeInteger(length) || length < 0) throw new Error(`${label} reported an invalid length.`);
1813const keys = [];
1814const seen = new Set();
1815for (let index = 0; index < length; index++) {
1816let key;
1817try {
1818key = storage.key(index);
1819} catch (error) {
1820throw new Error(`${label} could not enumerate key ${index}.`, { cause: error });
1821}
1822if (key === null || seen.has(key)) throw new Error(`${label} enumeration was incomplete.`);
1823seen.add(key);
1824keys.push(key);
1825}
1826try {
1827if (storage.length !== length) throw new Error(`${label} changed during enumeration.`);
1828} catch (error) {
1829if (error instanceof Error && error.message.endsWith("changed during enumeration.")) throw error;
1830throw new Error(`${label} could not verify enumeration.`, { cause: error });
1831}
1832return keys;
1833}
1834const managedLocalStorage = managedStorageFacade("local");
1835const managedSessionStorage = managedStorageFacade("session");
1836function managedLocalStorageKeys() {
1837const { storage } = certifiedArea("local");
1838const prefix = ownedKey("");
1839return enumerateStorageKeys(storage, "localStorage").filter(belongsToOwner).map((key) => logicalManagedStorageKey(key.slice(prefix.length))).filter((key) => key !== null && managedLocalStorage.getItem(key) !== null);
1840}
1841function managedWebStorageResetKeys() {
1842return [...new Set([
1843...enumerateStorageKeys(storageArea("local"), "localStorage"),
1844...enumerateStorageKeys(storageArea("session"), "sessionStorage")
1845])].filter(isManagedStorageKey);
1846}
1847function managedStorageFacade(area) {
1848return {
1849getItem(key) {
1850const { storage, epoch } = certifiedArea(area);
1851const own = logicalItem(readStorageValue(storage, physicalStorageKey(key, epoch), `${area}Storage key "${key}"`), epoch);
1852if (own !== null) return own;
1853const earlier = earlierLocalRecordKey(area, key, epoch);
1854return earlier ? logicalItem(readStorageValue(storage, earlier, `localStorage key "${key}"`), epoch) : null;
1855},
1856setItem(key, value) {
1857assertManagedLogicalKey(key);
1858const { storage, epoch } = certifiedArea(area);
1859const stored = epoch.generation === 0 ? value : JSON.stringify(managedStateStoredValue(value, epoch));
1860writeAndVerify(storage, physicalStorageKey(key, epoch), stored, `${area}Storage key "${key}"`);
1861removeEarlierLocalRecord(area, key, epoch);
1862assertAreaCertificate(area, epoch);
1863},
1864removeItem(key) {
1865assertManagedLogicalKey(key);
1866const { storage, epoch } = certifiedArea(area);
1867removeVerified(storage, physicalStorageKey(key, epoch), `${area}Storage key "${key}"`, `${area}Storage retained managed key "${key}".`);
1868removeEarlierLocalRecord(area, key, epoch);
1869assertAreaCertificate(area, epoch);
1870}
1871};
1872}
1873function earlierLocalRecordKey(area, key, epoch) {
1874if (area !== "local" || !selectedOwner || selectedOwner === "standalone") return null;
1875const storage = storageArea("local");
1876const marker2 = readStorageValue(storage, AREA_MARKER_KEYS.local, "localStorage earlier epoch marker");
1877return marker2 === managedStateEpochToken(epoch) ? epochSlotKey(key, epoch) : null;
1878}
1879function removeEarlierLocalRecord(area, key, epoch) {
1880const earlier = isHostedYomuOrigin() ? null : earlierLocalRecordKey(area, key, epoch);
1881if (earlier) removeVerified(storageArea("local"), earlier, `localStorage key "${key}"`, `localStorage retained earlier managed key "${key}".`);
1882}
1883function removeVerified(storage, key, label, retained) {
1884removeStorageValue(storage, key, label);
1885if (readStorageValue(storage, key, label) !== null) throw new Error(retained);
1886}
1887function logicalItem(raw, epoch) {
1888if (raw === null || epoch.generation === 0) return raw;
1889try {
1890const unreadable = Symbol("unreadable-managed-web-storage");
1891const value = managedStateLogicalValue(JSON.parse(raw), epoch, unreadable);
1892return typeof value === "string" ? value : null;
1893} catch {
1894return null;
1895}
1896}
1897function certifiedArea(area) {
1898const epoch = certifiedEpoch;
1899if (!epoch) throw new Error("Managed web storage has not passed its epoch barrier.");
1900assertAreaCertificate(area, epoch);
1901return { storage: storageArea(area), epoch };
1902}
1903function assertAreaCertificate(area, epoch) {
1904const marker2 = readStorageValue(storageArea(area), ownedKey(AREA_MARKER_KEYS[area]), `${area}Storage epoch marker`);
1905if (marker2 !== managedStateEpochToken(epoch)) {
1906throw new Error(`${area}Storage is not certified for the captured managed-state epoch.`);
1907}
1908}
1909function physicalStorageKey(key, epoch) {
1910return ownedKey(epochSlotKey(key, epoch));
1911}
1912function epochSlotKey(key, epoch) {
1913assertManagedLogicalKey(key);
1914if (epoch.generation === 0) return key;
1915return `${MANAGED_WEB_STORAGE_SLOT_KEY_PREFIX}${encodeURIComponent(managedStateEpochToken(epoch))}:${encodeURIComponent(key)}`;
1916}
1917function assertManagedLogicalKey(key) {
1918if (!isManagedStorageKey(key) || isManagedStorageSlotKey(key)) {
1919throw new TypeError(`Managed web storage requires a logical Yomu key, received "${key}".`);
1920}
1921}
1922function storageArea(area) {
1923try {
1924const storage = area === "local" ? localStorage : sessionStorage;
1925if (!storage) throw new Error(`${area}Storage is unavailable.`);
1926return storage;
1927} catch (error) {
1928throw new Error(`${area}Storage is unavailable.`, { cause: error });
1929}
1930}
1931function readStorageValue(storage, key, label) {
1932try {
1933return storage.getItem(key);
1934} catch (error) {
1935throw new Error(`${label} could not be read.`, { cause: error });
1936}
1937}
1938function writeAndVerify(storage, key, value, label) {
1939try {
1940storage.setItem(key, value);
1941} catch (error) {
1942throw new Error(`${label} could not be written.`, { cause: error });
1943}
1944if (readStorageValue(storage, key, label) !== value) throw new Error(`${label} failed read-back verification.`);
1945}
1946function removeStorageValue(storage, key, label) {
1947try {
1948storage.removeItem(key);
1949} catch (error) {
1950throw new Error(`${label} could not be removed.`, { cause: error });
1951}
1952}
1953const MANAGED_STATE_EPOCH_LEASE_KEY_PREFIX = "yomu:state-epoch-lease:v1:";
1954const STORAGE_LEASE_KEY_PREFIX = "yomu:lease:";
1955const STORAGE_WORK_LEASE_MS = 5e3;
1956const DEFAULT_LEASE_MS = 6e4;
1957const LEASE_ROUND_TRIPS = 20;
1958const WAIT_NOTICE_MS = 1500;
1959class StorageLeaseLapsedError extends Error {
1960name = "StorageLeaseLapsedError";
1961constructor(key) {
1962super(`Storage lease lapsed before it was renewed: ${key}`);
1963}
1964}
1965function isStorageLeaseLapsed(error) {
1966return error instanceof Error && error.name === "StorageLeaseLapsedError";
1967}
1968const guardingLeases = new Set();
1969const realmClaimOwners = new Set();
1970const realmWebLockRequests = new Map();
1971function fenceStorageLeaseWrite(key) {
1972for (const lease of guardingLeases) if (lease.guards(key)) lease.fenceWrite();
1973}
1974async function withGmStorageLeaseCore(name, operation, options, environment) {
1975const wait = new StorageLeaseWait(options.onWait);
1976const boundedLockWait = !environment.hostedOrigin && storageLeaseIo(environment.backend);
1977const lockWaitMs = boundedLockWait ? options.leaseMs ?? DEFAULT_LEASE_MS : void 0;
1978try {
1979return await withWebStorageLock(name, () => withSharedStorageLease(name, () => {
1980wait.end();
1981return operation();
1982}, options, environment, wait), wait, lockWaitMs);
1983} finally {
1984wait.end();
1985}
1986}
1987class StorageLeaseWait {
1988constructor(onWait) {
1989this.onWait = onWait;
1990}
1991state = "running";
1992timer;
1993/** Another tab holds the lease, or is ahead in its queue. */
1994blocked() {
1995if (!this.onWait || this.state !== "running") return;
1996this.state = "blocked";
1997this.timer = setTimeout(() => {
1998this.state = "told";
1999this.onWait?.(true);
2000}, WAIT_NOTICE_MS);
2001}
2002/** The caller got past what blocked it; a wait it was not yet told about starts over. */
2003passed() {
2004if (this.state !== "blocked") return;
2005clearTimeout(this.timer);
2006this.state = "running";
2007}
2008end() {
2009clearTimeout(this.timer);
2010if (this.state === "told") this.onWait?.(false);
2011this.state = "ended";
2012}
2013}
2014async function withSharedStorageLease(name, operation, options, environment, wait) {
2015const { getValue } = environment.backend;
2016const epoch = await environment.captureEpoch(getValue);
2017await environment.assertMutationFence(getValue, epoch);
2018const io = storageLeaseIo(environment.backend);
2019if (!io) {
2020const result = await operation();
2021await environment.assertMutationFence(getValue, epoch);
2022return result;
2023}
2024const leaseMs = boundedLeaseOption(options.leaseMs, DEFAULT_LEASE_MS, 1e3, 10 * 6e4);
2025return new StorageLeaseClaimant({
2026guards: options.guards,
2027prefix: `${STORAGE_LEASE_KEY_PREFIX}${normalizedStorageLeaseName(name)}:`,
2028epoch: environment.epochToken(epoch),
2029io,
2030fence: () => environment.assertMutationFence(getValue, epoch),
2031leaseMs,
2032pollMs: boundedLeaseOption(options.pollMs, 20, 1, 1e3),
2033timeoutMs: boundedLeaseOption(options.timeoutMs, 9e4, leaseMs, 15 * 6e4),
2034timeoutMessage: `Timed out waiting for storage lease: ${name}`,
2035wait
2036}).run(operation);
2037}
2038function storageLeaseIo(backend) {
2039const { getValue, setValue, deleteValue, listValues } = backend;
2040return [getValue, setValue, deleteValue, listValues].every(Boolean) ? backend : null;
2041}
2042async function withManagedStateEpochControlLeaseCore(operation, environment) {
2043const { getValue, setValue, deleteValue, listValues } = environment.backend;
2044const available = [getValue, setValue, deleteValue, listValues].filter(
2044Boolean).length;
2045if (available === 0) return withWebStorageLock("managed-state-epoch-control", operation);
2046if (!getValue || !setValue || !deleteValue || !listValues) {
2047throw new Error("Managed storage cannot serialize epoch reconciliation without GM_listValues.");
2048}
2049return new StorageLeaseClaimant({
2050prefix: MANAGED_STATE_EPOCH_LEASE_KEY_PREFIX,
2051epoch: "epoch-control:v1",
2052io: { getValue, setValue, deleteValue, listValues },
2053fence: async () => void 0,
2054leaseMs: 3e4,
2055pollMs: 10,
2056timeoutMs: 9e4,
2057timeoutMessage: "Timed out waiting for the managed-state epoch lease."
2058}).run(operation);
2059}
2060class StorageLeaseClaimant {
2061constructor(lease) {
2062this.lease = lease;
2063const owner = createStorageCoordinationId();
2064this.key = `${lease.prefix}${owner}`;
2065this.promptLandingMs = Math.min(1e3, Math.floor(lease.leaseMs / 5));
2066this.claim = {
2067version: 1,
2068claimId: createStorageCoordinationId(),
2069owner,
2070epoch: lease.epoch,
2071choosing: true,
2072ticket: 0,
2073leaseUntil: 0
2074};
2075}
2076key;
2077startedAt = Date.now();
2078promptLandingMs;
2079claim;
2080liveUntil = 0;
2081roundTripMs = 0;
2082async run(operation) {
2083realmClaimOwners.add(this.claim.owner);
2084try {
2085await this.waitForTurn();
2086return await this.hold(operation);
2087} finally {
2088const { getValue, deleteValue } = this.lease.io;
2089try {
2090await deleteStorageLeaseClaimIfOwned(this.key, this.claim, getValue, deleteValue);
2091} catch (error) {
2092debugStorageLeaseError("GM storage lease release failed", this.key, error);
2093}
2094realmClaimOwners.delete(this.claim.owner);
2095}
2096}
2097/**
2098* A claim must outlast the storage round trips its holder makes before it
2099* renews, so slow storage stretches it by LEASE_ROUND_TRIPS of them, up to
2100* the default lease: slowness delays a save instead of lapsing it.
2101*/
2102leaseMs() {
2103const { leaseMs } = this.lease;
2104return Math.min(leaseMs + LEASE_ROUND_TRIPS * this.roundTripMs, Math.max(leaseMs, DEFAULT_LEASE_MS));
2105}
2106live() {
2107return Date.now() + Math.max(this.promptLandingMs, 2 * this.roundTripMs) < this.liveUntil;
2108}
2109async waitForTurn() {
2110const { lease } = this;
2111while (true) {
2112await lease.fence();
2113if (Date.now() - this.startedAt >= lease.timeoutMs) throw new Error(lease.timeoutMessage);
2114try {
2115if (!this.live()) await this.queue();
2116else if (this.liveUntil - Date.now() <= this.leaseMs() / 2) await this.writeClaim();
2117} catch (error) {
2118if (error instanceof StorageLeaseLapsedError) continue;
2119throw error;
2120}
2121const ahead = await this.claimsAhead();
2122if (!ahead.length && this.live()) return;
2123if (ahead.some((other) => !realmClaimOwners.has(other.owner))) lease.wait?.blocked();
2124await storageLeaseDelay(lease.pollMs);
2125}
2126}
2127async queue() {
2128const { prefix, epoch, io: { listValues, getValue } } = this.lease;
2129await this.writeClaim({ choosing: true, ticket: 0 }, true);
2130const claims = await readStorageLeaseClaims(prefix, listValues, getValue, epoch, Date.now());
2131const highestTicket = claims.reduce((highest, item) => Math.max(highest, item.ticket), 0);
2132await this.writeClaim({ choosing: false, ticket: highestTicket + 1 });
2133}
2134async claimsAhead() {
2135const { prefix, epoch, io: { listValues, getValue } } = this.lease;
2136const { owner, ticket } = this.claim;
2137const claims = await readStorageLeaseClaims(prefix, listValues, getValue, epoch, Date.now());
2138return claims.filter((other) => other.owner !== owner && (other.choosing || other.ticket < ticket || other.ticket === ticket && other.owner.localeCompare(owner) < 0));
2139}
2140/** `requeue` writes a new place in the queue instead of extending the live claim. */
2141async writeClaim(changes = {}, requeue = false) {
2142const { getValue, setValue } = this.lease.io;
2143if (!requeue && !this.live()) throw new StorageLeaseLapsedError(this.key);
2144const writtenAt = Date.now();
2145const next = { ...this.claim, ...changes, leaseUntil: writtenAt + this.leaseMs() };
2146this.claim = next;
2147try {
2148await setValue(this.key, next);
2149const landedAt = Date.now();
2150this.liveUntil = next.leaseUntil;
2151await assertStorageLeaseClaimOwned(this.key, next, getValue);
2152this.roundTripMs = storageRoundTripMs(landedAt - writtenAt, Date.now() - landedAt, this.lease.leaseMs);
2153} catch (error) {
2154await this.rollBack();
2155throw error;
2156}
2157}
2158/** Deletes the claim this realm wrote, unless another has taken its key. */
2159async rollBack() {
2160const { getValue, deleteValue } = this.lease.io;
2161await deleteStorageLeaseClaimIfOwned(this.key, this.claim, getValue, deleteValue).cat
2161ch((error) => {
2162debugStorageLeaseError("GM storage lease rollback failed", this.key, error);
2163});
2164}
2165/**
2166* A holder's renewal. A factory reset in another tab deletes every claim,
2167* then checks that none is left while this section may still be running:
2168* a renewal reads the claim first and never writes back one that is gone,
2169* and takes back one the reset deleted while the write was in flight, which
2170* the fence after it reveals. The renewal on entry skips the read: the
2171* waiter's last poll fenced a moment ago, and once storage turns slow it
2172* may enter with little of its claim left.
2173*/
2174async renew(due, entry) {
2175if (!entry) await assertStorageLeaseClaimOwned(this.key, this.claim, this.lease.io.getValue);
2176do
2177await this.writeClaim();
2178while (due());
2179await this.lease.fence().catch(async (error) => {
2180await this.rollBack();
2181throw error;
2182});
2183}
2184/**
2185* Runs the operation while a timer renews the claim and, for a lease with
2186* `guards`, while each guarded write renews it too: those writes reach
2187* storage over messaging, so throttled timers cannot starve them.
2188*/
2189async hold(operation) {
2190const { lease, key } = this;
2191const { getValue } = lease.io;
2192const renewEveryMs = Math.max(250, Math.floor(lease.leaseMs / 3));
2193let held = true;
2194let lost;
2195let refused = false;
2196let renewal;
2197const due = () => held && this.liveUntil - Date.now() <= this.leaseMs() * 5 / 6;
2198const renewIfDue = (entry = false) => {
2199if (lost || renewal || !due()) return;
2200renewal = this.renew(due, entry).catch((error) => {
2201lost ??= { error };
2202debugStorageLeaseError("GM storage lease renewal failed", key, error);
2203}).finally(() => {
2204renewal = void 0;
2205});
2206};
2207const guarding = lease.guards && {
2208guards: lease.guards,
2209fenceWrite: () => {
2210if (!lost && !this.live()) lost = { error: new StorageLeaseLapsedError(key) };
2211if (lost) {
2212refused = true;
2213throw lost.error;
2214}
2215renewIfDue();
2216}
2217};
2218if (guarding) guardingLeases.add(guarding);
2219const timer = setInterval(() => renewIfDue(), renewEveryMs);
2220renewIfDue(true);
2221let outcome;
2222try {
2223await lease.fence();
2224await assertStorageLeaseClaimOwned(key, this.claim, getValue);
2225const value = await operation();
2226await lease.fence();
2227await assertStorageLeaseClaimOwned(key, this.claim, getValue);
2228outcome = { value };
2229} catch (error) {
2230outcome = { error };
2231} finally {
2232held = false;
2233if (guarding) guardingLeases.delete(guarding);
2234clearInterval(timer);
2235await renewal;
2236}
2237if ("error" in outcome) throw outcome.error;
2238if (lost && (!guarding || refused)) throw lost.error;
2239return outcome.value;
2240}
2241}
2242async function readStorageLeaseClaims(prefix, listValues, getValue, epochToken, now) {
2243const keys = (await listValues()).filter((key) => key.startsWith(prefix));
2244const values = await Promise.all(keys.map((key) => getValue(key, null)));
2245return values.flatMap((value) => {
2246const claim = parseStorageLeaseClaim(value);
2247return claim && claim.epoch === epochToken && claim.leaseUntil > now ? [claim] : [];
2248});
2249}
2250function parseStorageLeaseClaim(value) {
2251if (!isPlainRecord$1(value) || value.version !== 1 || typeof value.owner !== "string" || value.claimId !== void 0 && typeof value.claimId !== "string" || value.epoch !== void 0 && typeof value.epoch !== "string" || typeof value.choosing !== "boolean" || !Number.isSafeInteger(value.ticket) || value.ticket < 0 || !Number.isSafeInteger(value.leaseUntil)) return null;
2252return {
2253version: 1,
2254claimId: value.claimId || value.owner,
2255owner: value.owner,
2256epoch: value.epoch || "0:legacy",
2257choosing: value.choosing,
2258ticket: value.ticket,
2259leaseUntil: value.leaseUntil
2260};
2261}
2262async function assertStorageLeaseClaimOwned(key, expected, getValue) {
2263const actual = parseStorageLeaseClaim(await getValue(key, null));
2264if (!actual || !sameStorageLeaseClaimIdentity(actual, expected)) {
2265throw new Error(`Storage lease ownership was lost: ${key}`);
2266}
2267}
2268async function deleteStorageLeaseClaimIfOwned(key, expected, getValue, deleteValue) {
2269const actual = parseStorageLeaseClaim(await getValue(key, null));
2270if (actual && sameStorageLeaseClaimIdentity(actual, expected)) await deleteValue(key);
2271}
2272function sameStorageLeaseClaimIdentity(left, right) {
2273return left.claimId === right.claimId && left.owner === right.owner && left.epoch === right.epoch;
2274}
2275function normalizedStorageLeaseName(name) {
2276const normalized = name.trim().replaceAll(/[^a-z0-9._-]+/giu, "-").slice(0, 80);
2277if (!normalized) throw new TypeError("Storage lease name is required.");
2278return normalized;
2279}
2280function boundedLeaseOption(value, fallback, minimum, maximum) {
2281if (value === void 0) return fallback;
2282if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new TypeError("Invalid storage lease option.");
2283return value;
2284}
2285function storageLeaseDelay(milliseconds) {
2286return new Promise((resolve) => setTimeout(resolve, milliseconds));
2287}
2288function storageRoundTripMs(writeMs, readMs, leaseMs) {
2289const slower = Math.max(writeMs, readMs);
2290return slower <= leaseMs / 4 ? slower : Math.min(writeMs, readMs);
2291}
2292function webLockManager() {
2293return typeof navigator === "undefined" ? void 0 : navigator.locks;
2294}
2295async function withWebStorageLock(name, operation, wait, waitMs) {
2296const lockManager = webLockManager();
2297if (!lockManager) return operation();
2298const lockName = `yomu:${normalizedStorageLeaseName(name)}`;
2299if (!countRealmWebLockRequest(lockName, 1)) wait?.blocked();
2300try {
2301return await requestWebLock(lockManager, lockName, () => {
2302wait?.passed();
2303return operation();
2304}, waitMs);
2305} finally {
2306countRealmWebLockRequest(lockName, -1);
2307}
2308}
2309function countRealmWebLockRequest(lockName, change) {
2310const before = realmWebLockRequests.get(lockName) ?? 0;
2311if (before + change > 0) realmWebLockRequests.set(lockName, before + change);
2312else realmWebLockRequests.delete(lockName);
2313return before;
2314}
2315async function requestWebLock(locks, name, operation, waitMs) {
2316if (waitMs === void 0) return locks.request(name, {}, operation);
2317const giveUp = new AbortController();
2318const timer = setTimeout(() => giveUp.abort(), waitMs);
2319let granted = false;
2320try {
2321return await locks.request(name, { signal: giveUp.signal }, () => {
2322granted = true;
2323clearTimeout(timer);
2324return operation();
2325});
2326} catch (error) {
2327if (granted) throw error;
2328return operation();
2329} finally {
2330clearTimeout(timer);
2331}
2332}
2333function isPlainRecord$1(value) {
2334return Boolean(value && typeof value === "object" && !Array.isArray(value));
2335}
2336function createStorageCoordinationId() {
2337return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
2338}
2339function debugStorageLeaseError(message, key, error) {
2340if (typeof console !== "undefined") console.debug("[Yomu] Storage", message, { key, error });
2341}
2342const EXCLUDED_BACKUP_STORAGE_KEYS = new Set([
2343"yomu:factory-reset-signal",
2344MANAGED_STATE_EPOCH_KEY,
2345"yomu:local-storage-provenance:v1",
2346"__yomu_cloud_settings_sync_pending_action"
2347]);
2348function isManagedStorageBackupKey(key) {
2349return isManagedStorageKey(key) && !isPrivateManagedStorageKey(key) && !isManagedStorageSlotKey(key) && !key.startsWith(MANAGED_STATE_EPOCH_LEASE_KEY_PREFIX) && !key.startsWith(STORAGE_LEASE_KEY_PREFIX) && !EXCLUDED_BACKUP_STORAGE_KEYS.has(key);
2350}
2351function localStorageGet(key, fallback) {
2352try {
2353const value = localStorage.getItem(key);
2354return value == null ? fallback : JSON.parse(value);
2355} catch {
2356return fallback;
2357}
2358}
2359function localStorageSet(key, value) {
2360try {
2361localStorage.setItem(key, JSON.stringify(value));
2362} catch {
2363}
2364}
2365function removeLocalStorageKey(key) {
2366try {
2367localStorage.removeItem(key);
2368} catch {
2369}
2370}
2371function removeSessionStorageKey(key) {
2372try {
2373sessionStorage.removeItem(key);
2374} catch {
2375}
2376}
2377function webStorageHasKey(storage, key) {
2378try {
2379return storage.getItem(key) !== null;
2380} catch {
2381return false;
2382}
2383}
2384function localStorageSetOrThrow(key, value) {
2385try {
2386const serialized = JSON.stringify(value);
2387if (serialized === void 0) throw new Error("value is not JSON-serializable");
2388localStorage.setItem(key, serialized);
2389if (localStorage.getItem(key) !== serialized) throw new Error("read-back did not match");
2390return serialized;
2391} catch (error) {
2392throw storageWriteError(key, "localStorage write failed", error);
2393}
2394}
2395function storageWriteError(key, message, ...causes) {
2396const details = causes.map((cause) => cause instanceof Error ? cause.message : String(cause)).filter(Boolean).join("; ");
2397return new Error(`${message} for "${key}"${details ? `: ${details}` : ""}`);
2398}
2399const SETTINGS_STORAGE_KEY$1 = "jpdb-popup-reader-settings";
2400const RETIRED_SETTINGS_STORAGE_KEYS = [
2401"jpdb-reader-settings",
2402"yomu-reader-settings",
2403"yomu-settings",
2404"yomu:explicit-user-settings:v1"
2405];
2406const SETTINGS_INTENT_LEDGER_STORAGE_KEY$1 = "yomu:settings-intent:v2";
2407const PREFERRED_JAPANESE_SITE_LANGUAGE_STORAGE_KEY$1 = "yomu:prefer-japanese-site-language:v1";
2408const PREFERRED_JAPANESE_SITE_LANGUAGE_CACHE_KEY = "yomu:prefer-japanese-site-language";
2409const SETTINGS_AUTHORITY_STORAGE_KEYS = new Set([
2410SETTINGS_STORAGE_KEY$1,
2411...RETIRED_SETTINGS_STORAGE_KEYS,
2412SETTINGS_INTENT_LEDGER_STORAGE_KEY$1,
2413PREFERRED_JAPANESE_SITE_LANGUAGE_STORAGE_KEY$1,
2414PREFERRED_JAPANESE_SITE_LANGUAGE_CACHE_KEY
2415]);
2416function isSettingsAuthorityStorageKey(key) {
2417return SETTINGS_AUTHORITY_STORAGE_KEYS.has(key);
2418}
2419const HOSTED_LOCAL_SETTINGS_KEYS = [
2420"showFurigana",
2421"furiganaMode",
2422"showPitchAccent",
2423"wordUnderlineColorSource",
2424"subtitlePlayerEnabled",
2425"subtitleAutoDetect",
2426"subtitleOverlayVisible",
2427"subtitleControlsMode",
2428"subtitleTranscriptVisible",
2429"ocrEnabled",
2430"ocrVideoPauseFrames",
2431"ocrProvider",
2432"ocrOverlayTheme",
2433"preferJapaneseSiteLanguage"
2434];
2435const HOSTED_DEMO_READER_SETTINGS = {
2436showFurigana: true,
2437furiganaMode: "all",
2438showPitchAccent: true,
2439wordUnderlineColorSource: "pitch",
2440subtitlePlayerEnabled: true,
2441subtitleAutoDetect: true,
2442subtitleOverlayVisible: true,
2443subtitleControlsMode: "always",
2444subtitleTranscriptVisible: false,
2445ocrEnabled: true,
2446ocrVideoPauseFrames: true,
2447ocrProvider: "google-lens",
2448ocrOverlayTheme: "auto",
2449preferJapaneseSiteLanguage: false
2450};
2451const ACADEMY_READER_DEFAULTS = {
2452showFurigana: true,
2453furiganaMode: "all",
2454showPitchAccent: true
2455};
2456const HOSTED_APPEARANCE_CHOICES = {
2457interfaceLanguage: new Set(["auto", "en", "ja"]),
2458theme: new Set(["auto", "dark", "light"])
2459};
2460const HOSTED_ACCENT_COLOR_RE = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/iu;
2461function isPassiveHostedSettingsRecord(record2) {
2462const entries2 = Object.entries(record2).filter(([key, value]) => key !== "learningTargetChosen" || value !== false);
2463return entries2.every(isHostedAppearanceEntry) || extendsHostedPolicy(record2, entries2, ACADEMY_READER_DEFAULTS) || extendsHostedPolicy(record2, entries2, HOSTED_DEMO_READER_SETTINGS);
2464}
2465function isPassiveHostedSettingsJson(serialized) {
2466let record2;
2467try {
2468record2 = JSON.parse(serialized);
2469} catch {
2470return false;
2471}
2472return typeof record2 === "object" && record2 !== null && !Array.isArray(record2) && isPassiveHostedSettingsRecord(record2);
2473}
2474function extendsHostedPolicy(record2, entries2, policy2) {
2475return Object.entries(policy2).every(([key, value]) => record2[key] === value) && entries2.every((entry) => Object.hasOwn(policy2, entry[0]) || isHostedAppearanceEntry(entry));
2476}
2477function isHostedAppearanceEntry([key, value]) {
2478if (key === "accentColor") return typeof value === "string" && HOSTED_ACCENT_COLOR_RE.test(value);
2479return Object.hasOwn(HOSTED_APPEARANCE_CHOICES, key) && HOSTED_APPEARANCE_CHOICES[key].has(value);
2480}
2481function isRecord$2(value) {
2482return typeof value === "object" && value !== null && !Array.isArray(value);
2483}
2484function isNonNullObject(value) {
2485return typeof value === "object" && value !== null;
2486}
2487const PROVENANCE_KEY = "yomu:local-storage-provenance:v1";
2488const JAPANESE_SITE_LANGUAGE_KEY = "yomu:prefer-japanese-site-language:v1";
2489function captureLocalFallbackStoredState(key) {
2490try {
2491const serializedValue = localStorage.getItem(key);
2492const entry = provenanceValues()[key];
2493return {
2494serializedValue,
2495provenance: entry ? { ...entry } : null
2496};
2497} catch (error) {
2498throw storageWriteError(key, "Local fallback read failed", error);
2499}
2500}
2501function localFallbackStoredStatesMatch(left, right) {
2502return Boolean(right && left.serializedValue === right.serializedValue && provenanceEntriesMatch(left.provenance, right.provenance));
2503}
2504function provenanceEntriesMatch(left, right) {
2505return left === null ? right === null : right !== null && left.epoch === right.epoch && left.fingerprint === right.fingerprint;
2506}
2507function restoreLocalFallbackStoredState(key, state) {
2508try {
2509if (state.serializedValue === null) localStorage.removeItem(key);
2510else localStorage.setItem(key, state.serializedValue);
2511if (localStorage.getItem(key) !== state.serializedValue) throw new Error("read-back did not match");
2512} catch (error) {
2513throw storageWriteError(key, "Local fallback restore failed", error);
2514}
2515updateProvenance(key, state.provenance, true);
2516}
2517function writeLocalManagedValueOrThrow(key, value, epoch) {
2518const previous = captureLocalFallbackStoredState(key);
2519try {
2520const serialized = localStorageSetOrThrow(key, value);
2521updateProvenance(key, {
2522epoch: managedStateEpochToken(epoch),
2523fingerprint: fingerprint(serialized)
2524}, true);
2525} catch (error) {
2526try {
2527restoreLocalFallbackStoredState(key, previous);
2528} catch (rollbackError) {
2529throw new AggregateError(
2530[error, rollbackError],
2531`Local fallback publication and rollback failed for "${key}".`
2532);
2533}
2534throw error;
2535}
2536}
2537function removeLocalManagedValue(key) {
2538removeLocalStorageKey(key);
2539removeSessionStorageKey(key);
2540updateProvenance(key, null);
2541}
2542function restoreLocalFallbackStoredValueAtEpoch(key, value, existed, epoch) {
2543if (!existed) return removeLocalManagedValue(key);
2544if (!epoch) throw storageWriteError(key, "Managed storage cannot restore its localStorage fallback");
2545writeLocalManagedValueOrThrow(key, value, epoch);
2546}
2547function localMirrorBelongsToEpoch(key, epoch) {
2548const serialized = recoverableSerializedValue(key);
2549if (serialized === null) return false;
2550const entry = provenanceValues()[key];
2551return entry ? entry.epoch === managedStateEpochToken(epoch) && entry.fingerprint === fingerprint(serialized) : epoch.generation === 0;
2552}
2553function localMirrorEpochMatches(key, epoch, rawWriterRecord) {
2554const serialized = recoverableSerializedValue(key);
2555if (serialized === null) return false;
2556const entry = provenanceValues()[key];
2557if (entry) return entry.epoch === managedStateEpochToken(epoch);
2558return epoch.generation === 0 || rawWriterRecord(serialized);
2559}
2560function removeLocalMirrorProvenance(key) {
2561updateProvenance(key, null);
2562}
2563function updateProvenance(key, entry, strict = false) {
2564const values = provenanceValues();
2565if (!mutateProvenance(values, key, entry)) return;
2566persistProvenance(values, strict);
2567}
2568function mutateProvenance(values, key, entry) {
2569if (entry) {
2570values[key] = entry;
2571return true;
2572}
2573if (!(key in values)) return false;
2574delete values[key];
2575return true;
2576}
2577function persistProvenance(values, strict) {
2578if (Object.keys(values).length) {
2579const value = { version: 1, values };
2580if (strict) localStorageSetOrThrow(PROVENANCE_KEY, value);
2581else localStorageSet(PROVENANCE_KEY, value);
2582return;
2583}
2584removeLocalStorageKey(PROVENANCE_KEY);
2585}
2586function provenanceValues() {
2587const stored = localStorageGet(PROVENANCE_KEY, null);
2588if (!isStoredProvenance(stored)) return {};
2589const values = {};
2590for (const [key, value] of Object.entries(stored.values)) {
2591const normalized = normalizedEntry(value);
2592if (normalized) values[key] = normalized;
2593}
2594return values;
2595}
2596function isStoredProvenance(value) {
2597if (!isRecord$2(value) || value.version !== 1) return false;
2598return isRecord$2(value.values);
2599}
2600function normalizedEntry(value) {
2601if (!isRecord$2(value)) return null;
2602if (typeof value.epoch !== "string") return null;
2603if (typeof value.fingerprint !== "string") return null;
2604return { epoch: value.epoch, fingerprint: value.fingerprint };
2605}
2606function recoverableSerializedValue(key) {
2607if (key === JAPANESE_SITE_LANGUAGE_KEY) return null;
2608try {
2609return localStorage.getItem(key);
2610} catch {
2611return null;
2612}
2613}
2614function fingerprint(serialized) {
2615let hash = 2166136261;
2616for (let index = 0; index < serialized.length; index++) {
2617hash = Math.imul(hash ^ serialized.charCodeAt(index), 16777619);
2618}
2619return `${serialized.length}:${(hash >>> 0).toString(16).padStart(8, "0")}`;
2620}
2621const SETTINGS_KEY = "jpdb-popup-reader-settings";
2622const INTENT_LEDGER_KEY = "yomu:settings-intent:v2";
2623const LOCAL_SRS_V2_INDEX_KEY = "yomu:srs-local:v2:index";
2624const LAST_KEYS = [LOCAL_SRS_V2_INDEX_KEY, "yomu:prefer-japanese-site-language:v1", INTENT_LEDGER_KEY, SETTINGS_KEY];
2625const COORDINATION_FIELDS = ["__yomuSettingsPersistenceTransactionV1", "__yomuSettingsPersistenceCommitV1"];
2626const HOSTED_PATCH_FIELD = "__yomuHostedPendingGmPatch";
2627const OWNER_NAMESPACE_PREFIX = "yomu:web-owner:v2:";
2628let adoption;
2629function websiteOnlyValuePresent(key, epoch) {
2630return isHostedYomuOrigin() && isWebsiteStoreKey(key) && localMirrorBelongsToEpoch(key, epoch);
2631}
2632function adoptWebsiteOnlyStore(getValue, epoch, write) {
2633const token = managedStateEpochToken(epoch);
2634if (adoption?.token === token) return adoption.done;
2635const done = runAdoption(getValue, epoch, write).finally(() => {
2636if (adoption?.done === done) adoption = void 0;
2637});
2638adoption = { token, done };
2639return done;
2640}
2641async function runAdoption(getValue, epoch, write) {
2642if (!isHostedYomuOrigin()) return;
2643const installed = await readManagedGmValue(getValue, SETTINGS_KEY, epoch);
2644if (installed.kind === "found" && !saysNoTargetChosen(installed.value)) return;
2645const settingsUnitAbsent = installed.kind === "missing" && (await readManagedGmValue(getValue, INTENT_LEDGER_KEY, epoch)).kind === "missing";
2646for (const key of websiteOnlyKeys(epoch)) {
2647if (isSettingsAuthorityStorageKey(key) && !settingsUnitAbsent) continue;
2648if ((await readManagedGmValue(getValue, key, epoch)).kind !== "missing") continue;
2649const value = localStorageGet(key, MISSING);
2650if (!isMissingSentinel(value)) await write(key, adoptedValue(key, value));
2651}
2652}
2653function websiteOnlyKeys(epoch) {
2654const keys = [];
2655for (let index = 0; index < localStorage.length; index++) {
2656const key = localStorage.key(index);
2657if (key && websiteOnlyValuePresent(key, epoch)) keys.push(key);
2658}
2659const rank = (key) => LAST_KEYS.indexOf(key);
2660return keys.sort((left, right) => rank(left) - rank(right) || left.localeCompare(right));
2661}
2662function isWebsiteStoreKey(key) {
2663return isManagedStorageBackupKey(key) && !key.startsWith(OWNER_NAMESPACE_PREFIX) && !RETIRED_SETTINGS_STORAGE_KEYS.includes(key) && managedStateEntries().some((entry) => entry.kind === "gm" && (entry.key === key || entry.prefix !== void 0 && key.startsWith(entry.prefix)));
2664}
2665function adoptedValue(key, value) {
2666if (!isRecord$2(value)) return value;
2667const record2 = withoutFields(value, COORDINATION_FIELDS);
2668if (key === SETTINGS_KEY) return withoutFields(record2, [HOSTED_PATCH_FIELD, ...HOSTED_LOCAL_SETTINGS_KEYS]);
2669if (key === INTENT_LEDGER_KEY && isRecord$2(record2.records)) {
2670return { ...record2, records: withoutFields(record2.records, HOSTED_LOCAL_SETTINGS_KEYS) };
2671}
2672return record2;
2673}
2674function withoutFields(record2, fields) {
2675const copy = { ...record2 };
2676for (const field of fields) delete copy[field];
2677return copy;
2678}
2679function saysNoTargetChosen(settings2) {
2680return isRecord$2(settings2) && settings2.learningTargetChosen === false;
2681}
2682class ConcreteManagedWriteJournal {
2683constructor(storage, preserveLocalFallbackOnWriteFailure) {
2684this.storage = storage;
2685this.preserveLocalFallbackOnWriteFailure = preserveLocalFallbackOnWriteFailure;
2686}
2687receipts = new Map();
2688active = [];
2689open = true;
2690async capture(key) {
2691const existing = this.receipts.get(key);
2692if (existing) return existing;
2693const previous = await this.storage.readAuthority(key);
2694const observedLocal = shouldTrackExactLocalFallback(key) ? captureLocalFallbackStoredState(key) : void 0;
2695const receipt = {
2696key,
2697previous,
2698authorityTarget: previous,
2699localTarget: observedLocal ? previous : { existed: false, value: null },
2700localBefore: observedLocal,
2701stagedAuthority: [],
2702stagedLocal: [],
2703interrupted: false,
2704restoreCapturedLocal: true,
2705active: false
2706};
2707this.receipts.set(key, receipt);
2708return receipt;
2709}
2710adoptInterrupted(receipt, previous, localPrevious = previous) {
2711const record2 = receipt;
2712record2.authorityTarget = previous;
2713record2.localTarget = localPrevious;
2714record2.interrupted = true;
2715record2.restoreCapturedLocal = Boolean(record2.localBefore && record2.localBefore.serializedValue !== (record2.previous.existed ? JSON.stringify(rec
2715ord2.previous.value) : null));
2716if (record2.localBefore) rememberLocalStage(record2, record2.localBefore, record2.previous);
2717this.activate(record2);
2718}
2719async write(receipt, value) {
2720const record2 = receipt;
2721this.activate(record2);
2722const attempted = { existed: true, value };
2723record2.stagedAuthority.push(value);
2724try {
2725await this.storage.writeAuthority(
2726record2.key,
2727value,
2728this.preserveLocalFallbackOnWriteFailure
2729);
2730} finally {
2731if (record2.localBefore) {
2732rememberLocalStage(record2, captureLocalFallbackStoredState(record2.key), attempted);
2733}
2734}
2735}
2736async restore(receipt, message) {
2737const record2 = receipt;
2738this.activate(record2);
2739const errors = await rollbackManagedWrite(
2740this.storage,
2741record2,
2742this.preserveLocalFallbackOnWriteFailure
2743);
2744if (errors.length) throw new AggregateError(errors, message);
2745}
2746commit() {
2747this.open = false;
2748}
2749async rollback(message, stopOnError = false) {
2750if (!this.open) return;
2751this.open = false;
2752const errors = await rollbackManagedWrites(
2753this.storage,
2754this.active,
2755stopOnError,
2756this.preserveLocalFallbackOnWriteFailure
2757);
2758if (errors.length) throw new AggregateError(errors, `${message} for ${errors.length} operation(s).`);
2759}
2760async reject(error, message, stopOnError = false) {
2761if (!this.open) throw error;
2762this.open = false;
2763const rollbackErrors = await rollbackManagedWrites(
2764this.storage,
2765this.active,
2766stopOnError,
2767this.preserveLocalFallbackOnWriteFailure
2768);
2769if (!rollbackErrors.length) throw error;
2770throw new AggregateError(
2771[error, ...rollbackErrors],
2772`${message} and ${rollbackErrors.length} rollback operation(s) also failed.`
2773);
2774}
2775activate(receipt) {
2776if (!this.open) throw new Error("Managed storage write journal is already closed.");
2777if (receipt.active) return;
2778receipt.active = true;
2779this.active.push(receipt);
2780}
2781}
2782function createConcreteManagedWriteJournal(storage, preserveLocalFallbackOnWriteFailure = false) {
2783return new ConcreteManagedWriteJournal(storage, preserveLocalFallbackOnWriteFailure);
2784}
2785async function rollbackManagedWrites(storage, receipts, stopOnError, forceAuthorityRestore) {
2786const errors = [];
2787let authorityStopped = false;
2788for (let index = receipts.length - 1; index >= 0; index--) {
2789if (authorityStopped) {
2790const receipt = receipts[index];
2791if (receipt.restoreCapturedLocal) {
2792const local = captureManagedWriteLocal(receipt, errors);
2793rollbackManagedWriteLocal(storage, receipt, local, errors);
2794}
2795continue;
2796}
2797const current = await rollbackManagedWrite(storage, receipts[index], forceAuthorityRestore);
2798errors.push(...current);
2799if (stopOnError && current.length) authorityStopped = true;
2800}
2801return errors;
2802}
2803async function rollbackManagedWrite(storage, receipt, forceAuthorityRestore) {
2804const errors = [];
2805await rollbackManagedWriteAuthority(storage, receipt, forceAuthorityRestore, errors);
2806const currentLocal = captureManagedWriteLocal(receipt, errors);
2807rollbackManagedWriteLocal(storage, receipt, currentLocal, errors);
2808return errors;
2809}
2810function captureManagedWriteLocal(receipt, errors) {
2811if (!receipt.localBefore) return void 0;
2812try {
2813return captureLocalFallbackStoredState(receipt.key);
2814} catch (error) {
2815errors.push(error);
2816return void 0;
2817}
2818}
2819async function rollbackManagedWriteAuthority(storage, receipt, forceAuthorityRestore, errors) {
2820const current = await readRollbackAuthority(storage, receipt.key, errors);
2821if (!current) return;
2822if (!authorityRestoreIsRequired(receipt, current, forceAuthorityRestore)) return;
2823if (!authorityRollbackIsSafe(receipt, current)) {
2824errors.push(managedWriteConflict(receipt.key, "Managed storage value"));
2825return;
2826}
2827await restoreRollbackAuthority(storage, receipt, errors);
2828}
2829function authorityRestoreIsRequired(receipt, current, forceAuthorityRestore) {
2830return forceAuthorityRestore || !managedStoredValueStatesMatch(current, receipt.authorityTarget);
2831}
2832function authorityRollbackIsSafe(receipt, current) {
2833return managedStoredValueStatesMatch(current, receipt.authorityTarget) || authorityWasStaged(receipt, current);
2834}
2835async function readRollbackAuthority(storage, key, errors) {
2836try {
2837return await storage.readAuthority(key);
2838} catch (error) {
2839errors.push(error);
2840return void 0;
2841}
2842}
2843async function restoreRollbackAuthority(storage, receipt, errors) {
2844try {
2845await storage.restoreAuthority(receipt.key, receipt.authorityTarget);
2846} catch (error) {
2847errors.push(error);
2848}
2849}
2850function rollbackManagedWriteLocal(storage, receipt, current, errors) {
2851try {
2852restoreManagedWriteLocal(storage, receipt, current);
2853} catch (error) {
2854errors.push(error);
2855}
2856}
2857function restoreManagedWriteLocal(storage, receipt, current) {
2858if (!receipt.localBefore) return restoreUntrackedWriteLocal(storage, receipt);
2859if (!current) return;
2860assertLocalStateCanRollback(storage, receipt, current);
2861if (receipt.restoreCapturedLocal) return restoreLocalFallbackStoredState(receipt.key, receipt.localBefore);
2862storage.restoreLocalTarget(receipt.key, receipt.localTarget);
2863}
2864function restoreUntrackedWriteLocal(storage, receipt) {
2865if (!receipt.interrupted) storage.restoreLocalTarget(receipt.key, receipt.localTarget);
2866}
2867function assertLocalStateCanRollback(storage, receipt, current) {
2868if (localStateCanRollback(storage, receipt, current)) return;
2869throw managedWriteConflict(receipt.key, "Local fallback value");
2870}
2871function localStateCanRollback(storage, receipt, current) {
2872if (localStateWasCaptured(receipt, current)) return true;
2873return receipt.interrupted && managedStoredValueStatesMatch(storage.readLocalTarget(receipt.key), receipt.localTarget);
2874}
2875function localStateWasCaptured(receipt, current) {
2876return Boolean(receipt.localBefore && localFallbackStoredStatesMatch(current, receipt.localBefore)) || receipt.stagedLocal.some((state) => localFallbackStoredStatesMatch(current, state));
2877}
2878function authorityWasStaged(receipt, current) {
2879if (managedStoredValueStatesMatch(current, receipt.previous)) return true;
2880return current.existed && receipt.stagedAuthority.some((value) => managedStoredValuesMatch(current.value, value));
2881}
2882function rememberLocalStage(receipt, local, authority) {
2883if (!rawLocalStateRepresentsAuthority(local, authority)) return;
2884if (!receipt.stagedLocal.some((item) => localFallbackStoredStatesMatch(item, local))) {
2885receipt.stagedLocal.push(local);
2886}
2887}
2888function rawLocalStateRepresentsAuthority(local, authority) {
2889if (!authority.existed) return local.serializedValue === null;
2890if (local.serializedValue === null) return false;
2891try {
2892return managedStoredValuesMatch(JSON.parse(local.serializedValue), authority.value);
2893} catch {
2894return false;
2895}
2896}
2897function shouldTrackExactLocalFallback(key) {
2898return !isSettingsAuthorityStorageKey(key) || isHostedYomuOrigin();
2899}
2900function managedStoredValueStatesMatch(left, right) {
2901return left.existed === right.existed && (!left.existed || managedStoredValuesMatch(left.value, right.value));
2902}
2903function managedStoredValuesMatch(left, right) {
2904if (Object.is(left, right)) return true;
2905try {
2906return JSON.stringify(left) === JSON.stringify(right);
2907} catch {
2908return false;
2909}
2910}
2911function managedWriteConflict(key, label) {
2912return new Error(`${label} "${key}" changed after staging; rollback left the newer value intact.`);
2913}
2914function managedStorageOwner() {
2915return detectInstalledReaderRuntime() ?? userscriptGmStorageOwnerKind() ?? "standalone";
2916}
2917function asyncGmGetValue() {
2918if (packagedExtensionStorageAdapterMissing()) return null;
2919const direct = directGmGetValue();
2920if (direct) return direct;
2921const bridge = getUserscriptGmStorage();
2922return bridge ? (key, fallback) => bridge.getValue(key, fallback) : null;
2923}
2924function directGmGetValue() {
2925if (packagedExtensionStorageAdapterMissing()) return null;
2926return modernGmGetValue() ?? legacyGmGetValue() ?? rawExtensionStorageGetValue();
2927}
2928function legacyGmGetValue() {
2929return typeof GM_getValue === "function" ? GM_getValue : null;
2930}
2931function modernGmGetValue() {
2932const modern = globalThis.GM?.getValue;
2933return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
2934}
2935function asyncGmSetValue() {
2936if (packagedExtensionStorageAdapterMissing()) return null;
2937const direct = directGmSetValue();
2938if (direct) return direct;
2939if (directGmGetValue()) return null;
2940return bridgeGmSetValue();
2941}
2942function directGmSetValue() {
2943if (packagedExtensionStorageAdapterMissing()) return null;
2944return legacyGmSetValue() ?? modernGmSetValue() ?? extensionGmSetValue();
2945}
2946function legacyGmSetValue() {
2947return typeof GM_setValue === "function" ? GM_setValue : null;
2948}
2949function modernGmSetValue() {
2950const modern = globalThis.GM?.setValue;
2951return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
2952}
2953function extensionGmSetValue() {
2954const extension = extensionStorageArea();
2955return extension ? (key, value) => extension.set({ [key]: value }) : null;
2956}
2957function bridgeGmSetValue() {
2958const bridge = getUserscriptGmStorage();
2959return bridge ? (key, value) => bridge.setValue(key, value) : null;
2960}
2961function asyncGmDeleteValue() {
2962if (packagedExtensionStorageAdapterMissing()) return null;
2963const direct = directGmDeleteValue();
2964if (direct) return direct;
2965if (directGmGetValue()) return null;
2966return bridgeGmDeleteValue();
2967}
2968function directGmDeleteValue() {
2969if (packagedExtensionStorageAdapterMissing()) return null;
2970return legacyGmDeleteValue() ?? modernGmDeleteValue() ?? extensionGmDeleteValue();
2971}
2972function legacyGmDeleteValue() {
2973return typeof GM_deleteValue === "function" ? GM_deleteValue : null;
2974}
2975function modernGmDeleteValue() {
2976const modern = globalThis.GM?.deleteValue;
2977return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
2978}
2979function extensionGmDeleteValue() {
2980const extension = extensionStorageArea();
2981return extension ? (key) => extension.remove(key) : null;
2982}
2983function bridgeGmDeleteValue() {
2984const bridge = getUserscriptGmStorage();
2985return bridge ? (key) => bridge.deleteValue(key) : null;
2986}
2987function asyncGmListValues() {
2988if (packagedExtensionStorageAdapterMissing()) return null;
2989const direct = directGmListValues();
2990if (direct) return direct;
2991if (directGmGetValue()) return null;
2992return bridgeGmListValues();
2993}
2994function directGmListValues() {
2995return modernGmListValues() ?? legacyGmListValues() ?? extensionGmListValues();
2996}
2997function legacyGmListValues() {
2998if (typeof GM_listValues === "function") return GM_listValues;
2999const directListValues = globalThis.GM_listValues;
3000return typeof directListValues === "function" ? directListValues : null;
3001}
3002function modernGmListValues() {
3003const modern = globalThis.GM?.listValues;
3004return typeof modern === "function" ? modern.bind(globalThis.GM) : null;
3005}
3006function extensionGmListValues() {
3007const extension = extensionStorageArea();
3008if (!extension) return null;
3009return async () => extension.getKeys ? extension.getKeys() : Object.keys(await extension.get(null));
3010}
3011function bridgeGmListValues() {
3012const bridge = getUserscriptGmStorage();
3013return bridge ? () => bridge.listValues() : null;
3014}
3015function extensionStorageArea() {
3016return extensionCapability((extension) => extension.storage?.local);
3017}
3018function extensionCapability(select) {
3019const candidate = globalThis;
3020return activeExtensionCapability(candidate.browser, select) ?? activeExtensionCapability(candidate.chrome, select) ?? null;
3021}
3022function activeExtensionCapability(extension, select) {
3023return extension?.runtime?.id ? select(extension) : void 0;
3024}
3025function packagedExtensionStorageAdapterMissing() {
3026if (!isPackagedExtensionDocument()) return false;
3027const runtimeInstalled = globalThis.__YOMU_EXTENSION_STUDY_STORAGE_RUNTIME__ === true;
3028return !runtimeInstalled || typeof GM_getValue !== "function" || typeof GM_setValue !== "function";
3029}
3030function isPackagedExtensionDocument() {
3031try {
3032const protocol = globalThis.location?.protocol ?? "";
3033return /^(?:chrome|moz|safari-web)-extension:$/.test(protocol);
3034} catch {
3035return false;
3036}
3037}
3038function rawExtensionStorageGetValue() {
3039const extension = extensionStorageArea();
3040return extension ? extensionStorageGetValue(extension) : null;
3041}
3042function extensionStorageGetValue(extension) {
3043return async (key, fallback) => {
3044const value = (await extension.get(key))[key];
3045return value === void 0 ? fallback : value;
3046};
3047}
3048function extensionStorageChangedEvent() {
3049return extensionCapability((extension) => extension.storage?.onChanged);
3050}
3051const FACTORY_RESET_SIGNAL_KEY = "yomu:factory-reset-signal";
3052const FACTORY_RESET_CHANNEL_NAME = "yomu:factory-reset";
3053const YOMU_LOCAL_SRS_STORAGE_KEY = "yomu:srs-local:v1";
3054const YOMU_LOCAL_SRS_V2_INDEX_KEY = "yomu:srs-local:v2:index";
3055const YOMU_LOCAL_SRS_V2_CARD_PREFIX = "yomu:srs-local:v2:card:";
3056const YOMU_LOCAL_SRS_V2_TOMBSTONE_PREFIX = "yomu:srs-local:v2:tombstone:";
3057const MANAGED_IDB_DELETE_TIMEOUT_MS = 6e4;
3058const MANAGED_CACHE_NAME_PREFIXES = [
3059"yomu-newtab-",
3060"yomu-pdf-reader-",
3061"yomu-video-player-",
3062"yomu-docs-shell-"
3063];
3064const FACTORY_RESET_CONTROL_STORAGE_KEYS = new Set([
3065FACTORY_RESET_SIGNAL_KEY,
3066MANAGED_STATE_EPOCH_KEY
3067]);
3068const managedStateEpochSession = managedStateEpochSessionForRealm();
3069class ManagedStateResetError extends Error {
3070epochMayHaveCommitted;
3071constructor(diagnostic, options = {}, epochMayHaveCommitted = false) {
3072super(diagnostic, options);
3073Object.assign(this, { yomuUiCopyKey: "factoryResetStorageIncomplete" });
3074this.name = "ManagedStateResetError";
3075this.epochMayHaveCommitted = epochMayHaveCommitted;
3076}
3077}
3078function managedStateResetEpochMayHaveCommitted(error) {
3079return Boolean(error && typeof error === "object" && error.epochMayHaveCommitted === true);
3080}
3081async function assertRealmManagedStateEpoch(getValue) {
3082const readEpoch = getValue ? async () => {
3083const epoch2 = await authoritativeManagedStateEpoch(getValue);
3084return epoch2.generation === 0 ? void 0 : epoch2;
3085} : async () => localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0);
3086const epoch = await managedStateEpochSession.assertCurrent(readEpoch);
3087return epoch;
3088}
3089async function writeManagedGmValue(key, value, epoch, getValue, setValue) {
3090await assertManagedStateMutationFence(getValue, epoch);
3091const stored = managedStateStoredValue(value, epoch);
3092const storageKey = managedStateStorageKey(key, epoch);
3093fenceStorageLeaseWrite(key);
3094await setValue(storageKey, stored);
3095await assertManagedStateMutationFence(getValue, epoch);
3096}
3097async function deleteManagedGmValue(key, epoch, getValue, setValue, deleteValue) {
3098const storageKey = managedStateStorageKey(key, epoch);
3099fenceStorageLeaseWrite(key);
3100if (storageKey === key) {
3101if (!deleteValue) throw new Error("Managed storage cannot delete its legacy value.");
3102await deleteValue(key);
3103await assertRealmManagedStateEpoch(getValue);
3104return;
3105}
3106if (!setValue) throw new Error("Managed storage cannot persist a deletion tombstone.");
3107await setValue(storageKey, managedStateStoredValue(MISSING, epoch));
3108await assertRealmManagedStateEpoch(getValue);
3109if (deleteValue) {
3110try {
3111await deleteValue(key);
3112} catch (error) {
3113debugStorageError("Managed GM logical-key delete mirror failed", key, error);
3114}
3115await assertRealmManagedStateEpoch(getValue);
3116}
3117}
3118function managedStateEpochFromSynchronousGetter(getValue) {
3119const stored = getValue(MANAGED_STATE_EPOCH_KEY, MISSING);
3120if (isPromiseLike$3(stored)) {
3121void Promise.resolve(stored).catch((error) => debugStorageError("Synchronous epoch probe could not read async storage", MANAGED_STATE_EPOCH_KEY, error));
3122return null;
3123}
3124const shared = parseManagedStateEpoch(isMissingSentinel(stored) ? void 0 : stored);
3125managedStateEpochSession.assertCurrentSync(shared.generation === 0 ? void 0 : shared);
3126return shared;
3127}
3128function managedStateEpochForSynchronousLocalRead() {
3129try {
3130const getValue = typeof GM_getValue === "function" ? GM_getValue : null;
3131if (getValue) {
3132const synchronous = managedStateEpochFromSynchronousGetter(getValue);
3133if (synchronous) return synchronous;
3134return managedStateEpochSession.current() ?? null;
3135}
3136if (asyncGmGetValue()) return managedStateEpochSession.current() ?? null;
3137return managedStateEpochSession.assertCurrentSync(
3138localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0)
3139);
3140} catch (error) {
3141debugStorageError("Managed state epoch sync read failed", MANAGED_STATE_EPOCH_KEY, error);
3142return null;
3143}
3144}
3145function hasAsyncGmStorageBackend() {
3146return asyncGmGetValue() !== null;
3147}
3148async function assertManagedStateMutationAllowed() {
3149const getValue = asyncGmGetValue();
3150const epoch = await assertRealmManagedStateEpoch(getValue);
3151await assertManagedStateMutationFence(getValue, epoch);
3152return epoch;
3153}
3154async function ensureManagedWebStorageCurrent() {
3155const epoch = await assertRealmManagedStateEpoch(asyncGmGetValue());
3156await ensureManagedWebStorageEpochCurrent(epoch, managedStorageOwner());
3157}
3158function ensureManagedWebStorageCurrentSync() {
3159const epoch = managedStateEpochForSynchronousLocalRead();
3160if (!epoch) return false;
3161ensureManagedWebStorageEpochCurrentSync(epoch, managedStorageOwner());
3162return true;
3163}
3164function localFallbackStoredValue(key, fallback) {
3165const epoch = managedStateEpochForSynchronousLocalRead();
3166if (!epoch || !localMirrorBelongsToEpoch(key, epoch)) return fallback;
3167return localStorageGet(key, fallback);
3168}
3169async function gmStorageGet(key, fallback) {
3170const getValue = asyncGmGetValue();
3171if (!getValue) {
3172if (packagedExtensionStorageAdapterMissing()) return fallback;
3173return localOnlyManagedValue(key, fallback, await assertRealmManagedStateEpoch(null));
3174}
3175let epoch;
3176try {
3177epoch = await assertRealmManagedStateEpoch(getValue);
3178return await sharedManagedValue(getValue, key, fallback, epoch);
3179} catch (error) {
3180return failedManagedReadValue(error, key, fallback);
3181}
3182}
3183async function gmStorageGetStrict(key, fallback) {
3184const getValue = asyncGmGetValue();
3185if (!getValue) {
3186if (packagedExtensionStorageAdapterMissing()) return fallback;
3187return localOnlyManagedValueStrict(key, fallback, await assertRealmManagedStateEpoch(null));
3188}
3189const epoch = await assertRealmManagedStateEpoch(getValue);
3190return sharedManagedValue(getValue, key, fallback, epoch);
3191}
3192async function gmStorageGetShared(key, fallback) {
3193const getValue = asyncGmGetValue();
3194if (!getValue) return fallback;
3195return sharedOwnedManagedValue(getValue, key, fallback, "Shared GM storage read failed");
3196}
3197async function gmStorageGetSharedStrict(key, fallback) {
3198const getValue = asyncGmGetValue();
3199if (!getValue) return fallback;
3200const epoch = await assertRealmManagedStateEpoch(getValue);
3201return managedGmValue(getValue, key, fallback, epoch);
3202}
3203async function sharedOwnedManagedValue(getValue, key, fallback, errorLabel) {
3204try {
3205const epoch = await assertRealmManagedStateEpoch(getValue);
3206return await managedGmValue(getValue, key, fallback, epoch);
3207} catch (error) {
3208if (isStaleManagedStateEpochError(error)) throw error;
3209debugStorageError(errorLabel, key, error);
3210return fallback;
3211}
3212}
3213async function gmStorageGetMany(keys, fallback) {
3214if (!keys.length) return [];
3215const getValue = asyncGmGetValue();
3216if (!getValue) return localManagedValuesWithoutBackend(keys, fallback);
3217let passEpoch;
3218try {
3219passEpoch = await assertRealmManagedStateEpoch(getValue);
3220} catch (error) {
3221return keys.map((key) => failedManagedReadValue(error, key, fallback));
3222}
3223return Promise.all(keys.map(async (key) => {
3224try {
3225return await sharedManagedValue(getValue, key, fallback, passEpoch);
3226} catch (error) {
3227return failedManagedReadValue(error, key, fallback);
3228}
3229}));
3230}
3231async function localManagedValuesWithoutBackend(keys, fallback) {
3232if (packagedExtensionStorageAdapterMissing()) return keys.map(() => fallback);
3233const epoch = await assertRealmManagedStateEpoch(null);
3234return keys.map((key) => localOnlyManagedValue(key, fallback, epoch));
3235}
3236async function sharedManagedValue(getValue, key, fallback, epoch) {
3237const read = await readManagedGmValue(getValue, key, epoch);
3238if (read.kind === "found") return read.value;
3239if (read.kind === "deleted" || !websiteOnlyValuePresent(key, epoch)) return fallback;
3240await adoptWebsiteOnlyStore(getValue, epoch, gmStorageSet).catch((error) => debugStorageError("Website-only store adoption failed", key, error));
3241const adopted = await readManagedGmValue(getValue, key, epoch);
3242return adopted.kind === "found" ? adopted.value : fallback;
3243}
3244function failedManagedReadValue(error, key, fallback) {
3245if (isStaleManagedStateEpochError(error)) throw error;
3246debugStorageError("GM storage read failed", key, error);
3247return fallback;
3248}
3249function localOnlyManagedValue(key, fallback, epoch) {
3250const local = localMirrorBelongsToEpoch(key, epoch) ? localStorageGet(key, MISSING) : MISSING;
3251if (!isMissingSentinel(local)) return local;
3252return fallback;
3253}
3254function localOnlyManagedValueStrict(key, fallback, epoch) {
3255if (!hostedSettingsLocalValuePresent(key)) return localOnlyManagedValue(key, fallback, epoch);
3256if (!localMirrorEpochMatches(key, epoch, isPassiveHostedSettingsJson)) {
3257throw storageWriteError(key, "Hosted settings localStorage is present without matching provenance");
3258}
3259return localStorageGet(key, fallback);
3260}
3261function hostedSettingsLocalValuePresent(key) {
3262return isHostedSettingsStorageKey(key) && isHostedYomuOrigin() && webStorageHasKey(localStorage, key);
3263}
3264async function gmStorageGetForResetEnumeration(key, fallback) {
3265const getValue = asyncGmGetValue();
3266if (getValue) {
3267const before = await authoritativeManagedStateEpoch(getValue);
3268const storageKey = managedStateStorageKey(key, before);
3269let stored2 = await getValue(storageKey, MISSING);
3270const readFromCurrentSlot = !isMissingSentinel(stored2);
3271if (!readFromCurrentSlot && storageKey !== key) {
3272stored2 = await getValue(key, MISSING);
3273}
3274const after = await authoritativeManagedStateEpoch(getValue);
3275if (!sameManagedStateEpoch(before, after)) {
3276throw new ManagedStateResetError(`Managed storage changed epoch while enumerating "${key}".`);
3277}
3278if (isMissingSentinel(stored2)) return fallback;
3279const logical = managedStateResetEnumerationValue(stored2);
3280return isMissingSentinel(logical) ? fallback : logical;
3281}
3282if (asyncGmSetValue() || asyncGmDeleteValue()) {
3283throw new ManagedStateResetError(`Managed storage cannot read the index "${key}".`);
3284}
3285const stored = localStorageGet(key, MISSING);
3286return isMissingSentinel(stored) ? fallback : managedStateResetEnumerationValue(stored);
3287}
3288async function gmPrivateStorageGet(key, fallback) {
3289assertPrivateStorageKey(key);
3290removeLocalStorageKey(key);
3291removeSessionStorageKey(key);
3292const getValue = directGmGetValue();
3293if (!getValue) return fallback;
3294return sharedOwnedManagedValue(getValue, key, fallback, "Private GM storage read failed");
3295}
3296async function withGmStorageLease(name, operation, options = {}) {
3297return withGmStorageLeaseCore(name, operation, options, {
3298backend: gmStorageLeaseBackend(),
3299captureEpoch: assertRealmManagedStateEpoch,
3300assertMutationFence: assertManagedStateMutationFence,
3301epochToken: managedStateEpochToken,
3302hostedOrigin: isHostedYomuOrigin()
3303});
3304}
3305async function withManagedStateEpochControlLease(operation) {
3306return withManagedStateEpochControlLeaseCore(operation, {
3307backend: gmStorageLeaseBackend()
3308});
3309}
3310function gmStorageLeaseBackend() {
3311return {
3312getValue: asyncGmGetValue(),
3313setValue: asyncGmSetValue(),
3314deleteValue: asyncGmDeleteValue(),
3315listValues: asyncGmListValues()
3316};
3317}
3318function gmStorageGetSync(key, fallback) {
3319if (packagedExtensionStorageAdapterMissing()) return fallback;
3320const getValue = typeof GM_getValue === "function" ? GM_getValue : null;
3321let epoch = null;
3322if (getValue) {
3323epoch = managedStateEpochFromSynchronousGetter(getValue);
3324if (!epoch) return fallback;
3325const read = gmStorageSyncRead(key, getValue, epoch);
3326if (read.kind === "found") return read.value;
3327if (read.kind === "deleted") return fallback;
3328}
3329if (asyncGmGetValue()) return fallback;
3330epoch ??= managedStateEpochForSynchronousLocalRead();
3331return epoch && localMirrorBelongsToEpoch(key, epoch) ? localStorageGet(key, fallback) : fallback;
3332}
3333function gmStorageGetSharedSync(key, fallback) {
3334if (packagedExtensionStorageAdapterMissing()) return fallback;
3335const getValue = typeof GM_getValue === "function" ? GM_getValue : null;
3336if (!getValue) return fallback;
3337try {
3338const epoch = managedStateEpochFromSynchronousGetter(getValue);
3339if (!epoch) return fallback;
3340const storageKey = managedStateStorageKey(key, epoch);
3341let stored = getValue(storageKey, MISSING);
3342if (isPromiseLike$3(stored)) return fallback;
3343if (isMissingSentinel(stored) && storageKey !== key) {
3344stored = getValue(key, MISSING);
3345}
3346if (isPromiseLike$3(stored) || isMissingSentinel(stored)) return fallback;
3347const unreadable = Symbol("unreadable-managed-state");
3348const logical = managedStateLogicalValue(stored, epoch, unreadable);
3349return logical === unreadable || isMissingSentinel(logical) ? fallback : logical;
3350} catch (error) {
3351debugStorageError("Shared GM storage sync read failed", key, error);
3352return fallback;
3353}
3354}
3355function gmStorageSyncRead(key, getValue, epoch) {
3356try {
3357const storageKey = managedStateStorageKey(key, epoch);
3358let stored = getValue(storageKey, MISSING);
3359if (isPromiseLike$3(stored)) return { kind: "fallback" };
3360const readFromCurrentSlot = !isMissingSentinel(stored);
3361if (isMissingSentinel(stored) && storageKey !== key) {
3362stored = getValue(key, MISSING);
3363if (isPromiseLike$3(stored)) return { kind: "fallback" };
3364}
3365if (!isMissingSentinel(stored)) {
3366const unreadable = Symbol("unreadable-managed-state");
3367const value = managedStateLogicalValue(stored, epoch, unreadable);
3368if (value === unreadable) return readFromCurrentSlot ? { kind: "deleted" } : { kind: "fallback" };
3369if (isMissingSentinel(value)) return { kind: "deleted" };
3370return { kind: "found", value };
3371}
3372return { kind: "fallback" };
3373} catch (error) {
3374debugStorageError("GM storage sync read failed", key, error);
3375return { kind: "fallback" };
3376}
3377}
3378async function gmStorageSet(key, value) {
3379const getValue = asyncGmGetValue();
3380const setValue = asyncGmSetValue();
3381if (setValue) return setSharedManagedValue(key, value, getValue, setValue);
3382if (getValue) throw storageWriteError(key, "Installed storage has no writer");
3383if (packagedExtensionStorageAdapterMissing()) {
3384throw storageWriteError(key, "Packaged Study storage adapter is unavailable");
3385}
3386const epoch = await assertRealmManagedStateEpoch(null);
3387writeLocalManagedValueOrThrow(key, value, epoch);
3388}
3389async function setSharedManagedValue(key, value, getValue, setValue) {
3390let epoch;
3391try {
3392if (!getValue) throw new Error("Managed storage cannot validate its state epoch.");
3393epoch = await assertRealmManagedStateEpoch(getValue);
3394await writeManagedGmValue(key, value, epoch, getValue, setValue);
3395} catch (error) {
3396if (isStaleManagedStateEpochError(error) || isStorageLeaseLapsed(error)) throw error;
3397throw storageWriteError(key, "GM storage write failed", error);
3398}
3399}
3400async function gmPrivateStorageSet(key, value) {
3401assertPrivateStorageKey(key);
3402removeLocalStorageKey(key);
3403removeSessionStorageKey(key);
3404const getValue = directGmGetValue();
3405const setValue = directGmSetValue();
3406if (!getValue || !setValue) throw new Error("Secure extension storage is unavailable.");
3407try {
3408const epoch = await assertRealmManagedStateEpoch(getValue);
3409await writeManagedGmValue(key, value, epoch, getValue, setValue);
3410} catch (error) {
3411if (isStaleManagedStateEpochError(error)) throw error;
3412debugStorageError("Private GM storage write failed", key, error);
3413throw new Error("Secure extension storage is unavailable.");
3414}
3415}
3416function gmStorageSetSync(key, value) {
3417if (packagedExtensionStorageAdapterMissing()) {
3418debugStorageError("Packaged Study storage adapter is unavailable", key, null);
3419return;
3420}
3421const getValue = typeof GM_getValue === "function" ? GM_getValue : null;
3422const setValue = typeof GM_setValue === "function" ? GM_setValue : null;
3423let epoch = null;
3424if (getValue && setValue) {
3425try {
3426epoch = managedStateEpochFromSynchronousGetter(getValue);
3427if (!epoch) {
3428void gmStorageSet(key, value).catch((error) => debugStorageError("GM storage async write failed", key, error));
3429return;
3430}
3431const stored = managedStateStoredValue(value, epoch);
3432const storageKey = managedStateStorageKey(key, epoch);
3433const result = setValue(storageKey, stored);
3434if (isPromiseLike$3(result)) {
3435void result.then(async () => {
3436await assertRealmManagedStateEpoch(getValue);
3437}).catch((error) => debugStorageError("GM storage async write failed", key, error));
3438return;
3439}
3440const after = managedStateEpochFromSynchronousGetter(getValue);
3441if (!after || !sameManagedStateEpoch(epoch, after)) return;
3442return;
3443} catch (error) {
3444if (isStaleManagedStateEpochError(error)) {
3445debugStorageError("Rejected stale managed state write", key, error);
3446return;
3447}
3448debugStorageError("GM storage sync write failed", key, error);
3449return;
3450}
3451}
3452if ((!getValue || !setValue) && asyncGmSetValue()) {
3453void gmStorageSet(key, value).catch((error) => debugStorageError("GM storage async write failed", key, error));
3454return;
3455}
3456if (asyncGmGetValue()) return;
3457try {
3458epoch ??= managedStateEpochForSynchronousLocalRead();
3459if (!epoch) return;
3460writeLocalManagedValueOrThrow(key, value, epoch);
3461} catch (error) {
3462debugStorageError("localStorage sync write failed", key, error);
3463}
3464}
3465async function gmStorageDelete(key) {
3466const getValue = asyncGmGetValue();
3467const setValue = asyncGmSetValue();
3468const deleteValue = asyncGmDeleteValue();
3469const hasBackend = Boolean(getValue || setValue || deleteValue);
3470if (!hasBackend && packagedExtensionStorageAdapterMissing()) {
3471throw storageWriteError(key, "Packaged Study storage adapter is unavailable");
3472}
3473if (hasBackend && !getValue) {
3474throw storageWriteError(key, "Managed storage cannot validate and delete the same backend value");
3475}
3476if (getValue) {
3477try {
3478const epoch = await assertRealmManagedStateEpoch(getValue);
3479await deleteManagedGmValue(key, epoch, getValue, setValue, deleteValue);
3480} catch (error) {
3481if (isStaleManagedStateEpochError(error) || isStorageLeaseLapsed(error)) throw error;
3482debugStorageError("GM storage delete failed", key, error);
3483throw storageWriteError(key, "GM storage delete failed", error);
3484}
3485} else {
3486await assertRealmManagedStateEpoch(null);
3487}
3488if (getValue) return;
3489removeLocalStorageKey(key);
3490removeSessionStorageKey(key);
3491removeLocalMirrorProvenance(key);
3492}
3493async function deleteManagedStoredValue(key) {
3494const getValue = asyncGmGetValue();
3495const deleteValue = asyncGmDeleteValue();
3496if (getValue && !deleteValue) {
3497throw new ManagedStateResetError(`Managed storage cannot delete "${key}".`);
3498}
3499const epoch = !isManagedStorageSlotKey(key) && getValue ? await authoritativeManagedStateEpoch(getValue) : null;
3500const targets = new Set([key]);
3501if (epoch) targets.add(managedStateStorageKey(key, epoch));
3502if (deleteValue) {
3503for (const target of targets) {
3504try {
3505await deleteValue(target);
3506} catch (error) {
3507throw new ManagedStateResetError(`Managed storage failed to delete "${target}".`, { cause: error });
3508}
3509}
3510}
3511if (!getValue) {
3512for (const target of targets) {
3513removeLocalStorageKey(target);
3514removeSessionStorageKey(target);
3515}
3516}
3517if (getValue) {
3518for (const target of targets) {
3519let stored;
3520try {
3521stored = await getValue(target, MISSING);
3522} catch (error) {
3523throw new ManagedStateResetError(`Managed storage could not verify deletion of "${target}".`, { cause: error });
3524}
3525if (!isMissingSentinel(stored)) {
3526throw new ManagedStateResetError(`Managed storage still contains "${target}" after deletion.`);
3527}
3528}
3529}
3530if (!getValue) {
3531for (const target of targets) {
3532if (resetWebStorageHasKey(localStorage, target, "localStorage") || resetWebStorageHasKey(sessionStorage, target, "sessionStorage")) {
3533throw new ManagedStateResetError(`Web storage still contains "${target}" after deletion.`);
3534}
3535}
3536}
3537}
3538async function gmPrivateStorageDelete(key) {
3539assertPrivateStorageKey(key);
3540const getValue = directGmGetValue();
3541const setValue = directGmSetValue();
3542const deleteValue = directGmDeleteValue();
3543if (!getValue || !setValue && !deleteValue) throw new Error("Secure extension storage is unavailable.");
3544if (getValue) {
3545try {
3546const epoch = await assertRealmManagedStateEpoch(getValue);
3547await deleteManagedGmValue(key, epoch, getValue, setValue, deleteValue);
3548} catch (error) {
3549if (isStaleManagedStateEpochError(error)) throw error;
3550debugStorageError("Private GM storage delete failed", key, error);
3551throw new Error("Secure extension storage is unavailable.");
3552}
3553}
3554removeLocalStorageKey(key);
3555removeSessionStorageKey(key);
3556}
3557function assertPrivateStorageKey(key) {
3558if (!isPrivateManagedStorageKey(key)) throw new TypeError("Private storage requires a yomu:private: key.");
3559}
3560function gmStorageDeleteSync(key) {
3561if (packagedExtensionStorageAdapterMissing()) {
3562debugStorageError("Packaged Study storage adapter is unavailable", key, null);
3563return;
3564}
3565const getValue = typeof GM_getValue === "function" ? GM_getValue : null;
3566const setValue = typeof GM_setValue === "function" ? GM_setValue : null;
3567const deleteValue = typeof GM_deleteValue === "function" ? GM_deleteValue : null;
3568if (getValue && (setValue || deleteValue)) {
3569try {
3570const epoch = managedStateEpochFromSynchronousGetter(getValue);
3571if (!epoch) {
3572void gmStorageDelete(key).catch((error) => debugStorageError("GM storage async delete failed", key, error));
3573return;
3574}
3575const storageKey = managedStateStorageKey(key, epoch);
3576const result = storageKey === key ? deleteValue?.(key) : setValue?.(storageKey, managedStateStoredValue(MISSING, epoch));
3577if (result === void 0 && (storageKey === key ? !deleteValue : !setValue)) {
3578void gmStorageDelete(key).catch((error) => debugStorageError("GM storage async delete failed", key, error));
3579return;
3580}
3581if (isPromiseLike$3(result)) {
3582void result.then(async () => {
3583await assertRealmManagedStateEpoch(getValue);
3584}).catch((error) => debugStorageError("GM storage async delete failed", key, error));
3585return;
3586}
3587const after = managedStateEpochFromSynchronousGetter(getValue);
3588if (!after || !sameManagedStateEpoch(epoch, after)) return;
3589return;
3590} catch (error) {
3591debugStorageError("GM storage sync delete failed", key, error);
3592return;
3593}
3594}
3595if (asyncGmDeleteValue() || asyncGmSetValue()) {
3596void gmStorageDelete(key).catch((error) => debugStorageError("GM storage async delete failed", key, error));
3597return;
3598}
3599if (asyncGmGetValue()) return;
3600try {
3601if (!managedStateEpochForSynchronousLocalRead()) return;
3602removeLocalManagedValue(key);
3603} catch (error) {
3604debugStorageError("localStorage sync delete failed", key, error);
3605}
3606}
3607async function exportStoredValues(prefixes) {
3608await ensureManagedWebStorageCurrent();
3609await adoptWebsiteOnlyStoreBeforeExport();
3610const localKeys = new Set();
3611addLocalStorageKeys(localKeys, prefixes);
3612const keys = (await storageKeys(prefixes)).filter(isManagedStorageBackupKey);
3613const entries2 = await Promise.all(keys.map(async (key) => [key, await storedBackupValue(key, localKeys.has(key))]));
3614return Object.fromEntries(entries2.filter(([, value]) => !isMissingSentinel(value)));
3615}
3616async function adoptWebsiteOnlyStoreBeforeExport() {
3617const getValue = asyncGmGetValue();
3618if (!getValue || !isHostedYomuOrigin()) return;
3619await adoptWebsiteOnlyStore(getValue, await assertRealmManagedStateEpoch(getValue), gmStorageSet);
3620}
3621async function storedBackupValue(key, pageRecord) {
3622const shared = await gmStorageGet(key, MISSING);
3623if (!isMissingSentinel(shared) || !pageRecord) return shared;
3624try {
3625const serialized = managedLocalStorage.getItem(key);
3626return serialized === null ? MISSING : JSON.parse(serialized);
3627} catch {
3628return MISSING;
3629}
3630}
3631async function exportManagedStoredValues() {
3632return await exportStoredValues(MANAGED_STORAGE_KEY_PREFIXES);
3633}
3634async function importStoredValues(values) {
3635const transaction = await beginStoredValuesImport(values);
3636transaction.commit();
3637return transaction.count;
3638}
3639async function beginStoredValuesImport(values) {
3640const entries2 = orderedManagedStoredValueEntries(values);
3641const journal = createManagedWriteJournal();
3642try {
3643for (const [key, imported] of entries2) {
3644const receipt = await journal.capture(key);
3645await journal.write(receipt, await prepareStoredValueForImport(key, imported));
3646}
3647} catch (error) {
3648await journal.reject(error, "Managed storage import failed");
3649}
3650return {
3651count: entries2.length,
3652commit: () => journal.commit(),
3653rollback: () => journal.rollback("Managed storage import rollback failed")
3654};
3655}
3656const MANAGED_WRITE_STORAGE = {
3657readAuthority: readManagedStoredValueAuthority,
3658writeAuthority: (key, value) => gmStorageSet(key, value),
3659restoreAuthority: (key, target) => restoreManagedStoredValueAuthority(key, target.value, target.existed),
3660readLocalTarget: (key) => managedStoredValueState(localFallbackStoredValue(key, MISSING)),
3661restoreLocalTarget: (key, target) => restoreLocalFallbackStoredValue(key, target.value, target.existed)
3662};
3663function createManagedWriteJournal(preserveLocalFallbackOnWriteFailure = false) {
3664return createConcreteManagedWriteJournal(
3665MANAGED_WRITE_STORAGE,
3666preserveLocalFallbackOnWriteFailure
3667);
3668}
3669function orderedManagedStoredValueEntries(values) {
3670return managedStoredValueEntries(values).sort(([left], [right]) => Number(left === YOMU_LOCAL_SRS_V2_INDEX_KEY) - Number(right === YOMU_LOCAL_SRS_V2_INDEX_KEY));
3671}
3672async function prepareStoredValueForImport(key, value) {
3673return key === YOMU_LOCAL_SRS_STORAGE_KEY ? mergeYomuLocalSrsDeckImport(value) : mergeYomuLocalSrsV2Import(key, value);
3674}
3675async function readManagedStoredValueAuthority(key) {
3676if (isSettingsAuthorityStorageKey(key)) {
3677const value2 = isHostedYomuOrigin() ? await gmStorageGetStrict(key, MISSING) : await gmStorageGetSharedStrict(key, MISSING);
3678return managedStoredValueState(value2);
3679}
3680const getValue = asyncGmGetValue();
3681const epoch = await assertRealmManagedStateEpoch(getValue);
3682const value = getValue ? await managedGmValue(getValue, key, MISSING, epoch) : localOnlyManagedValue(key, MISSING, epoch);
3683return managedStoredValueState(value);
3684}
3685function managedStoredValueState(value) {
3686return {
3687existed: !isMissingSentinel(value),
3688value: isMissingSentinel(value) ? null : value
3689};
3690}
3691async function restoreManagedStoredValueAuthority(key, previousValue, existed) {
3692const getValue = asyncGmGetValue();
3693if (!getValue) return;
3694const epoch = await assertRealmManagedStateEpoch(getValue);
3695if (existed) {
3696const setValue = asyncGmSetValue();
3697if (!setValue) throw storageWriteError(key, "Managed storage cannot restore its authoritative value");
3698await writeManagedGmValue(key, previousValue, epoch, getValue, setValue);
3699return;
3700}
3701await deleteManagedGmValue(
3702key,
3703epoch,
3704getValue,
3705asyncGmSetValue(),
3706asyncGmDeleteValue()
3707);
3708}
3709async function mergeYomuLocalSrsV2Import(key, imported) {
3710if (key === YOMU_LOCAL_SRS_V2_INDEX_KEY) {
3711const existing = await gmStorageGet(key, null).catch(() => null);
3712if (!isPlainRecord(imported) || !isPlainRecord(existing)) return imported;
3713return {
3714version: 2,
3715revision: Math.max(
3716nonNegativeSafeInteger(existing.revision),
3717nonNegativeSafeInteger(imported.revision)
3718) + 1,
3719cardIds: mergedStringIds(existing.cardIds, imported.cardIds),
3720tombstoneIds: mergedStringIds(existing.tombstoneIds, imported.tombstoneIds)
3721};
3722}
3723if (key.startsWith(YOMU_LOCAL_SRS_V2_CARD_PREFIX)) {
3724const id = decodeStoredYomuSrsId(key.slice(YOMU_LOCAL_SRS_V2_CARD_PREFIX.length));
3725const existing = await gmStorageGet(key, null).catch(() => null);
3726if (!id || !isPlainRecord(imported) || !isPlainRecord(existing)) return imported;
3727return mergeYomuLocalSrsCards(
3728{ [id]: existing },
3729{ [id]: imported }
3730)[id] ?? imported;
3731}
3732if (key.startsWith(YOMU_LOCAL_SRS_V2_TOMBSTONE_PREFIX)) {
3733const existing = await gmStorageGet(key, null).catch(() => null);
3734return Math.max(nonNegativeSafeInteger(existing), nonNegativeSafeInteger(imported));
3735}
3736return imported;
3737}
3738function mergedStringIds(left, right) {
3739return [...new Set([
3740...Array.isArray(left) ? left.filter((value) => typeof value === "string") : [],
3741...Array.isArray(right) ? right.filter((value) => typeof value === "string") : []
3742])].sort();
3743}
3744function nonNegativeSafeInteger(value) {
3745const number = Number(value);
3746return Number.isSafeInteger(number) && number >= 0 ? number : 0;
3747}
3748function decodeStoredYomuSrsId(value) {
3749try {
3750return decodeURIComponent(value);
3751} catch {
3752return null;
3753}
3754}
3755function managedStoredValueEntries(values) {
3756return isStorageImportRecord(values) ? Object.entries(values).filter(([key]) => isManagedStorageBackupKey(key) && !isSettingsAuthorityStorageKey(key)) : [];
3757}
3758function isStorageImportRecord(values) {
3759return Boolean(values && typeof values === "object" && !Array.isArray(values));
3760}
3761async function mergeYomuLocalSrsDeckImport(imported) {
3762const importedDeck = yomuLocalSrsDeckRecord(imported);
3763if (!importedDeck) return imported;
3764const existingDeck = yomuLocalSrsDeckRecord(await gmStorageGet(YOMU_LOCAL_SRS_STORAGE_KEY, null).catch(() => null));
3765if (!existingDeck) return importedDeck;
3766return {
3767version: 1,
3768cards: mergeYomuLocalSrsCards(existingDeck.cards, importedDeck.cards)
3769};
3770}
3771function yomuLocalSrsDeckRecord(value) {
3772if (!isPlainRecord(value) || value.version !== 1 || !isPlainRecord(value.cards)) return null;
3773const cards = {};
3774for (const [id, card] of Object.entries(value.cards)) {
3775if (isPlainRecord(card)) cards[id] = card;
3776}
3777return { version: 1, cards };
3778}
3779function mergeYomuLocalSrsCards(existingCards, importedCards) {
3780const cards = { ...existingCards };
3781for (const [id, importedCard] of Object.entries(importedCards)) {
3782const existingCard = cards[id];
3783cards[id] = existingCard ? mergeYomuLocalSrsCard(existingCard, importedCard) : importedCard;
3784}
3785return cards;
3786}
3787function mergeYomuLocalSrsCard(existingCard, importedCard) {
3788return {
3789...importedCard,
3790...existingCard,
3791meanings: uniquePrimitiveStrings([
3792...stringArray(importedCard.meanings),
3793...stringArray(existingCard.meanings)
3794]),
3795sentence: existingCard.sentence || importedCard.sentence,
3796sourceUrl: existingCard.sourceUrl || importedCard.sourceUrl,
3797tags: uniquePrimitiveStrings([
3798...stringArray(importedCard.tags),
3799...stringArray(existingCard.tags)
3800]),
3801createdAt: minFiniteNumber(importedCard.createdAt, existingCard.createdAt) ?? existingCard.createdAt ?? importedCard.createdAt,
3802updatedAt: maxFiniteNumber(importedCard.updatedAt, existingCard.updatedAt) ?? existingCard.updatedAt ?? importedCard.updatedAt
3803};
3804}
3805function stringArray(value) {
3806return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
3807}
3808function uniquePrimitiveStrings(values) {
3809return Array.from(new Set(values));
3810}
3811function minFiniteNumber(a, b) {
3812const values = [a, b].filter((value) => typeof value === "number" && Number.isFinite(value));
3813return values.length ? Math.min(...values) : void 0;
3814}
3815function maxFiniteNumber(a, b) {
3816const values = [a, b].filter((value) => typeof value === "number" && Number.isFinite(value));
3817return values.length ? Math.max(...values) : void 0;
3818}
3819function isPlainRecord(value) {
3820return Boolean(value && typeof value === "object" && !Array.isArray(value));
3821}
3822async function clearManagedStoredValues() {
3823const keys = await allStorageKeys();
3824await clearBridgePrivateManagedValuesForReset();
3825let count = 0;
3826for (const key of keys) {
3827await deleteManagedStoredValue(key);
3828count++;
3829}
3830for (const key of originWebStorageResetKeys()) {
3831removeLocalStorageKey(key);
3832removeSessionStorageKey(key);
3833if (resetWebStorageHasKey(localStorage, key, "localStorage") || resetWebStorageHasKey(sessionStorage, key, "sessionStorage")) {
3834throw new ManagedStateResetError(`Web storage still contains "${key}" after deletion.`);
3835}
3836count++;
3837}
3838count += await clearStrandedExtensionStudyManagedValuesForReset();
3839await clearManagedIndexedDatabases();
3840count += await clearManagedBrowserCaches();
3841count += await unregisterManagedServiceWorkers();
3842return count;
3843}
3844async function managedStoredKeysStillPresent() {
3845const keys = await allStorageKeys();
3846await clearBridgePrivateManagedValuesForReset();
3847return [...new Set([...keys, ...originWebStorageResetKeys(), ...await strandedExtensionStudyManagedKeys()])].sort();
3848}
3849const RESET_LEASE_SETTLE_PASSES = 5;
3850const RESET_LEASE_SETTLE_MS = 500;
3851async function managedStoredKeysLeftAfterReset() {
3852let left = await managedStoredKeysStillPresent();
3853for (let pass = 0; pass < RESET_LEASE_SETTLE_PASSES && onlyLeaseClaims(left); pass++) {
3854await delay(RESET_LEASE_SETTLE_MS);
3855for (const key of left) await deleteManagedStoredValue(key);
3856left = await managedStoredKeysStillPresent();
3857}
3858return left;
3859}
3860function onlyLeaseClaims(keys) {
3861return keys.length > 0 && keys.every((key) => key.startsWith(STORAGE_LEASE_KEY_PREFIX));
3862}
3863function originWebStorageResetKeys() {
3864try {
3865return managedWebStorageResetKeys().filter((key) => !isFactoryResetControlStorageKey(key));
3866} catch (error) {
3867throw new ManagedStateResetError("Factory reset could not enumerate this site's web storage.", { cause: error });
3868}
3869}
3870async function clearStrandedExtensionStudyManagedValuesForReset() {
3871try {
3872return await clearLegacyExtensionManagedStorage();
3873} catch (error) {
3874throw new ManagedStateResetError("Factory reset could not clear legacy extension Study storage.", { cause: error });
3875}
3876}
3877async function strandedExtensionStudyManagedKeys() {
3878try {
3879return await legacyExtensionManagedStorageKeys();
3880} catch (error) {
3881throw new ManagedStateResetError("Factory reset could not inspect legacy extension Study storage.", { cause: error });
3882}
3883}
3884async function clearManagedBrowserCaches() {
3885if (typeof caches === "undefined") return 0;
3886try {
3887const keys = await caches.keys();
3888const managedKeys = keys.filter(isManagedBrowserCacheName);
3889const deleted = await Promise.all(managedKeys.map((key) => caches.delete(key)));
3890const failed = managedKeys.filter((_key, index) => !deleted[index]);
3891if (failed.length) throw new ManagedStateResetError(`Managed browser caches remained: ${failed.join(", ")}`);
3892return deleted.length;
3893} catch (error) {
3894debugStorageError("Cache API clear failed", "managed-caches", error);
3895if (error instanceof ManagedStateResetError) throw error;
3896throw new ManagedStateResetError("Managed browser caches could not be cleared.", { cause: error });
3897}
3898}
3899async function unregisterManagedServiceWorkers() {
3900if (typeof navigator === "undefined" || !navigator.serviceWorker?.getRegistrations) return 0;
3901try {
3902const registrations = await navigator.serviceWorker.getRegistrations();
3903const managedRegistrations = registrations.filter(isManagedServiceWorkerRegistration);
3904const unregistered = await Promise.all(managedRegistrations.map((registration) => registration.unregister()));
3905const failed = managedRegistrations.filter((_registration, index) => !unregistered[index]);
3906if (failed.length) throw new ManagedStateResetError("Managed service workers remained registered.");
3907return unregistered.length;
3908} catch (error) {
3909debugStorageError("Service worker unregister failed", "managed-service-workers", error);
3910if (error instanceof ManagedStateResetError) throw error;
3911throw new ManagedStateResetError("Managed service workers could not be unregistered.", { cause: error });
3912}
3913}
3914async function setRawControlStorageValue(key, value) {
3915const setValue = asyncGmSetValue();
3916if (setValue) {
3917await setValue(key, value);
3918return;
3919}
3920localStorageSetOrThrow(key, value);
3921}
3922async function deleteRawControlStorageValue(key) {
3923const getValue = asyncGmGetValue();
3924const deleteValue = asyncGmDeleteValue();
3925if (getValue && !deleteValue) throw new Error(`Managed storage c
3925annot delete control key "${key}".`);
3926if (deleteValue) await deleteValue(key);
3927if (getValue) {
3928const stored = await getValue(key, MISSING);
3929if (!isMissingSentinel(stored)) throw new Error(`Managed storage retained control key "${key}".`);
3930}
3931if (!getValue) {
3932removeLocalStorageKey(key);
3933removeSessionStorageKey(key);
3934}
3935}
3936async function clearFactoryResetSignal() {
3937await deleteRawControlStorageValue(FACTORY_RESET_SIGNAL_KEY);
3938}
3939function createFactoryResetSignal(phase, id = createStorageCoordinationId()) {
3940return {
3941id,
3942phase,
3943at: Date.now(),
3944href: location.href
3945};
3946}
3947async function publishFactoryResetSignal(signal) {
3948const normalized = normalizeFactoryResetSignal(signal);
3949await setRawControlStorageValue(FACTORY_RESET_SIGNAL_KEY, normalized);
3950publishBroadcastFactoryResetSignal(normalized);
3951}
3952async function commitManagedStateResetEpoch(resetId) {
3953const getValue = asyncGmGetValue();
3954const setValue = asyncGmSetValue();
3955if (Boolean(getValue) !== Boolean(setValue)) {
3956throw new ManagedStateResetError("Managed storage cannot persist the reset epoch safely.");
3957}
3958let epochMayHaveCommitted = false;
3959try {
3960return await withManagedStateEpochControlLease(async () => {
3961const current = getValue && setValue ? await authoritativeManagedStateEpoch(getValue) : parseManagedStateEpoch(localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0));
3962const captured = managedStateEpochSession.current();
3963if (captured && !sameManagedStateEpoch(captured, current)) {
3964throw new StaleManagedStateEpochError(captured, current);
3965}
3966const next = nextManagedStateEpoch(current, resetId);
3967await setRawControlStorageValue(MANAGED_STATE_EPOCH_KEY, next);
3968epochMayHaveCommitted = true;
3969const persisted = getValue ? parseManagedStateEpoch(await rawAuthoritativeManagedStateEpoch(getValue)) : parseManagedStateEpoch(localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0));
3970if (!sameManagedStateEpoch(next, persisted)) {
3971throw new ManagedStateResetError("Managed storage did not retain the new reset epoch.");
3972}
3973return next;
3974});
3975} catch (error) {
3976if (error instanceof ManagedStateResetError && error.epochMayHaveCommitted === epochMayHaveCommitted) throw error;
3977throw new ManagedStateResetError(
3978"Managed storage could not commit the reset epoch.",
3979{ cause: error },
3980epochMayHaveCommitted
3981);
3982}
3983}
3984function subscribeToFactoryResetSignals(onSignal) {
3985const cleanups = [];
3986const owner = managedStorageOwner();
3987addGmValueChangeCleanup(cleanups, FACTORY_RESET_SIGNAL_KEY, (_key, _oldValue, newValue, remote) => {
3988const signal = parseFactoryResetSignal(newValue);
3989if (signal) onSignal(signal, { remote, transport: "gm-storage" });
3990}, "GM factory reset listener failed");
3991if (typeof BroadcastChannel === "function") {
3992try {
3993const channel = new BroadcastChannel(`${FACTORY_RESET_CHANNEL_NAME}:${owner}`);
3994channel.onmessage = (event) => {
3995if (!isPlainRecord(event.data) || event.data.owner !== owner) return;
3996const signal = parseFactoryResetSignal(event.data.signal);
3997if (signal) onSignal(signal, { remote: true, transport: "broadcast-channel" });
3998};
3999cleanups.push(() => channel.close());
4000} catch (error) {
4001debugStorageError("Broadcast factory reset listener failed", FACTORY_RESET_CHANNEL_NAME, error);
4002}
4003}
4004if (owner === "standalone") {
4005addWebStorageCleanup(cleanups, FACTORY_RESET_SIGNAL_KEY, (event) => {
4006const signal = parseFactoryResetSignal(event.newValue);
4007if (signal) onSignal(signal, { remote: true, transport: "web-storage" });
4008});
4009}
4010return () => runStorageCleanups(cleanups);
4011}
4012function subscribeToStoredValueChanges(key, onChange) {
4013const cleanups = [];
4014const subscriptionEpoch = managedStateEpochForSynchronousLocalRead();
4015const sharedKeys = new Set([key]);
4016if (subscriptionEpoch) sharedKeys.add(managedStateStorageKey(key, subscriptionEpoch));
4017let notificationQueue = Promise.resolve();
4018const enqueueManagedChange = (_stored, source) => {
4019notificationQueue = notificationQueue.then(() => notifyManagedStoredValueChange(key, source, onChange)).catch((error) => debugStorageError("Managed stored value listener failed", key, error));
4020};
4021for (const sharedKey of sharedKeys) {
4022addGmValueChangeCleanup(cleanups, sharedKey, (_key, _oldValue, newValue, remote) => {
4023enqueueManagedChange(newValue, { remote, transport: "gm-storage" });
4024}, "GM stored value listener failed");
4025}
4026addWebStorageCleanup(cleanups, key, (event) => {
4027const epoch = managedStateEpochForSynchronousLocalRead();
4028if (!epoch || !localMirrorBelongsToEpoch(key, epoch)) return;
4029onChange(JSON.parse(event.newValue || "null"), { remote: true, transport: "web-storage" });
4030});
4031const extensionChanges = extensionStorageChangedEvent();
4032if (extensionChanges) {
4033const listener = (changes, areaName) => {
4034if (areaName !== "local") return;
4035for (const sharedKey of sharedKeys) {
4036if (!(sharedKey in changes)) continue;
4037enqueueManagedChange(
4038changes[sharedKey]?.newValue,
4039{ remote: true, transport: "extension-storage" }
4040);
4041}
4042};
4043extensionChanges.addListener(listener);
4044cleanups.push(() => extensionChanges.removeListener(listener));
4045}
4046return () => runStorageCleanups(cleanups);
4047}
4048async function notifyManagedStoredValueChange(key, source, onChange) {
4049const getValue = asyncGmGetValue();
4050if (!getValue) return;
4051const epoch = await assertRealmManagedStateEpoch(getValue);
4052const read = await readManagedGmValue(getValue, key, epoch);
4053onChange(read.kind === "found" ? read.value : void 0, source);
4054}
4055function addGmValueChangeCleanup(cleanups, key, listener, errorLabel) {
4056const ambientAddValueChangeListener = typeof GM_addValueChangeListener === "function" ? GM_addValueChangeListener : void 0;
4057const addValueChangeListener = ambientAddValueChangeListener ?? globalThis.GM_addValueChangeListener;
4058if (typeof addValueChangeListener !== "function") return;
4059try {
4060const listenerId = addValueChangeListener(key, listener);
4061cleanups.push(() => {
4062const ambientRemoveValueChangeListener = typeof GM_removeValueChangeListener === "function" ? GM_removeValueChangeListener : void 0;
4063const removeValueChangeListener = ambientRemoveValueChangeListener ?? globalThis.GM_removeValueChangeListener;
4064if (typeof removeValueChangeListener === "function") removeValueChangeListener(listenerId);
4065});
4066} catch (error) {
4067debugStorageError(errorLabel, key, error);
4068}
4069}
4070function addWebStorageCleanup(cleanups, key, listener) {
4071const onStorage = (event) => {
4072if (event.key !== key) return;
4073listener(event);
4074};
4075window.addEventListener("storage", onStorage);
4076cleanups.push(() => window.removeEventListener("storage", onStorage));
4077}
4078function runStorageCleanups(cleanups) {
4079while (cleanups.length) {
4080try {
4081cleanups.pop()?.();
4082} catch {
4083}
4084}
4085}
4086async function storageKeys(prefixes) {
4087const keys = new Set();
4088await addPrefixedGmStorageKeys(keys, prefixes);
4089addLocalStorageKeys(keys, prefixes);
4090await addKnownManagedStorageKeys(keys, prefixes);
4091return [...keys].sort();
4092}
4093async function addPrefixedGmStorageKeys(keys, prefixes) {
4094const listValues = asyncGmListValues();
4095if (!listValues) return;
4096try {
4097addMatchingStorageKeys(keys, await listValues(), prefixes);
4098} catch (error) {
4099debugStorageError("GM storage list failed", "GM_listValues", error);
4100}
4101}
4102function addLocalStorageKeys(keys, prefixes) {
4103try {
4104addMatchingStorageKeys(keys, managedLocalStorageKeys(), prefixes);
4105} catch {
4106}
4107}
4108async function addKnownManagedStorageKeys(keys, prefixes) {
4109for (const key of registeredManagedStorageKeys()) {
4110if (storageKeyMatchesPrefix(key, prefixes) && await storedValueExists(key)) keys.add(key);
4111}
4112}
4113function addMatchingStorageKeys(keys, candidates, prefixes) {
4114for (const key of candidates) {
4115const logicalKey = logicalManagedStorageKey(key);
4116if (logicalKey && storageKeyMatchesPrefix(logicalKey, prefixes)) keys.add(logicalKey);
4117}
4118}
4119function storageKeyMatchesPrefix(key, prefixes) {
4120return prefixes.some((prefix) => key.startsWith(prefix));
4121}
4122async function allStorageKeys() {
4123await preflightFactoryResetEpoch();
4124const bridgePrivateValuesHandledSeparately = bridgePrivateManagedResetAvailable();
4125const keys = new Set();
4126const gmEnumeration = await addGmStorageKeys(keys);
4127await addKnownStoredKeys(keys, bridgePrivateValuesHandledSeparately);
4128if (!gmEnumeration.complete) {
4129const incompleteOwners = await addDeclaredGmPrefixKeys(keys);
4130if (incompleteOwners.length) {
4131throw new ManagedStateResetError([
4132gmEnumeration.diagnostic,
4133`Missing authoritative prefix inventory: ${incompleteOwners.join(", ")}`
4134].filter(Boolean).join(" "));
4135}
4136}
4137warnUnregisteredManagedKeys(keys);
4138return [...keys].filter((key) => !isFactoryResetControlStorageKey(key)).sort();
4139}
4140function bridgePrivateManagedResetAvailable() {
4141return !directGmGetValue() && Boolean(getUserscriptGmStorage());
4142}
4143async function clearBridgePrivateManagedValuesForReset() {
4144if (!bridgePrivateManagedResetAvailable()) return false;
4145const bridge = getUserscriptGmStorage();
4146if (!bridge) return false;
4147try {
4148await bridge.clearPrivateManagedValues();
4149await bridge.clearLegacyExtensionManagedValues();
4150return true;
4151} catch (error) {
4152throw new ManagedStateResetError("Factory reset could not clear private managed storage.", { cause: error });
4153}
4154}
4155function isFactoryResetControlStorageKey(key) {
4156return FACTORY_RESET_CONTROL_STORAGE_KEYS.has(key) || key.startsWith(MANAGED_STATE_EPOCH_LEASE_KEY_PREFIX);
4157}
4158async function preflightFactoryResetEpoch() {
4159try {
4160await assertRealmManagedStateEpoch(asyncGmGetValue());
4161} catch (error) {
4162throw new ManagedStateResetError("Factory reset cannot validate the current managed-state epoch.", { cause: error });
4163}
4164}
4165function unregisteredManagedStorageKeys(keys) {
4166const exactKeys = new Set(registeredManagedStorageKeys());
4167const prefixes = managedStateEntries().filter((entry) => entry.kind !== "idb" && entry.prefix).map((entry) => entry.prefix);
4168const unregistered = [];
4169for (const key of keys) {
4170if (isFactoryResetControlStorageKey(key)) continue;
4171const logicalKey = logicalManagedStorageKey(key) ?? key;
4172if (exactKeys.has(logicalKey)) continue;
4173if (prefixes.some((prefix) => logicalKey.startsWith(prefix))) continue;
4174unregistered.push(key);
4175}
4176return unregistered;
4177}
4178function warnUnregisteredManagedKeys(keys) {
4179const unregistered = unregisteredManagedStorageKeys(keys);
4180if (unregistered.length) {
4181debugStorageError("Managed key not in registry (unregistered store escaping reset)", unregistered.join(","), null);
4182}
4183}
4184async function addGmStorageKeys(keys) {
4185const listValues = asyncGmListValues();
4186if (!listValues) {
4187const hasGmBackend = Boolean(asyncGmGetValue() || asyncGmSetValue() || asyncGmDeleteValue());
4188return hasGmBackend ? { complete: false, diagnostic: "GM_listValues is unavailable." } : { complete: true };
4189}
4190try {
4191for (const key of await listValues()) keys.add(key);
4192return { complete: true };
4193} catch (error) {
4194debugStorageError("GM storage list failed", "GM_listValues", error);
4195return { complete: false, diagnostic: "GM_listValues failed." };
4196}
4197}
4198async function addDeclaredGmPrefixKeys(keys) {
4199const incompleteOwners = [];
4200for (const entry of managedStateEntries()) {
4201if (entry.kind !== "gm" || !entry.prefix) continue;
4202if (!entry.enumerate) {
4203incompleteOwners.push(entry.owner);
4204continue;
4205}
4206try {
4207const enumerated = await entry.enumerate();
4208if (enumerated.some((key) => !key.startsWith(entry.prefix))) {
4209incompleteOwners.push(entry.owner);
4210continue;
4211}
4212for (const key of enumerated) keys.add(key);
4213} catch (error) {
4214debugStorageError("Managed prefix enumeration failed", entry.owner, error);
4215incompleteOwners.push(entry.owner);
4216}
4217}
4218return [...new Set(incompleteOwners)].sort();
4219}
4220async function addKnownStoredKeys(keys, bridgePrivateValuesHandledSeparately) {
4221for (const key of registeredManagedStorageKeys()) {
4222if (bridgePrivateValuesHandledSeparately && isPrivateManagedStorageKey(key)) continue;
4223if (await resetStoredValueExists(key)) keys.add(key);
4224}
4225}
4226async function resetStoredValueExists(key) {
4227const getValue = asyncGmGetValue();
4228if (getValue) {
4229try {
4230const epoch = await authoritativeManagedStateEpoch(getValue);
4231const storageKey = managedStateStorageKey(key, epoch);
4232const stored = await getValue(storageKey, MISSING);
4233if (!isMissingSentinel(stored)) return true;
4234if (storageKey !== key) {
4235const logical = await getValue(key, MISSING);
4236if (!isMissingSentinel(logical)) return true;
4237}
4238return false;
4239} catch (error) {
4240throw new ManagedStateResetError(`Factory reset could not inspect "${key}".`, { cause: error });
4241}
4242}
4243return resetWebStorageHasKey(localStorage, key, "localStorage") || resetWebStorageHasKey(sessionStorage, key, "sessionStorage");
4244}
4245async function storedValueExists(key) {
4246const getValue = asyncGmGetValue();
4247if (getValue) {
4248try {
4249const epoch2 = await assertRealmManagedStateEpoch(getValue);
4250const read = await readManagedGmValue(getValue, key, epoch2);
4251if (read.kind === "found") return true;
4252if (read.kind === "deleted") return false;
4253} catch (error) {
4254if (isStaleManagedStateEpochError(error)) throw error;
4255debugStorageError("GM storage existence check failed", key, error);
4256}
4257}
4258const epoch = managedStateEpochForSynchronousLocalRead();
4259return Boolean(epoch && localMirrorBelongsToEpoch(key, epoch) && (webStorageHasKey(localStorage, key) || webStorageHasKey(sessionStorage, key)));
4260}
4261function resetWebStorageHasKey(storage, key, label) {
4262try {
4263return storage.getItem(key) !== null;
4264} catch (error) {
4265throw new ManagedStateResetError(`Factory reset could not verify ${label} key "${key}".`, { cause: error });
4266}
4267}
4268function restoreLocalFallbackStoredValue(key, value, existed) {
4269restoreLocalFallbackStoredValueAtEpoch(key, value, existed, managedStateEpochForSynchronousLocalRead());
4270}
4271async function clearManagedIndexedDatabases() {
4272await Promise.all(registeredManagedIndexedDbNames().map(deleteIndexedDbDatabase));
4273}
4274function isManagedBrowserCacheName(name) {
4275return MANAGED_CACHE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix));
4276}
4277function isManagedServiceWorkerRegistration(registration) {
4278return [
4279registration.scope,
4280registration.active?.scriptURL,
4281registration.installing?.scriptURL,
4282registration.waiting?.scriptURL
4283].some(hasManagedYomuServiceWorkerPath);
4284}
4285function hasManagedYomuServiceWorkerPath(value) {
4286if (typeof value !== "string") return false;
4287try {
4288const url = new URL(value, location.href);
4289if (!isManagedServiceWorkerOrigin(url)) return false;
4290return url.pathname === "/sw.js" || url.pathname.endsWith("/sw.js") || url.pathname.includes("/study/") || url.pathname.includes("/newtab/") || url.pathname.includes("/pdf-reader/") || url.pathname.includes("/video-player/");
4291} catch {
4292return value.includes("/study/") || value.includes("/newtab/") || value.includes("/pdf-reader/") || value.includes("/video-player/") || value.endsWith("/sw.js");
4293}
4294}
4295function isManagedServiceWorkerOrigin(url) {
4296return url.origin === DOCS_ORIGIN || url.hostname === "hrussellzfac023.github.io" || /^(127\.0\.0\.1|localhost|\[::1\])$/.test(url.hostname);
4297}
4298function deleteIndexedDbDatabase(name) {
4299if (typeof indexedDB === "undefined") return Promise.resolve();
4300return new Promise((resolve, reject) => {
4301let blockedCause;
4302let settled = false;
4303const finish = (complete) => {
4304if (settled) return;
4305settled = true;
4306clearTimeout(timeout);
4307complete();
4308};
4309const timeout = setTimeout(() => finish(() => reject(new ManagedStateResetError(
4310blockedCause ? `IndexedDB deletion remained blocked for "${name}". Close other よむ tabs and retry.` : `IndexedDB deletion timed out for "${name}".`,
4311{ cause: blockedCause }
4312))), MANAGED_IDB_DELETE_TIMEOUT_MS);
4313try {
4314const request = indexedDB.deleteDatabase(name);
4315request.onsuccess = () => finish(resolve);
4316request.onerror = (error) => {
4317debugStorageError("IndexedDB delete failed", name, error);
4318finish(() => reject(new ManagedStateResetError(`IndexedDB failed to delete "${name}".`, { cause: error })));
4319};
4320request.onblocked = (error) => {
4321debugStorageError("IndexedDB delete blocked", name, error);
4322blockedCause = error;
4323};
4324} catch (error) {
4325debugStorageError("IndexedDB delete threw", name, error);
4326finish(() => reject(new ManagedStateResetError(`IndexedDB could not delete "${name}".`, { cause: error })));
4327}
4328});
4329}
4330function normalizeFactoryResetSignal(signal) {
4331return {
4332id: normalizedFactoryResetId(signal.id),
4333phase: normalizedFactoryResetPhase(signal.phase),
4334at: normalizedFactoryResetAt(signal.at),
4335href: normalizedFactoryResetHref(signal.href)
4336};
4337}
4338function normalizedFactoryResetId(id) {
4339return String(id || createStorageCoordinationId());
4340}
4341function normalizedFactoryResetPhase(phase) {
4342return phase === "complete" ? "complete" : "prepare";
4343}
4344function normalizedFactoryResetAt(at) {
4345return typeof at === "number" && Number.isFinite(at) ? at : Date.now();
4346}
4347function normalizedFactoryResetHref(href) {
4348return typeof href === "string" ? href : location.href;
4349}
4350function parseFactoryResetSignal(value) {
4351const parsed = typeof value === "string" ? parseJsonRecord(value) : value;
4352if (!isFactoryResetSignalRecord(parsed)) return null;
4353const record2 = parsed;
4354if (!isValidFactoryResetPhase(record2.phase)) return null;
4355return {
4356id: record2.id,
4357phase: record2.phase,
4358at: factoryResetSignalTime(record2.at),
4359href: factoryResetSignalHref(record2.href)
4360};
4361}
4362function factoryResetSignalTime(value) {
4363return typeof value === "number" && Number.isFinite(value) ? value : Date.now();
4364}
4365function factoryResetSignalHref(value) {
4366return typeof value === "string" ? value : "";
4367}
4368function isFactoryResetSignalRecord(value) {
4369return Boolean(value && typeof value === "object" && !Array.isArray(value) && typeof value.id === "string" && value.id?.trim());
4370}
4371function isValidFactoryResetPhase(value) {
4372return value === "prepare" || value === "complete";
4373}
4374function parseJsonRecord(value) {
4375try {
4376return JSON.parse(value);
4377} catch {
4378return null;
4379}
4380}
4381function publishBroadcastFactoryResetSignal(signal) {
4382if (typeof BroadcastChannel !== "function") return;
4383try {
4384const owner = managedStorageOwner();
4385const channel = new BroadcastChannel(`${FACTORY_RESET_CHANNEL_NAME}:${owner}`);
4386channel.postMessage({ owner, signal });
4387channel.close();
4388} catch (error) {
4389debugStorageError("Broadcast factory reset publish failed", FACTORY_RESET_CHANNEL_NAME, error);
4390}
4391}
4392async function assertManagedStateMutationFence(getValue, expected) {
4393const before = getValue ? await authoritativeManagedStateEpoch(getValue) : parseManagedStateEpoch(localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0));
4394if (!sameManagedStateEpoch(expected, before)) throw new StaleManagedStateEpochError(expected, before);
4395const rawSignal = getValue ? await getValue(FACTORY_RESET_SIGNAL_KEY, MISSING) : localStorageGet(FACTORY_RESET_SIGNAL_KEY, MISSING);
4396const signal = isMissingSentinel(rawSignal) ? null : parseFactoryResetSignal(rawSignal);
4397if (signal?.phase === "prepare" || managedStateWritesSuppressed()) {
4398throw new Error("Managed state writes are suppressed during factory reset.");
4399}
4400const after = getValue ? await authoritativeManagedStateEpoch(getValue) : parseManagedStateEpoch(localStorageGet(MANAGED_STATE_EPOCH_KEY, void 0));
4401if (!sameManagedStateEpoch(expected, after)) throw new StaleManagedStateEpochError(expected, after);
4402}
4403function debugStorageError(message, key, error) {
4404if (typeof console !== "undefined") console.debug("[Yomu] Storage", message, { key, error });
4405}
4406const STORAGE_RUNTIME_API_SLOT = Symbol.for("yomu.storage-runtime-api.v1");
4407function registerStorageRuntimeApi(api) {
4408Object.defineProperty(globalThis, STORAGE_RUNTIME_API_SLOT, {
4409configurable: true,
4410enumerable: false,
4411value: api,
4412writable: true
4413});
4414}
4415registerStorageRuntimeApi({
4416managedLocalStorage,
4417managedSessionStorage,
4418hasAsyncGmStorageBackend,
4419assertManagedStateMutationAllowed,
4420ensureManagedWebStorageCurrent,
4421ensureManagedWebStorageCurrentSync,
4422localFallbackStoredValue,
4423gmStorageGet,
4424gmStorageGetForResetEnumeration,
4425gmPrivateStorageGet,
4426withGmStorageLease,
4427gmStorageGetSync,
4428gmStorageGetSharedSync,
4429gmStorageSet,
4430gmPrivateStorageSet,
4431gmStorageSetSync,
4432gmStorageDelete,
4433gmPrivateStorageDelete,
4434gmStorageDeleteSync,
4435exportManagedStoredValues,
4436importStoredValues,
4437clearManagedBrowserCaches,
4438unregisterManagedServiceWorkers,
4439subscribeToStoredValueChanges,
4440storedValueExists,
4441isHostedYomuOrigin
4442});
4443const SYNTHETIC_INTERACTION_TEST_SLOT = void 0;
4444function syntheticEventsAllowed() {
4445return SYNTHETIC_INTERACTION_TEST_SLOT !== void 0;
4446}
4447function sandboxSharedState(key, create) {
4448const realm = globalThis;
4449const slot = Symbol.for(key);
4450const existing = realm[slot];
4451if (existing && typeof existing === "object") return existing;
4452const created = create();
4453Object.defineProperty(realm, slot, {
4454configurable: true,
4455enumerable: false,
4456value: created,
4457writable: true
4458});
4459return created;
4460}
4461const GUARD_LIFETIME_MS = 750;
4462const COORDINATE_SLOP_PX = 16;
4463const { guardedDocuments, guards } = sandboxSharedState("yomu.compat-guard.v1", () => ({
4464guardedDocuments: new WeakSet(),
4465guards: new WeakMap()
4466}));
4467function installCompatibilityGuard(target) {
4468if (guardedDocuments.has(target)) return;
4469guardedDocuments.add(target);
4470const clear = (event) => {
4471if (blockable(event)) guards.delete(target);
4472};
4473for (const eventName of ["pointerdown", "pointerover", "pointermove", "pointercancel"]) {
4474target.defaultView?.addEventListener(eventName, clear, { capture: true, passive: true });
4475target.addEventListener(eventName, clear, { capture: true, passive: true });
4476}
4477for (const eventName of ["mouseover", "mouseenter", "mousemove", "mousedown", "mouseup", "click"]) {
4478target.addEventListener(eventName, (event) => suppressCompatibilityEvent(event, target), {
4479capture: true,
4480passive: false
4481});
4482}
4483}
4484function armCompatibilityGuard(target, event, startX, startY) {
4485installCompatibilityGuard(target);
4486guards.set(target, {
4487minX: Math.min(startX, event.clientX),
4488maxX: Math.max(startX, event.clientX),
4489minY: Math.min(startY, event.clientY),
4490maxY: Math.max(startY, event.clientY),
4491expiresAt: Date.now() + GUARD_LIFETIME_MS
4492});
4493}
4494function suppressCompatibilityEvent(event, target) {
4495const guard = eligibleCompatibilityGuard(event, target);
4496if (!guard) return;
4497if (!shouldSuppressCompatibilityEvent(event, guard)) return;
4498consumeCompatibilityEvent(event, target);
4499}
4500function eligibleCompatibilityGuard(event, target) {
4501const guard = guards.get(target);
4502if (!guard) return null;
4503if (!blockable(event)) return null;
4504if (Date.now() <= guard.expiresAt) return guard;
4505guards.delete(target);
4506return null;
4507}
4508function shouldSuppressCompatibilityEvent(event, guard) {
4509return withinGestureEnvelope(event, guard) && isCompatibilityMouseEvent(event);
4510}
4511function consumeCompatibilityEvent(event, target) {
4512event.preventDefault();
4513event.stopImmediatePropagation();
4514if (event.type === "click") guards.delete(target);
4515}
4516function blockable(event) {
4517return event.isTrusted || syntheticEventsAllowed();
4518}
4519function withinGestureEnvelope(event, guard) {
4520return event.clientX >= guard.minX - COORDINATE_SLOP_PX && event.clientX <= guard.maxX + COORDINATE_SLOP_PX && event.clientY >= guard.minY - COORDINATE_SLOP_PX && event.clientY <= guard.maxY + COORDINATE_SLOP_PX;
4521}
4522function isCompatibilityMouseEvent(event) {
4523return event.type !== "click" || event.detail > 0;
4524}
4525const CONTROL_POINTER_ACTIVATION_SELECTOR = [
4526"button",
4527"a[href]",
4528"summary",
4529'[role="button"]',
4530'[role="checkbox"]',
4531'[role="link"]',
4532'[role="menuitem"]',
4533'[role="option"]',
4534'[role="radio"]',
4535'[role="switch"]',
4536'[role="tab"]',
4537"[data-action]",
4538"[data-token-choice]"
4539].join(",");
4540function closestReaderControlElement(target) {
4541return closestHtmlElementMatching(target, CONTROL_POINTER_ACTIVATION_SELECTOR);
4542}
4543function closestHtmlElementMatching(target, selector) {
4544let element = target instanceof Element ? target : null;
4545while (element) {
4546const match = matchingHtmlElement(element, selector);
4547if (match) return match;
4548element = element.parentElement;
4549}
4550return null;
4551}
4552function matchingHtmlElement(element, selector) {
4553if (!(element instanceof HTMLElement)) return null;
4554if (!element.matches(selector)) return null;
4555return element;
4556}
4557function readerControlIsDisabled(control) {
4558if (control.closest('[aria-disabled="true"]')) return true;
4559return control.matches(":disabled, fieldset[disabled] *");
4560}
4561const READER_ROOT_SELECTOR$4 = "[data-jpdb-reader-root]";
4562const TAP_SLOP_PX = 12;
4563const DRAG_HANDLE_SLOP_PX = 8;
4564const BOUNDARY_EVENTS = [
4565"click",
4566"dblclick",
4567"auxclick",
4568"contextmenu",
4569"beforeinput",
4570"input",
4571"change",
4572"submit",
4573"keydown",
4574"keyup",
4575"paste",
4576"drop",
4577"pointerdown",
4578"pointermove",
4579"pointerup",
4580"pointercancel",
4581"pointerover",
4582"pointerout",
4583"mousedown",
4584"mousemove",
4585"mouseup",
4586"mouseover",
4587"mouseout",
4588"touchstart",
4589"touchmove",
4590"touchend",
4591"touchcancel",
4592"wheel"
4593];
4594const sharedState = sandboxSharedState("yomu.trusted-interaction.v1", () => ({
4595pendingClick: {},
4596authorizedClicks: new WeakSet(),
4597authorizedEvents: new WeakSet(),
4598localActivationRoots: new WeakSet(),
4599documentActivation: new WeakMap()
4600}));
4601function claimLocalTapActivation(root) {
4602if (sharedState.localActivationRoots.has(root)) return false;
4603sharedState.localActivationRoots.add(root);
4604return true;
4605}
4606function isDirectTrustedReaderInteraction(event) {
4607return event.isTrusted || sharedState.authorizedClicks.has(event) || sharedState.authorizedEvents.has(event) || syntheticEventsAllowed();
4608}
4609function isTrustedReaderInteraction(event) {
4610return isDirectTrustedReaderInteraction(event) || isHostedYomuOrigin();
4611}
4612function trustedReaderEventHandler(handler) {
4613return (event) => {
4614if (isTrustedReaderInteraction(event)) handler(event);
4615};
4616}
4617function blockUntrustedRootInteraction(event) {
4618const target = event.target instanceof Element ? event.target : null;
4619if (claimReaderControlClick(event, target)) return;
4620if (!mustBlockRootInteraction(event, target)) return;
4621event.preventDefault();
4622event.stopImmediatePropagation();
4623}
4624function mustBlockRootInteraction(event, target) {
4625return Boolean(readerRootFromEvent(event, target)) && !isTrustedReaderInteraction(event);
4626}
4627function readerRootFromEvent(event, target) {
4628return closestReaderRoot(target) ?? eventComposedPath(event).map(closestReaderRoot).find((root) => Boolean(root)) ?? null;
4629}
4630function closestReaderRoot(node) {
4631return node instanceof Element ? node.closest(READER_ROOT_SELECTOR$4) : null;
4632}
4633function eventComposedPath(event) {
4634return typeof event.composedPath === "function" ? event.composedPath() : [];
4635}
4636function installTrustedReaderRootBoundary(target, signal) {
4637installCompatibilityGuard(target);
4638for (const eventName of BOUNDARY_EVENTS) {
4639target.addEventListener(eventName, blockUntrustedRootInteraction, {
4640capture: true,
4641passive: false,
4642signal
4643});
4644}
4645installDocumentTapActivation(target, signal);
4646}
4647function installDocumentTapActivation(documentTarget, signal) {
4648if (!claimDocumentActivation(documentTarget, signal)) return;
4649const resolveControl = (target) => documentControlTarget(target, documentTarget);
4650installControlTapActivation(documentTarget, documentTarget, resolveControl, { signal });
4651}
4652function installControlTapActivation(listenTarget, ownerDocument, resolveControl, options = {}) {
4653const state = {};
4654const listenerOptions = { capture: true, passive: false, signal: options.signal };
4655listenTarget.addEventListener("pointerdown", (event) => updatePointerTap(state, event, resolveControl), listenerOptions);
4656listenTarget.addEventListener("pointerup", (event) => activatePointerTap(state, event, ownerDocument, resolveControl, options), listenerOptions);
4657listenTarget.addEventListener("pointermove", (event) => invalidateMovedTap(state, event), listenerOptions);
4658listenTarget.addEventListener("pointercancel", (event) => clearPointerTap(state, event), listenerOptions);
4659}
4660function claimDocumentActivation(target, signal) {
4661if (documentActivationHeld(target)) return false;
4662const lifecycle = signal ?? null;
4663sharedState.documentActivation.set(target, lifecycle);
4664lifecycle?.addEventListener("abort", () => {
4665if (sharedState.documentActivation.get(target) === lifecycle) sharedState.documentActivation.delete(target);
4666}, { once: true });
4667return true;
4668}
4669function documentActivationHeld(target) {
4670if (!sharedState.documentActivation.has(target)) return false;
4671const lifecycle = sharedState.documentActivation.get(target);
4672return lifecycle === null || !lifecycle.aborted;
4673}
4674function updatePointerTap(state, event, resolveControl) {
4675const control = downControl(event, resolveControl);
4676if (control) state.tap = { pointerId: event.pointerId, target: control.target, root: control.root, x: event.clientX, y: event.clientY };
4677else clearPointerTap(state, event);
4678}
4679function downControl(event, resolveControl) {
4680if (event.button !== 0) return null;
4681return isDirectControlPointer(event) ? resolveControl(event.target) : null;
4682}
4683function activatePointerTap(state, event, ownerDocument, resolveControl, options) {
4684const tap = consumePointerTap(state, event);
4685if (!tap) return;
4686const control = releasedControl(tap, event, resolveControl);
4687if (!control) return;
4688event.preventDefault();
4689if (options.stopOnActivate) event.stopPropagation();
4690armCompatibilityGuard(ownerDocument, event, tap.x, tap.y);
4691dispatchAuthorizedReaderControlClick(control.target);
4692}
4693function consumePointerTap(state, event) {
4694if (state.tap?.pointerId !== event.pointerId) return void 0;
4695const tap = state.tap;
4696state.tap = void 0;
4697return tap;
4698}
4699function releasedControl(tap, event, resolveControl) {
4700if (!isDirectControlPointer(event)) return null;
4701if (!tapWithinSlop(tap, event)) return null;
4702const control = resolveControl(event.target);
4703return matchesTap(control, tap) ? control : null;
4704}
4705function matchesTap(control, tap) {
4706return control?.target === tap.target && control.root === tap.root;
4707}
4708function invalidateMovedTap(state, event) {
4709if (!state.tap || state.tap.pointerId !== event.pointerId) return;
4710if (!tapWithinSlop(state.tap, event)) delete state.tap;
4711}
4712function clearPointerTap(state, event) {
4713if (state.tap?.pointerId === event.pointerId) state.tap = void 0;
4714}
4715function tapWithinSlop(tap, event) {
4716const deltaX = Math.abs(event.clientX - tap.x);
4717const deltaY = Math.abs(event.clientY - tap.y);
4718if (tap.target.hasAttribute("data-subtitle-rail-drag-handle")) {
4719return deltaX + deltaY <= DRAG_HANDLE_SLOP_PX;
4720}
4721return Math.hypot(deltaX, deltaY) <= TAP_SLOP_PX;
4722}
4723function isDirectControlPointer(event) {
4724return isDirectTrustedReaderInteraction(event) && (event.pointerType === "pen" || event.pointerType === "touch") && event.isPrimary !== false;
4725}
4726function documentControlTarget(target, documentTarget) {
4727const control = enabledReaderControl(target);
4728if (!control) return null;
4729const root = documentControlRoot(control, documentTarget);
4730return root ? { target: control, root } : null;
4731}
4732function enabledReaderControl(target) {
4733const control = closestReaderControlElement(target);
4734return control && !readerControlIsDisabled(control) ? control : null;
4735}
4736function documentControlRoot(control, target) {
4737const root = control.closest(READER_ROOT_SELECTOR$4);
4738if (!root || root.ownerDocument !== target) return null;
4739return hasLocalTapActivation(root) ? null : root;
4740}
4741function hasLocalTapActivation(root) {
4742return root instanceof HTMLElement && sharedState.localActivationRoots.has(root);
4743}
4744function dispatchAuthorizedReaderControlClick(target) {
4745if (sharedState.pendingClick.grant) return;
4746const grant = { target };
4747sharedState.pendingClick.grant = grant;
4748try {
4749target.click();
4750} finally {
4751if (grant.event) sharedState.authorizedClicks.delete(grant.event);
4752if (sharedState.pendingClick.grant === grant) sharedState.pendingClick.grant = void 0;
4753}
4754}
4755function claimReaderControlClick(event, target) {
4756const grant = sharedState.pendingClick.grant;
4757if (!grant || event.type !== "click" || target !== grant.target) return false;
4758sharedState.pendingClick.grant = void 0;
4759grant.event = event;
4760sharedState.authorizedClicks.add(event);
4761return true;
4762}
4763function isTrustedAccountDataSurface(value) {
4764const appUrl = readTrustedYomuUrl(value);
4765if (!appUrl) return false;
4766if (![isYomuNewTabUrl(value), appUrl.originKind !== "docs-preview"].every(Boolean)) return false;
4767return [
4768appUrl.originKind !== "loopback",
4769isPrivilegedYomuLocalDevelopmentOrigin(appUrl.url.origin)
4770].some(Boolean);
4771}
4772function currentAccountDataSurfaceIsTrusted() {
4773return typeof location !== "undefined" && isTrustedAccountDataSurface(location.href);
4774}
4775const DECK_CLASS_NAME_LIMIT = 8;
4776function cardDeckMembership(card) {
4777const names = cardDeckNames(card);
4778return {
4779source: cardDeckMembershipSource(card),
4780names,
4781member: hasPrimaryDeckMembership(card) || hasAnkiDeckMembership(card)
4782};
4783}
4784function cardDeckNames(card) {
4785return uniqueDeckNames([
4786...primaryDeckNames(card),
4787...ankiDeckNames(card)
4788]);
4789}
4790function cardDeckMembershipClassNames(card) {
4791const membership = cardDeckMembership(card);
4792if (!membership.member) return [];
4793if (!currentAccountDataSurfaceIsTrusted()) return ["yomu-deck-member"];
4794return trustedDeckMembershipClassNames(card);
4795}
4796function trustedDeckMembershipClassNames(card) {
4797const classes = new Set(["yomu-deck-member"]);
4798const memberships = [
4799{ member: hasPrimaryDeckMembership(card), source: primaryDeckMembershipSource(card), names: primaryDeckNames(card) },
4800{ member: hasAnkiDeckMembership(card), source: "anki", names: ankiDeckNames(card) }
4801];
4802memberships.filter((membership) => membership.member).forEach((membership) => addDeckSourceClasses(classes, membership.source, membership.names));
4803return [...classes];
4804}
4805function deckClassSlug(value) {
4806const slug = value.normalize("NFKC").trim().toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 64);
4807return slug || "unnamed";
4808}
4809function uniqueDeckNames(values) {
4810const seen = new Set();
4811return values.map((value) => value?.trim() ?? "").filter((value) => {
4812if (!value || seen.has(value)) return false;
4813seen.add(value);
4814return true;
4815});
4816}
4817function cardDeckMembershipSource(card) {
4818if (!hasPrimaryDeckMembership(card) && hasAnkiDeckMembership(card)) return "anki";
4819return primaryDeckMembershipSource(card);
4820}
4821function primaryDeckMembershipSource(card) {
4822return card.source ?? (card.reviewSource === "jiten-api" ? "jiten" : "jpdb");
4823}
4824function primaryDeckNames(card) {
4825return uniqueDeckNames([
4826...card.deckNames ?? [],
4827card.sourceDeckName ?? ""
4828]);
4829}
4830function ankiDeckNames(card) {
4831return uniqueDeckNames(card.ankiDeckNames ?? []);
4832}
4833function hasPrimaryDeckMembership(card) {
4834return primaryDeckNames(card).length > 0 || card.cardState.includes("in-deck") || Boolean(card.jpdbDeckMembership?.trim());
4835}
4836function hasAnkiDeckMembership(card) {
4837return ankiDeckNames(card).length > 0;
4838}
4839function addDeckSourceClasses(classes, source, names) {
4840classes.add(`${source}-deck-member`);
4841names.slice(0, DECK_CLASS_NAME_LIMIT).forEach((name) => {
4842const slug = deckClassSlug(name);
4843classes.add(`yomu-deck-${slug}`);
4844classes.add(`${source}-deck-${slug}`);
4845});
4846}
4847const CARD_STATES = new Set([
4848"new",
4849"learning",
4850"young",
4851"mature",
4852"known",
4853"mastered",
4854"due",
4855"failed",
4856"locked",
4857"never-forget",
4858"blacklisted",
4859"suspended",
4860"in-deck",
4861"not-in-deck",
4862"redundant",
4863"frequent",
4864"unparsed"
4865]);
4866const CARD_STATE_ALIASES = {
4867never_forget: "never-forget",
4868neverforget: "never-forget",
4869"never forget": "never-forget",
4870not_in_deck: "not-in-deck",
4871notindeck: "not-in-deck",
4872"not in deck": "not-in-deck",
4873in_deck: "in-deck",
4874indeck: "in-deck",
4875"in deck": "in-deck",
4876blacklist: "blacklisted",
4877blacklisted: "blacklisted",
4878ignored: "blacklisted",
4879unknown: "new"
4880};
4881function normalizeCardState(value) {
4882const keys = normalizedCardStateKeys(value);
4883if (!keys) return null;
4884const aliased = aliasedCardState(keys.trimmed, keys.dashed, keys.compact);
4885if (aliased) return aliased;
4886return knownCardState(keys.dashed);
4887}
4888function normalizedCardStateKeys(value) {
4889if (typeof value !== "string") return null;
4890const trimmed = value.trim().toLowerCase();
4891if (!trimmed) return null;
4892return {
4893trimmed,
4894dashed: trimmed.replace(/[_\s]+/g, "-"),
4895compact: trimmed.replace(/[_\s-]+/g, "")
4896};
4897}
4898function aliasedCardState(...keys) {
4899return keys.map((key) => CARD_STATE_ALIASES[key]).find(Boolean);
4900}
4901function knownCardState(value) {
4902if (CARD_STATES.has(value)) return value;
4903return null;
4904}
4905function normalizeCardStates(value, fallback = "not-in-deck") {
4906const states = uniqueNormalizedCardStates(Array.isArray(value) ? value : [value]);
4907return states.length ? states : [fallback];
4908}
4909function uniqueNormalizedCardStates(rawStates) {
4910const states = [];
4911for (const rawState of rawStates) {
4912appendNormalizedCardState(states, rawState);
4913}
4914return states;
4915}
4916function appendNormalizedCardState(states, rawState) {
4917const state = normalizeCardState(rawState);
4918if (!state || states.includes(state)) return;
4919states.push(state);
4920}
4921function primaryCardState(value) {
4922return normalizeCardStates(value)[0] ?? "not-in-deck";
4923}
4924const UNIFIED_IDEOGRAPH_RE = /^\p{Unified_Ideograph}$/u;
4925function isUnifiedIdeograph(value) {
4926return UNIFIED_IDEOGRAPH_RE.test(value);
4927}
4928const HIRAGANA = "぀-ゟ";
4929const KATAKANA = "゠-ヿ";
4930const KANA = "぀-ヿ";
4931const HALFWIDTH_KATAKANA = "ヲ-゚";
4932const KANJI = "㐀-鿿";
4933const UNIFIED_IDEOGRAPH = "\\p{Unified_Ideograph}";
4934const SUPPLEMENTARY_KANJI_PATTERN = `(?:(?![\\u0000-\\uFFFF])${UNIFIED_IDEOGRAPH})`;
4935const KANJI_PATTERN = `(?:[${KANJI}]|${SUPPLEMENTARY_KANJI_PATTERN})`;
4936const ITERATION_MARK = "々";
4937const ITERATION_MARKS = `${ITERATION_MARK}〆`;
4938const KANA_COUNTERS = "ヵヶ";
4939const PROLONGED_SOUND_MARK = "ー";
4940const KATAKANA_MIDDLE_DOT = "・";
4941const JAPANESE_SENTENCE_PUNCTUATION = "、。!?・";
4942const COMBINING_KANA_MARKS = "゙゚";
4943const HIRAGANA_LETTERS = "ぁ-ゖゝ-ゟ";
4944const KATAKANA_LETTERS = "ァ-ヺヽ-ヿ";
4945const HALFWIDTH_KATAKANA_LETTERS = "ヲ-ッア-ン";
4946const KANJI_LIKE = `${KANJI}${ITERATION_MARKS}`;
4947const KANJI_LIKE_WITH_COUNTERS = `${KANJI_LIKE}${KANA_COUNTERS}`;
4948const KANJI_LIKE_WITH_COUNTERS_PATTERN = `(?:${KANJI_PATTERN}|[${ITERATION_MARKS}${KANA_COUNTERS}])`;
4949const HIRAGANA_WITH_PROLONGED = `${HIRAGANA}${PROLONGED_SOUND_MARK}`;
4950const KATAKANA_WITH_PROLONGED = `${KATAKANA}${PROLONGED_SOUND_MARK}`;
4951const KANA_WITH_PROLONGED = `${KANA}${PROLONGED_SOUND_MARK}`;
4952const READING_KANA = `${KANA}${PROLONGED_SOUND_MARK}${KATAKANA_MIDDLE_DOT}`;
4953const JAPANESE_SCRIPT = `${KANA}${KANJI}${ITERATION_MARKS}${HALFWIDTH_KATAKANA}`;
4954const JAPANESE_LETTERS = `${HIRAGANA_LETTERS}${KATAKANA_LETTERS}${KANJI}${HALFWIDTH_KATAKANA_LETTERS}`;
4955const HAS_JAPANESE = new RegExp(`(?:[${JAPANESE_SCRIPT}]|${SUPPLEMENTARY_KANJI_PATTERN})`, "u");
4956const HAS_JAPANESE_LETTER = new RegExp(`(?:[${JAPANESE_LETTERS}]|${SUPPLEMENTARY_KANJI_PATTERN})`, "u");
4957const KANJI_RE$1 = new RegExp(KANJI_PATTERN, "u");
4958const KANA_ONLY_RUN_RE = new RegExp(`^[${KANA_WITH_PROLONGED}]+$`, "u");
4959const READING_KANA_ONLY_RE = new RegExp(`^[${READING_KANA}]+$`, "u");
4960const BMP_KANJI_CHARACTER_RE = new RegExp(`^[${KANJI}]$`, "u");
4961function isJapaneseKanjiCharacter(value) {
4962return BMP_KANJI_CHARACTER_RE.test(value) || value.length > 1 && isUnifiedIdeograph(value);
4963}
4964const PITCH_LEVELS = new Set(["H", "L"]);
4965const SMALL_KANA = new Set("ゃゅょぁぃぅぇぉゎャュョァィゥェォヮ゙゚");
4966const PRONUNCIATION_KANA = new RegExp(`^[${KANA}${COMBINING_KANA_MARKS}]+$`, "u");
4967const PITCH_CLASS_RULES = [
4968{ className: "heiban", matches: (pitchNumber) => pitchNumber === 0 },
4969{ className: "atamadaka", matches: (pitchNumber) => pitchNumber === 1 },
4970{ className: "odaka", matches: (pitchNumber, moraCount) => pitchNumber === moraCount },
4971{ className: "nakadaka", matches: (pitchNumber, moraCount) => pitchNumber > 1 && pitchNumber < moraCount }
4972];
4973function normalizePitchPatternForReading(pattern, reading) {
4974const levels = pitchLevels(pattern);
4975if (!levels.length) return "";
4976return normalizePitchLevelsForReading(levels, reading).join("");
4977}
4978function normalizePitchPatternsForReading(patterns, reading) {
4979return (patterns ?? []).map((pattern) => normalizePitchPatternForReading(pattern, reading)).filter(Boolean);
4980}
4981function pitchLevelsForDisplay(pattern, reading) {
4982return normalizePitchPatternForReading(pattern, reading).slice(0, countMorae(reading)).split("");
4983}
4984function pitchLevels(pattern) {
4985return Array.from(pattern).filter((level) => PITCH_LEVELS.has(level));
4986}
4987function splitMorae(reading) {
4988if (!PRONUNCIATION_KANA.test(reading)) return [];
4989const morae = [];
4990for (const char of Array.from(reading)) {
4991if (morae.length && SMALL_KANA.has(char)) morae[morae.length - 1] += char;
4992else morae.push(char);
4993}
4994return morae;
4995}
4996function countMorae(reading) {
4997return splitMorae(reading).length;
4998}
4999function pitchPatternFromPosition(reading, position) {
5000const moraCount = countMorae(reading);
5001if (!moraCount || !Number.isInteger(position) || position < 0 || position > moraCount) return "";
5002if (position === 0) return `L${"H".repeat(moraCount)}`;
5003if (position === 1) return `H${"L".repeat(moraCount)}`;
5004const highMorae = position - 1;
5005const lowTail = moraCount - position + 1;
5006return `L${"H".repeat(highMorae)}${"L".repeat(lowTail)}`;
5007}
5008function pitchProfileForPattern(pattern, reading) {
5009const normalized = normalizePitchPatternForReading(pattern, reading);
5010const morae = splitMorae(reading);
5011const pitchNumber = pitchNumberFromPattern(normalized, reading);
5012return {
5013reading,
5014morae,
5015pitchNumber,
5016pattern: normalized,
5017className: pitchClassNameFromProfile(morae.length, pitchNumber)
5018};
5019}
5020function pitchClassNameForPattern(pattern, reading) {
5021return pitchProfileForPattern(pattern, reading).className;
5022}
5023function collectPitchVariants(reading, patterns, max = Infinity) {
5024const seen = new Set();
5025const variants = [];
5026for (const pattern of patterns) {
5027if (pitchClassNameForPattern(pattern, reading) === "" || !pitchLevelsForDisplay(pattern, reading).join("")) continue;
5028const position = pitchNumberFromPattern(pattern, reading);
5029const key = position != null ? `#${position}` : pitchLevelsForDisplay(pattern, reading).join("");
5030if (seen.has(key)) continue;
5031seen.add(key);
5032variants.push({ pattern, position });
5033if (variants.length >= max) break;
5034}
5035return variants;
5036}
5037function contextPitchPattern(patterns, reading) {
5038if (!patterns?.length) return "";
5039if (!reading) return patterns[0];
5040return patterns.find((pattern) => pitchClassNameForPattern(pattern, reading) !== "") ?? "";
5041}
5042function pitchNumberForReading(patterns, reading) {
5043if (!reading || !patterns?.length) return null;
5044const pattern = contextPitchPattern(patterns, reading);
5045if (!pattern) return null;
5046return pitchNumberFromPattern(pattern, reading);
5047}
5048function pitchNumberFromPattern(pattern, reading) {
5049const levels = pitchLevels(normalizePitchPatternForReading(pattern, reading));
5050const moraCount = countMorae(reading);
5051if (!moraCount) return null;
5052if (levels.length < moraCount) return looksLikeCompactHeibanPattern(levels) ? 0 : null;
5053if (levels.length > moraCount + 1) return null;
5054for (let position = 0; position <= moraCount; position += 1) {
5055const expected = pitchLevels(pitchPatternFromPosition(reading, position));
5056if (levels.every((level, index) => expected[index] === level)) return position;
5057}
5058return null;
5059}
5060function looksLikeCompactHeibanPattern(levels) {
5061return levels.length >= 2 && levels[0] === "L" && levels.slice(1).every((level) => level === "H");
5062}
5063function pitchClassNameFromProfile(moraCount, pitchNumber) {
5064if (!moraCount || pitchNumber == null) return "";
5065return PITCH_CLASS_RULES.find((rule) => rule.matches(pitchNumber, moraCount))?.className ?? "";
5066}
5067function normalizePitchLevelsForReading(levels, reading) {
5068const chars = Array.from(reading);
5069if (!levels.length || !chars.some((char) => SMALL_KANA.has(char))) return levels;
5070if (!looksCharacterAlignedPitch(levels, chars)) return levels;
5071const normalized = [];
5072for (let index = 0; index < Math.min(chars.length, levels.length); index++) {
5073if (normalized.length && SMALL_KANA.has(chars[index])) continue;
5074normalized.push(levels[index]);
5075}
5076return normalized.concat(levels.slice(chars.length));
5077}
5078function looksCharacterAlignedPitch(levels, chars) {
5079if (levels.length > splitMorae(chars.join("")).length + 1) return true;
5080if (levels.length < chars.length) return false;
5081return chars.some((char, index) => index > 0 && SMALL_KANA.has(char) && levels[index] === levels[index - 1]);
5082}
5083function getPitchClass(pitchAccent, reading) {
5084const pattern = contextPitchPattern(pitchAccent, reading);
5085return pattern ? pitchClassNameForPattern(pattern, reading) : "";
5086}
5087const SEGMENTER_BY_LOCALE = new Map();
5088function wordSegmenter(locale) {
5089const cached = SEGMENTER_BY_LOCALE.get(locale);
5090if (cached !== void 0) return cached;
5091let segmenter = null;
5092try {
5093if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
5094segmenter = new Intl.Segmenter(locale, { granularity: "word" });
5095}
5096} catch {
5097segmenter = null;
5098}
5099SEGMENTER_BY_LOCALE.set(locale, segmenter);
5100return segmenter;
5101}
5102function icuWordSegments(text2, locale) {
5103const segmenter = wordSegmenter(locale);
5104if (!segmenter) return null;
5105const segments = [];
5106for (const segment of segmenter.segment(text2)) {
5107if (!segment.isWordLike) continue;
5108segments.push({
5109text: segment.segment,
5110start: segment.index,
5111end: segment.index + segment.segment.length
5112});
5113}
5114return segments;
5115}
5116const ANNOTATED_READING_RE = /([^\[\]]+)\[([^\]]+)\]/g;
5117const TRAILING_KANJI_RUN_RE = new RegExp(`((?:${KANJI_PATTERN}|[々〻ヶ])+)$`, "u");
5118function annotatedWordRubies(spelling, annotated) {
5119if (!annotated || !annotated.includes("[")) return [];
5120const rubies = [];
5121let cursor = 0;
5122let baseText = "";
5123let baseOffset = 0;
5124for (const match of annotated.matchAll(ANNOTATED_READING_RE)) {
5125const matchIndex = match.index ?? 0;
5126const captured = match[1] ?? "";
5127const runMatch = captured.match(TRAILING_KANJI_RUN_RE);
5128const base = runMatch ? runMatch[1] : captured;
5129const plain = annotated.slice(cursor, matchIndex) + captured.slice(0, captured.length - base.length);
5130const reading = (match[2] ?? "").trim();
5131baseText += plain;
5132baseOffset += plain.length;
5133const start = baseOffset;
5134baseText += base;
5135baseOffset += base.length;
5136if (base && reading) {
5137rubies.push({ text: reading, start, end: start + base.length, length: base.length });
5138}
5139cursor = matchIndex + match[0].length;
5140}
5141baseText += annotated.slice(cursor);
5142return baseText === spelling ? rubies : [];
5143}
5144function readingFromSurfaceRubies(surface, rubies) {
5145let reading = "";
5146let offset = 0;
5147for (const ruby of rubies.slice().sort((first, second) => first.start - second.start)) {
5148if (ruby.start < offset || ruby.end > surface.length || ruby.end <= ruby.start) continue;
5149reading += unannotatedPronunciationText$1(surface.slice(offset, ruby.start));
5150reading += ruby.text;
5151offset = ruby.end;
5152}
5153reading += unannotatedPronunciationText$1(surface.slice(offset));
5154return reading;
5155}
5156function unannotatedPronunciationText$1(value) {
5157return Array.from(value).filter((character) => !KANJI_RE$1.test(character)).join("");
5158}
5159const PITCH_CLASSES$1 = new Set(["heiban", "atamadaka", "nakadaka", "odaka"]);
5160const MAX_INFERRED_PITCH_LOOKUP_COMPONENTS = 3;
5161function inferredAnnotatedPitchComponents(card) {
5162if (getPitchClass(card.pitchAccent, card.reading || card.spelling) || card.pitchComponents?.length) return [];
5163const spelling = compact(card.spelling);
5164const reading = compact(card.reading);
5165const annotated = compact(card.wordWithReading ?? "");
5166if (!spelling || !reading || !annotated.includes("[")) return [];
5167if (Array.from(spelling).filter((character) => KANJI_RE$1.test(character)).length < 2) return [];
5168const rubies = annotatedWordRubies(spelling, annotated);
5169if (!rubies.length || compact(readingFromSurfaceRubies(spelling, rubies)) !== reading) return [];
5170const rawSegments = icuWordSegments(spelling, "ja");
5171if (!rawSegments) return [];
5172const components = [];
5173for (const segment of rawSegments) {
5174const start = segment.start;
5175const end = segment.end;
5176const readingStart = readingOffsetAtSurfaceBoundary(start, rubies);
5177const readingEnd = readingOffsetAtSurfaceBoundary(end, rubies);
5178if (readingStart < 0 || readingEnd <= readingStart) return [];
5179const component = {
5180spelling: segment.text,
5181reading: reading.slice(readingStart, readingEnd),
5182pitchAccent: [],
5183wordWithReading: null,
5184inferredFromAnnotatedReading: true
5185};
5186const previous = components[components.length - 1];
5187if (previous && !containsKanji$1(previous.spelling) && !containsKanji$1(component.spelling)) {
5188previous.spelling += component.spelling;
5189previous.reading += component.reading;
5190} else {
5191components.push(component);
5192}
5193}
5194if (components.length < 2) return [];
5195if (components.map((component) => component.spelling).join("") !== spelling) return [];
5196if (components.map((component) => component.reading).join("") !== reading) return [];
5197if (components.filter((component) => containsKanji$1(component.spelling)).length > MAX_INFERRED_PITCH_LOOKUP_COMPONENTS) return [];
5198return components.some((component) => containsKanji$1(component.spelling)) ? components : [];
5199}
5200function tiledPitchComponents(card) {
5201if (getPitchClass(card.pitchAccent, card.reading || card.spelling)) return null;
5202const components = card.pitchComponents ?? [];
5203if (components.length < 2) return null;
5204if (compact(components.map((component) => component.spelling).join("")) !== compact(card.spelling)) return null;
5205if (card.reading && compact(components.map((component) => component.reading).join("")) !== compact(card.reading)) return null;
5206return components.map((component) => ({
5207...component,
5208pitchClass: getPitchClass(component.pitchAccent, component.reading || component.spelling)
5209}));
5210}
5211function resolvedPitchComponents(card) {
5212const components = tiledPitchComponents(card);
5213if (!components) return [];
5214return components.every((component) => PITCH_CLASSES$1.has(component.pitchClass)) ? components : [];
5215}
5216function hasResolvedPitchComponents(card) {
5217return resolvedPitchComponents(card).length > 0;
5218}
5219function hasPaintablePitchComponents(card) {
5220const components = tiledPitchComponents(card);
5221return Boolean(components?.some((component) => PITCH_CLASSES$1.has(component.pitchClass)));
5222}
5223function pitchComponentUnderlineGradient(card) {
5224const components = tiledPitchComponents(card);
5225if (!components) return "";
5226if (!components.some((component) => PITCH_CLASSES$1.has(component.pitchClass))) return "";
5227const lengths = components.map((component) => Array.from(component.spelling).length);
5228const total = lengths.reduce((sum, length) => sum + length, 0);
5229if (!total) return "";
5230let offset = 0;
5231const stops = [];
5232components.forEach((component, index) => {
5233const start = offset / total * 100;
5234offset += lengths[index] ?? 0;
5235const end = offset / total * 100;
5236const color = PITCH_CLASSES$1.has(component.pitchClass) ? `var(--jpdb-reader-pitch-${component.pitchClass})` : "var(--jpdb-reader-pitch-unknown)";
5237stops.push(`${color} ${formatPercent(start)}`, `${color} ${formatPercent(end)}`);
5238});
5239return `linear-gradient(to right, ${stops.join(", ")})`;
5240}
5241function compact(value) {
5242return value.replace(/\s+/g, "").trim();
5243}
5244function containsKanji$1(value) {
5245return KANJI_RE$1.test(value);
5246}
5247function readingOffsetAtSurfaceBoundary(offset, rubies) {
5248let readingOffset = offset;
5249for (const ruby of rubies) {
5250if (offset > ruby.start && offset < ruby.end) return -1;
5251if (ruby.end <= offset) readingOffset += ruby.text.length - ruby.length;
5252}
5253return readingOffset;
5254}
5255function formatPercent(value) {
5256return `${Number(value.toFixed(3))}%`;
5257}
5258const RENDERED_WORD_CONTRAST_VARS = [
5259"--jpdb-reader-page-bg",
5260"--jpdb-reader-highlight-backdrop",
5261"--jpdb-reader-word-accessible-color",
5262"--jpdb-reader-word-accessible-highlight",
5263"--jpdb-reader-word-accessible-underline",
5264"--jpdb-reader-word-highlight-text",
5265"--jpdb-reader-word-contrast-shadow"
5266];
5267const RENDERED_WORD_CONTRAST_VARS_WITHOUT_SHADOW = RENDERED_WORD_CONTRAST_VARS.filter(
5268(name) => name !== "--jpdb-reader-word-contrast-shadow"
5269);
5270const AGGREGATE_RUNTIME_MODULES_SLOT = Symbol.for("yomu.aggregate-runtime-modules.v1");
5271function aggregateRuntimeModules() {
5272const modules = globalThis[AGGREGATE_RUNTIME_MODULES_SLOT];
5273if (!isAggregateRuntimeModules(modules)) {
5274throw new Error("The Yomu aggregate runtime Modules are not installed.");
5275}
5276return modules;
5277}
5278function isAggregateRuntimeModules(value) {
5279if (!value || typeof value !== "object") return false;
5280return [
5281aggregateRuntimeMember(value, "deinflection", "deinflectJapaneseTerm"),
5282aggregateRuntimeMember(value, "deinflection", "termRulesMatch"),
5283aggregateRuntimeMember(value, "settings", "normalizeReaderSettings"),
5284aggregateRuntimeMember(value, "tokenTextRendering", "renderRuby"),
5285aggregateRuntimeMember(value, "localYomuDeck", "normalizeStoredYomuSrsDeck"),
5286aggregateRuntimeMember(value, "interfaceDirection", "applyInterfaceLocaleToRoot"),
5287aggregateRuntimeMember(value, "interfaceLocaleResolution", "resolveInterfaceLocale"),
5288aggregateRuntimeMember(value, "handleDrag", "createHandleDragController")
5289].every((member) => typeof member === "function");
5290}
5291function aggregateRuntimeMember(value, moduleName, memberName) {
5292const module = Reflect.get(value, moduleName);
5293if (!module || typeof module !== "object") return void 0;
5294return Reflect.get(module, memberName);
5295}
5296function kanjiNavigationForElement(element) {
5297const host = element.closest("[data-jpdb-reader-kanji-nav]");
5298if (!host) return void 0;
5299return {
5300enabled: true,
5301label: host.dataset.jpdbReaderKanjiNavLabel || "Show kanji"
5302};
5303}
5304const tokenTextRendering = aggregateRuntimeModules().tokenTextRendering;
5305const {
5306PITCH_CLASSES,
5307effectiveTokenRubies,
5308inferredInflectedSurfaceRubies,
5309isParticleCard,
5310localRubyRange,
5311miningInsightTokenKey,
5312miningInsightTokenKeys,
5313nonOverlappingTokens,
5314readerCardId,
5315readerCardSource,
5316readerReadingIndex,
5317readerWordClassName,
5318renderHighlightedTextHtml,
5319renderKanjiNavigationText,
5320renderRuby,
5321renderTokenReadings,
5322shouldHideFuriganaForCardState,
5323shouldRenderRuby,
5324tokenPitchClass
5325} = tokenTextRendering;
5326function renderDetachedReadings(surface, token, kanjiNavigation, preserveTokenRubies = false) {
5327return renderTokenReadings(
5328surface,
5329token,
5330kanjiNavigation,
5331preserveTokenRubies,
5332"detached"
5333);
5334}
5335const TOKEN_ATTRIBUTE = "data-yomu-private-token";
5336const MAX_PENDING_VALUES = 16384;
5337const { valuesByElement, pendingValues, replayableBlueprints, domainSlots } = sandboxSharedState("yomu.private-element-state.v1", () => ({
5338valuesByElement: new WeakMap(),
5339pendingValues: new Map(),
5340replayableBlueprints: new Map(),
5341domainSlots: new Map()
5342}));
5343function createPrivateElementStateSlot(domain, snapshot, options = {}) {
5344const slot = domainSlot(domain);
5345return {
5346attributes(value) {
5347const token = registerPendingValue(slot, snapshot(value), options.replayable === true);
5348return ` ${TOKEN_ATTRIBUTE}="${token}"`;
5349},
5350prepareSerialization(element, value) {
5351const token = registerPendingValue(slot, snapshot(value), options.replayable === true);
5352const current = element.getAttribute(TOKEN_ATTRIBUTE)?.trim();
5353element.setAttribute(TOKEN_ATTRIBUTE, current ? `${current} ${token}` : token);
5354},
5355bind(element, value) {
5356element.removeAttribute(TOKEN_ATTRIBUTE);
5357setPrivateValue(element, slot, snapshot(value));
5358},
5359read(element) {
5360return element ? valuesByElement.get(element)?.get(slot) : void 0;
5361}
5362};
5363}
5364function domainSlot(domain) {
5365const existing = domainSlots.get(domain);
5366if (existing) return existing;
5367const slot = Symbol(`yomu-private-element-state:${domain}`);
5368domainSlots.set(domain, slot);
5369return slot;
5370}
5371function registerPendingValue(slot, value, replayable) {
5372const token = privateStateToken();
5373const pending = { slot, value };
5374pendingValues.set(token, pending);
5375if (replayable) replayableBlueprints.set(token, pending);
5376prunePendingValues();
5377return token;
5378}
5379function hydratePrivateElementState(root) {
5380const candidates = [];
5381if (root instanceof Element && root.hasAttribute(TOKEN_ATTRIBUTE)) candidates.push(root);
5382candidates.push(...root.querySelectorAll(`[${TOKEN_ATTRIBUTE}]`));
5383for (const element of candidates) hydratePrivateElement(element);
5384}
5385function hydratePrivateElement(element) {
5386const tokens = element.getAttribute(TOKEN_ATTRIBUTE)?.split(/\s+/u).filter(
5386Boolean) ?? [];
5387element.removeAttribute(TOKEN_ATTRIBUTE);
5388for (const token of tokens) {
5389const pending = pendingValues.get(token);
5390pendingValues.delete(token);
5391if (pending) setPrivateValue(element, pending.slot, pending.value);
5392}
5393}
5394function setPrivateValue(element, slot, value) {
5395const values = valuesByElement.get(element) ?? new Map();
5396values.set(slot, value);
5397valuesByElement.set(element, values);
5398}
5399function prunePendingValues() {
5400pruneOldestPrivateValues(pendingValues);
5401pruneOldestPrivateValues(replayableBlueprints);
5402}
5403function pruneOldestPrivateValues(values) {
5404while (values.size > MAX_PENDING_VALUES) {
5405const oldest = values.keys().next().value;
5406if (oldest === void 0) return;
5407values.delete(oldest);
5408}
5409}
5410function privateStateToken() {
5411const bytes = new Uint32Array(4);
5412if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
5413crypto.getRandomValues(bytes);
5414return Array.from(bytes, (value) => value.toString(36)).join("-");
5415}
5416return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
5417}
5418const privateStateSlot = createPrivateElementStateSlot(
5419"rendered-word",
5420(state) => Object.freeze({ ...state }),
5421{ replayable: true }
5422);
5423const DATASET_KEYS = {
5424vid: "vid",
5425sid: "sid",
5426cardSource: "cardSource",
5427cardId: "cardId",
5428readingIndex: "readingIndex",
5429cardState: "cardState",
5430stateProvenance: "stateProvenance",
5431srsProvider: "srsProvider",
5432bunproState: "bunproState",
5433bunproPrefillState: "bunproPrefillState",
5434bunproPrefillProvenance: "bunproPrefillProvenance",
5435ankiState: "ankiState",
5436ankiDecks: "ankiDecks"
5437};
5438function renderedWordPrivateStateForCard(card, state) {
5439const source = renderedWordCardSource$1(card);
5440return {
5441vid: String(card.vid),
5442sid: String(card.sid),
5443cardSource: source,
5444cardId: String(renderedWordProviderNumber(source, card.jitenWordId, card.vid)),
5445readingIndex: String(renderedWordProviderNumber(source, card.jitenReadingIndex, card.sid)),
5446cardState: state,
5447stateProvenance: renderedWordStateProvenance(card)
5448};
5449}
5450function renderedWordCardSource$1(card) {
5451if (card.source) return card.source;
5452return card.reviewSource === "jiten-api" ? "jiten" : "jpdb";
5453}
5454function renderedWordProviderNumber(source, jitenValue, fallback) {
5455if (source !== "jiten") return fallback;
5456return jitenValue ?? fallback;
5457}
5458function renderedWordStateProvenance(card) {
5459return card.provisionalState === true ? "provisional" : "authoritative";
5460}
5461function registerRenderedWordPrivateState(word, state) {
5462privateStateSlot.bind(word, state);
5463word.dataset.yomuWord = "true";
5464projectPrivateState(word, state);
5465}
5466function updateRenderedWordPrivateState(word, patch) {
5467const current = { ...privateStateSlot.read(word) ?? {} };
5468for (const key of Object.keys(patch)) {
5469const value = patch[key];
5470if (value === void 0) delete current[key];
5471else current[key] = value;
5472}
5473registerRenderedWordPrivateState(word, current);
5474}
5475function renderedWordPrivateValue(word, key) {
5476return readRenderedWordPrivateState(word)?.[key];
5477}
5478function readRenderedWordPrivateState(word) {
5479return privateStateSlot.read(word);
5480}
5481function projectPrivateState(word, state) {
5482const trusted = currentAccountDataSurfaceIsTrusted();
5483for (const key of Object.keys(DATASET_KEYS)) {
5484const datasetKey = DATASET_KEYS[key];
5485const value = state[key];
5486if (trusted && value !== void 0) word.dataset[datasetKey] = value;
5487else delete word.dataset[datasetKey];
5488}
5489}
5490const scannedShadowRootRefs = new Set();
5491const scannedShadowRootState = new WeakMap();
5492let shadowRootScanHook = null;
5493const POTENTIAL_SHADOW_HOST_POLL_MS = 100;
5494const POTENTIAL_SHADOW_HOST_POLL_LIMIT = 40;
5495const MAX_POTENTIAL_SHADOW_HOSTS = 160;
5496const MAX_PENDING_UPGRADE_NAMES = 64;
5497const OPEN_SHADOW_ROOT_DISCOVERY_EVENT = "yomu:open-shadow-root-attached";
5498const PAGE_SHADOW_DISCOVERY_KEY = "__yomuOpenShadowRootDiscoveryV1";
5499const potentialShadowHosts = new Set();
5500let seenPotentialShadowHosts = new WeakSet();
5501let potentialShadowHostTimer;
5502const subscribedUpgradeNames = new Set();
5503let customElementLifecycleGeneration = 0;
5504let customElementUpgradeHook = null;
5505let pendingUpgradeWakeup = false;
5506let acceptPendingUpgradeWakeups = true;
5507let openShadowRootDiscoveryUsers = 0;
5508function noteScannedShadowRoot(root) {
5509noteShadowRoot(root, "scan");
5510}
5511function noteShadowRoot(root, cause) {
5512const active = scannedShadowRootState.get(root);
5513if (active) return;
5514scannedShadowRootState.set(root, true);
5515if (active === void 0) scannedShadowRootRefs.add(new WeakRef(root));
5516shadowRootScanHook?.(root, cause);
5517}
5518function watchPotentialOpenShadowRootHost(host) {
5519const root = host.shadowRoot;
5520if (root) {
5521noteShadowRoot(root, "scan");
5522return root;
5523}
5524const tagName = host.localName.toLowerCase();
5525const isCustomElement = tagName.includes("-");
5526if (!isCustomElement) return null;
5527const registry = customElementRegistry();
5528if (registry && !registry.get(tagName)) {
5529subscribeToCustomElementUpgrade(registry, tagName);
5530return null;
5531}
5532if (seenPotentialShadowHosts.has(host) || potentialShadowHosts.size >= MAX_POTENTIAL_SHADOW_HOSTS) return null;
5533seenPotentialShadowHosts.add(host);
5534potentialShadowHosts.add({
5535ref: new WeakRef(host),
5536remainingPolls: POTENTIAL_SHADOW_HOST_POLL_LIMIT
5537});
5538schedulePotentialShadowHostPoll();
5539return null;
5540}
5541function installOpenShadowRootDiscovery() {
5542openShadowRootDiscoveryUsers += 1;
5543if (openShadowRootDiscoveryUsers === 1) {
5544installPageOpenShadowRootDiscoveryBridge();
5545document.addEventListener(OPEN_SHADOW_ROOT_DISCOVERY_EVENT, handleOpenShadowRootAttached, true);
5546try {
5547document.querySelectorAll(":not(:defined)").forEach((host) => watchPotentialOpenShadowRootHost(host));
5548} catch {
5549}
5550schedulePotentialShadowHostPoll();
5551}
5552let disposed = false;
5553return () => {
5554if (disposed) return;
5555disposed = true;
5556openShadowRootDiscoveryUsers -= 1;
5557if (openShadowRootDiscoveryUsers > 0) return;
5558document.removeEventListener(OPEN_SHADOW_ROOT_DISCOVERY_EVENT, handleOpenShadowRootAttached, true);
5559if (!customElementUpgradeHook) resetPotentialShadowHostTracking();
5560};
5561}
5562function handleOpenShadowRootAttached(event) {
5563const host = event.composedPath()[0];
5564const root = host instanceof Element ? host.shadowRoot : null;
5565if (root) noteShadowRoot(root, "attached");
5566}
5567function schedulePotentialShadowHostPoll() {
5568if (!openShadowRootDiscoveryUsers && !customElementUpgradeHook || potentialShadowHostTimer !== void 0 || !potentialShadowHosts.size || pollSuspendedForHiddenPage()) return;
5569potentialShadowHostTimer = window.setTimeout(
5570pollPotentialShadowHosts,
5571POTENTIAL_SHADOW_HOST_POLL_MS
5572);
5573}
5574function pollSuspendedForHiddenPage() {
5575return typeof document !== "undefined" && document.visibilityState === "hidden";
5576}
5577function wakeShadowHostPoll() {
5578schedulePotentialShadowHostPoll();
5579}
5580function pollPotentialShadowHosts() {
5581potentialShadowHostTimer = void 0;
5582for (const pending of potentialShadowHosts) {
5583const host = pending.ref.deref();
5584if (!host || !host.isConnected) {
5585potentialShadowHosts.delete(pending);
5586if (host && !host.isConnected) seenPotentialShadowHosts.delete(host);
5587continue;
5588}
5589if (host.shadowRoot) {
5590potentialShadowHosts.delete(pending);
5591noteShadowRoot(host.shadowRoot, "attached");
5592continue;
5593}
5594if (pending.remainingPolls <= 1) {
5595potentialShadowHosts.delete(pending);
5596} else {
5597pending.remainingPolls -= 1;
5598}
5599}
5600schedulePotentialShadowHostPoll();
5601}
5602function installPageOpenShadowRootDiscoveryBridge() {
5603const sandbox = globalThis;
5604const pageWindow = sandbox.unsafeWindow;
5605if (pageWindow) {
5606const sameRealm = pageWindow.Object === Object;
5607if (sameRealm) {
5608try {
5609pageOpenShadowRootDiscoveryBootstrap(
5610pageWindow,
5611OPEN_SHADOW_ROOT_DISCOVERY_EVENT,
5612PAGE_SHADOW_DISCOVERY_KEY
5613);
5614return;
5615} catch {
5616}
5617}
5618}
5619const parent = document.head || document.documentElement;
5620if (!parent) return;
5621try {
5622const script = document.createElement("script");
5623const nonceHost = document.querySelector("script[nonce]");
5624const nonce = nonceHost?.nonce || nonceHost?.getAttribute("nonce");
5625if (nonce) script.setAttribute("nonce", nonce);
5626script.textContent = `;(${pageOpenShadowRootDiscoveryBootstrap.toString()})(window,${JSON.stringify(OPEN_SHADOW_ROOT_DISCOVERY_EVENT)},${JSON.stringify(PAGE_SHADOW_DISCOVERY_KEY)});`;
5627parent.append(script);
5628script.remove();
5629} catch {
5630}
5631}
5632function pageOpenShadowRootDiscoveryBootstrap(pageWindow, eventName, stateKey) {
5633const state = pageWindow;
5634if (state[stateKey]) return;
5635const prototype = pageWindow.Element?.prototype;
5636const descriptor = prototype && Object.getOwnPropertyDescriptor(prototype, "attachShadow");
5637const original = descriptor?.value;
5638if (!prototype || !descriptor || typeof original !== "function") return;
5639const patched = function attachShadow(init) {
5640const root = original.call(this, init);
5641if (root.mode === "open") {
5642this.dispatchEvent(new pageWindow.Event(eventName, { bubbles: true, composed: true }));
5643}
5644return root;
5645};
5646Object.defineProperty(prototype, "attachShadow", {
5647...descriptor,
5648value: patched
5649});
5650state[stateKey] = true;
5651}
5652function setShadowRootScanHook(hook) {
5653shadowRootScanHook = hook;
5654if (hook) forEachScannedShadowRoot((root) => hook(root, "replay"));
5655}
5656function forEachScannedShadowRoot(callback, includeDetached = false) {
5657for (const ref of scannedShadowRootRefs) {
5658const root = ref.deref();
5659if (!root) {
5660scannedShadowRootRefs.delete(ref);
5661continue;
5662}
5663if (!root.host?.isConnected) {
5664scannedShadowRootState.set(root, false);
5665if (!includeDetached) continue;
5666}
5667callback(root);
5668}
5669}
5670function sweepDisconnectedShadowRoots() {
5671let swept = false;
5672for (const ref of scannedShadowRootRefs) {
5673const root = ref.deref();
5674if (!root) {
5675scannedShadowRootRefs.delete(ref);
5676continue;
5677}
5678if (root.host?.isConnected || scannedShadowRootState.get(root) === false) continue;
5679scannedShadowRootState.set(root, false);
5680swept = true;
5681}
5682return swept;
5683}
5684function setCustomElementUpgradeHook(hook) {
5685customElementUpgradeHook = hook;
5686if (!hook) {
5687customElementLifecycleGeneration += 1;
5688subscribedUpgradeNames.clear();
5689acceptPendingUpgradeWakeups = false;
5690pendingUpgradeWakeup = false;
5691if (!openShadowRootDiscoveryUsers) resetPotentialShadowHostTracking();
5692return;
5693}
5694acceptPendingUpgradeWakeups = true;
5695schedulePotentialShadowHostPoll();
5696if (pendingUpgradeWakeup) {
5697pendingUpgradeWakeup = false;
5698hook();
5699}
5700}
5701function customElementRegistry() {
5702const registry = Reflect.get(globalThis, "customElements");
5703if (!registry) return null;
5704const callableMethods = [registry.get, registry.whenDefined].filter((method) => typeof method === "function");
5705return callableMethods.length === 2 ? registry : null;
5706}
5707function subscribeToCustomElementUpgrade(registry, tagName) {
5708if (subscribedUpgradeNames.has(tagName) || subscribedUpgradeNames.size >= MAX_PENDING_UPGRADE_NAMES) return;
5709subscribedUpgradeNames.add(tagName);
5710const generation = customElementLifecycleGeneration;
5711void registry.whenDefined(tagName).then(() => {
5712if (generation !== customElementLifecycleGeneration) return;
5713subscribedUpgradeNames.delete(tagName);
5714notifyCustomElementLifecycle();
5715}, () => {
5716if (generation !== customElementLifecycleGeneration) return;
5717subscribedUpgradeNames.delete(tagName);
5718});
5719}
5720function notifyCustomElementLifecycle() {
5721if (customElementUpgradeHook) customElementUpgradeHook();
5722else if (acceptPendingUpgradeWakeups) pendingUpgradeWakeup = true;
5723}
5724function resetPotentialShadowHostTracking() {
5725if (potentialShadowHostTimer !== void 0) {
5726window.clearTimeout(potentialShadowHostTimer);
5727potentialShadowHostTimer = void 0;
5728}
5729potentialShadowHosts.clear();
5730seenPotentialShadowHosts = new WeakSet();
5731}
5732const LANE_CLASS_STEM = "yomu-review-";
5733const ORDINARY_PAGE_WORD_SELECTOR = '.jpdb-reader-word[data-yomu-word="true"]';
5734let lanePainted = false;
5735function isReviewLaneClass(className) {
5736return className.startsWith(LANE_CLASS_STEM);
5737}
5738function syncWordReviewLane(word) {
5739const lane = laneClassName(renderedWordPrivateValue(word, "ankiState"));
5740Array.from(word.classList).filter((className) => isReviewLaneClass(className) && className !== lane).forEach((className) => word.classList.remove(className));
5741if (lane) word.classList.add(lane);
5742}
5743function setReviewLanePainted(painted) {
5744if (painted === lanePainted) return;
5745lanePainted = painted;
5746if (currentAccountDataSurfaceIsTrusted()) return;
5747const roots = [document];
5748forEachScannedShadowRoot((root) => roots.push(root));
5749roots.forEach((root) =>
5749 root.querySelectorAll(ORDINARY_PAGE_WORD_SELECTOR).forEach(syncWordReviewLane));
5750}
5751function laneClassName(ankiState) {
5752const onLane = [lanePainted, Boolean(ankiState), ankiState !== "not-in-deck", !currentAccountDataSurfaceIsTrusted()].every(Boolean);
5753return onLane ? `${LANE_CLASS_STEM}${ankiState}` : "";
5754}
5755function renderedWordNumericIdentity(word) {
5756return {
5757vid: Number(renderedWordPrivateValue(word, "vid")),
5758sid: Number(renderedWordPrivateValue(word, "sid"))
5759};
5760}
5761function renderedWordSpan(word, fallbackLength) {
5762return renderedWordRecordedSpan(word) ?? { start: 0, end: fallbackLength };
5763}
5764function renderedWordRecordedSpan(word) {
5765const start = Number(word.dataset.tokenStart);
5766const end = Number(word.dataset.tokenEnd);
5767const valid = [Number.isInteger(start), Number.isInteger(end), start >= 0, end > start].every(Boolean);
5768if (!valid) return null;
5769return { start, end };
5770}
5771function isProvisionalRenderedWord(word) {
5772return renderedWordPrivateValue(word, "stateProvenance") === "provisional";
5773}
5774function renderedWordProviderIdentity(word) {
5775const state = readRenderedWordPrivateState(word);
5776if (!state) return "";
5777const provider = completeIdentity([state.cardSource, state.cardId, state.readingIndex], ":", "/");
5778const fallback = completeIdentity([state.vid, state.sid], ":");
5779return [provider, fallback].find(Boolean) ?? "";
5780}
5781async function resolveRenderedWordAttempts(attempts, isCurrent) {
5782for (const attempt2 of attempts) {
5783if (!isCurrent()) return true;
5784if (await attempt2()) return true;
5785}
5786return !isCurrent();
5787}
5788function selectRenderedWordAttempt(useFirst, first, second) {
5789return useFirst ? first : second;
5790}
5791function normalizeIdentityText$1(value) {
5792return String(value ?? "").replace(/\s+/g, " ").trim();
5793}
5794function completeIdentity(parts, firstSeparator, secondSeparator = firstSeparator) {
5795const normalized = parts.map(normalizeIdentityText$1);
5796if (!normalized.every(Boolean)) return "";
5797return normalized.length === 3 ? `${normalized[0]}${firstSeparator}${normalized[1]}${secondSeparator}${normalized[2]}` : normalized.join(firstSeparator);
5798}
5799const RENDERED_WORD_CARD_STATES = [
5800"new",
5801"learning",
5802"young",
5803"mature",
5804"known",
5805"mastered",
5806"due",
5807"failed",
5808"locked",
5809"never-forget",
5810"blacklisted",
5811"suspended",
5812"in-deck",
5813"not-in-deck",
5814"redundant",
5815"frequent",
5816"unparsed"
5817];
5818const RENDERED_WORD_CARD_STATE_PREFIXES = ["jpdb", "jiten", "local", "fallback", "bunpro", "yomu-local"];
5819const RENDERED_WORD_DECK_SOURCE_PREFIXES = ["jpdb", "jiten", "local", "fallback", "anki"];
5820const RENDERED_WORD_MINING_INSIGHT_STATES = new Set(["new", "not-in-deck", "in-deck"]);
5821const BUNPRO_FILLABLE_CARD_STATES = new Set(["", "not-in-deck"]);
5822function clearRenderedWordAnkiState(word) {
5823Array.from(word.classList).filter(isAnkiStateClass).forEach((className) => word.classList.remove(className));
5824const ankiState = renderedWordPrivateValue(word, "ankiState");
5825const cardState = renderedWordPrivateValue(word, "cardState");
5826clearOffhostProjectedAnkiState(word, ankiState, cardState);
5827updateRenderedWordPrivateState(word, { ankiState: void 0, ankiDecks: void 0 });
5828RENDERED_WORD_CONTRAST_VARS.forEach((name) => word.style.removeProperty(name));
5829if (word.title.startsWith("Anki:")) word.removeAttribute("title");
5830}
5831function clearOffhostProjectedAnkiState(word, ankiState, cardState) {
5832if (currentAccountDataSurfaceIsTrusted()) return;
5833if (!ankiState || ankiState === cardState) return;
5834word.classList.remove(`jpdb-${ankiState}`);
5835}
5836function renderedWordHasAnkiState(word) {
5837return Boolean(renderedWordPrivateValue(word, "ankiState") || renderedWordPrivateValue(word, "ankiDecks") || Array.from(word.classList).some(isAnkiStateClass));
5838}
5839function isAnkiStateClass(className) {
5840return className.startsWith("anki-") || isReviewLaneClass(className);
5841}
5842function renderedWordCardKey(vid, sid) {
5843return `${vid}:${sid}`;
5844}
5845function renderedWordElementKey(word) {
5846return renderedWordCardKey(
5847Number(renderedWordPrivateValue(word, "vid")),
5848Number(renderedWordPrivateValue(word, "sid"))
5849);
5850}
5851function isValidRenderedWordKey(key) {
5852const parts = key.split(":");
5853return parts.length === 2 && parts.every((part) => part.trim() !== "" && Number.isFinite(Number(part)));
5854}
5855function renderedWordSelectorForKey(key) {
5856if (!isValidRenderedWordKey(key)) return null;
5857if (!currentAccountDataSurfaceIsTrusted()) return '.jpdb-reader-word[data-yomu-word="true"]';
5858const parts = key.split(":").map(escapeCssAttributeValue);
5859return `.jpdb-reader-word[data-vid="${parts[0]}"][data-sid="${parts[1]}"]`;
5860}
5861function rootContainsRenderedWord(root, word) {
5862return root === document || root === word || root instanceof Node && root.contains(word);
5863}
5864function renderedWordsInRoot(root) {
5865const words = new Set();
5866if (root instanceof HTMLElement && isRegisteredRenderedWord(root)) words.add(root);
5867root.querySelectorAll('.jpdb-reader-word[data-yomu-word="true"], .jpdb-reader-word[data-vid][data-sid]').forEach((word) => words.add(word));
5868return [...words];
5869}
5870async function* renderedWordsInRootChunked(root, chunkSize) {
5871let yielded = 0;
5872for (const word of registeredRenderedWordsInRoot(root)) {
5873yield word;
5874yielded += 1;
5875if (yielded % chunkSize === 0) await yieldToNextTask();
5876}
5877}
5878function* registeredRenderedWordsInRoot(root) {
5879if (root instanceof HTMLElement && isRegisteredRenderedWord(root)) yield root;
5880const ownerDocument = renderedWordOwnerDocument(root);
5881const walker = ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
5882acceptNode: registeredRenderedWordNodeFilter
5883});
5884let node = walker.nextNode();
5885while (node) {
5886yield node;
5887node = walker.nextNode();
5888}
5889}
5890function renderedWordOwnerDocument(root) {
5891return root instanceof Document ? root : root.ownerDocument ?? document;
5892}
5893function registeredRenderedWordNodeFilter(node) {
5894return [node instanceof HTMLElement, node instanceof HTMLElement && isRegisteredRenderedWord(node)].every(Boolean) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
5895}
5896function uniqueParentNodes(roots) {
5897return [...new Set(roots)];
5898}
5899function renderedFallbackVocabularyCacheKey(word) {
5900const { vid, sid } = renderedWordNumericIdentity(word);
5901const spelling = word.dataset.expression?.trim() ?? "";
5902const span = renderedWordRecordedSpan(word);
5903if (![Number.isFinite(vid), Number.isFinite(sid), Boolean(spelling), Boolean(span)].every(Boolean)) return "";
5904return fallbackVocabularySpanCacheKeyParts(vid, sid, spelling, "", span.start, span.end);
5905}
5906function fallbackVocabularySpanCacheKey(card, span) {
5907return fallbackVocabularySpanCacheKeyParts(card.vid, card.sid, card.spelling, card.reading, span.start, span.end);
5908}
5909function fallbackVocabularySpanCacheKeyParts(vid, sid, spelling, reading, start, end) {
5910return `${vid}:${sid}:${spelling}:${reading}:${start}:${end}`;
5911}
5912function setRenderedWordPitchClass(word, pitchClass) {
5913Array.from(word.classList).filter((className) => className.startsWith("jpdb-pitch-")).forEach((className) => word.classList.remove(className));
5914word.dataset.pitchClass = pitchClass;
5915if (pitchClass) word.classList.add(`jpdb-pitch-${pitchClass}`);
5916}
5917function setRenderedWordPitchAccentPattern(word, card) {
5918const pitchAccent = card.pitchAccent.join("|");
5919if (pitchAccent) word.dataset.pitchAccent = pitchAccent;
5920}
5921function setRenderedWordPitchComponents(word, card) {
5922const gradient = pitchComponentUnderlineGradient(card);
5923if (!gradient) {
5924delete word.dataset.pitchComponents;
5925word.style.removeProperty("--jpdb-reader-inline-pitch-gradient");
5926return;
5927}
5928word.dataset.pitchComponents = "true";
5929word.style.setProperty("--jpdb-reader-inline-pitch-gradient", gradient);
5930}
5931function cardStateProvenance(card) {
5932return card.provisionalState === true ? "provisional" : "authoritative";
5933}
5934function setRenderedWordCardIdentity(word, card, options = {}) {
5935const identity = renderedWordPrivateStateForCard(card, primaryCardState(card.cardState));
5936updateRenderedWordPrivateState(word, {
5937vid: identity.vid,
5938sid: identity.sid,
5939cardSource: identity.cardSource,
5940cardId: identity.cardId,
5941readingIndex: identity.readingIndex
5942});
5943word.dataset.expression = card.spelling;
5944word.dataset.reading = card.reading;
5945applyRenderedWordPitchIdentity(word, card, options);
5946applyRenderedWordCardStatus(word, card, options, true);
5947}
5948function applyRenderedWordPitchIdentity(word, card, options) {
5949const particle = isParticleCard(card);
5950word.classList.toggle("jpdb-reader-particle", particle);
5951const handlers = {
5952clear: () => clearRenderedWordPitchIdentity(word, particle),
5953particle: () => applyRenderedParticlePitchIdentity(word),
5954card: () => applyRenderedCardPitchIdentity(word, card)
5955};
5956handlers[renderedWordPitchIdentityMode(options, particle)]();
5957}
5958function renderedWordPitchIdentityMode(options, particle) {
5959if (options.pitchPolicy === "clear") return "clear";
5960return particle ? "particle" : "card";
5961}
5962function clearRenderedWordPitchIdentity(word, particle) {
5963clearRenderedWordPitchMetadata(word, "");
5964if (particle) clearRenderedWordMiningInsight(word);
5965}
5966function applyRenderedParticlePitchIdentity(word) {
5967clearRenderedWordPitchMetadata(word, "particle");
5968clearRenderedWordMiningInsight(word);
5969}
5970function clearRenderedWordPitchMetadata(word, pitchClass) {
5971setRenderedWordPitchClass(word, pitchClass);
5972delete word.dataset.pitchAccent;
5973delete word.dataset.pitchComponents;
5974word.style.removeProperty("--jpdb-reader-inline-pitch-gradient");
5975}
5976function applyRenderedCardPitchIdentity(word, card) {
5977if (!card.pitchAccent.length) delete word.dataset.pitchAccent;
5978setRenderedWordPitchAccentPattern(word, card);
5979setRenderedWordPitchComponents(word, card);
5980}
5981function setRenderedWordCardStatus(word, card, options = {}) {
5982applyRenderedWordCardStatus(word, card, options, false);
5983}
5984function applyRenderedWordCardStatus(word, card, options, replaceCardIdentity) {
5985const source = renderedWordCardSource(card);
5986const state = primaryCardState(card.cardState);
5987projectRenderedWordCardIdentity(word, card, source, replaceCardIdentity);
5988if (shouldPreserveAuthoritativeState(word, card, state, options)) return;
5989clearRenderedWordCardStateClasses(word);
5990updateRenderedWordPrivateState(word, { bunproState: void 0, srsProvider: void 0 });
5991clearRenderedWordDeckMembershipClasses(word, ["anki"]);
5992updateRenderedWordPrivateState(word, {
5993cardState: state,
5994stateProvenance: cardStateProvenance(card)
5995});
5996if (!RENDERED_WORD_MINING_INSIGHT_STATES.has(state)) clearRenderedWordMiningInsight(word);
5997word.classList.add(`jpdb-${state}`);
5998projectRenderedWordSourceStateClass(word, source, state);
5999applyRenderedWordDeckMembership(word, card);
6000}
6001function projectRenderedWordCardIdentity(word, card, source, replace) {
6002if (!replace) return;
6003updateRenderedWordPrivateState(word, {
6004cardSource: source,
6005cardId: String(renderedWordCardId(card, source)),
6006readingIndex: String(renderedWordReadingIndex(card, source))
6007});
6008}
6009function projectRenderedWordSourceStateClass(word, source, state) {
6010if (source === "jpdb") return;
6011if (!currentAccountDataSurfaceIsTrusted()) return;
6012word.classList.add(`${source}-${state}`);
6013}
6014function refreshRenderedMiningInsights(root) {
6015const words = renderedWordsInRoot(root);
6016const insightKeys = renderedMiningInsightKeys(renderedMiningSentenceCards(words));
6017return words.filter((word) => updateRenderedMiningInsight(word, insightKeys));
6018}
6019function renderedMiningSentenceCards(words) {
6020const sentenceCards = new Map();
6021words.forEach((word) => recordRenderedMiningCard(sentenceCards, word));
6022return sentenceCards;
6023}
6024function recordRenderedMiningCard(sentenceCards, word) {
6025const sentence = renderedMiningSentence(word);
6026const identity = renderedMiningCardIdentity(word);
6027if (![sentence, identity, !word.classList.contains("jpdb-reader-particle")].every(Boolean)) return;
6028const cards = sentenceCards.get(sentence) ?? new Map();
6029sentenceCards.set(sentence, cards);
6030if (!cards.has(identity)) cards.set(identity, { unknown: renderedMiningStateIsUnknown(word) });
6031}
6032function renderedMiningStateIsUnknown(word) {
6033return RENDERED_WORD_MINING_INSIGHT_STATES.has(renderedWordPrivateValue(word, "cardState") ?? "");
6034}
6035function renderedMiningInsightKeys(sentenceCards) {
6036const insightKeys = new Set();
6037sentenceCards.forEach((cards, sentence) => {
6038const unknown = [...cards.entries()].filter(([, state]) => state.unknown);
6039if ([cards.size >= 3, unknown.length === 1].every(Boolean)) {
6040insightKeys.add(`${sentence}\0${unknown[0]?.[0] ?? ""}`);
6041}
6042});
6043return insightKeys;
6044}
6045function updateRenderedMiningInsight(word, insightKeys) {
6046const sentence = renderedMiningSentence(word);
6047const identity = renderedMiningCardIdentity(word);
6048const insight = [
6049Boolean(sentence),
6050Boolean(identity),
6051!word.classList.contains("jpdb-reader-particle"),
6052insightKeys.has(`${sentence}\0${identity}`)
6053].every(Boolean);
6054const unchanged = [
6055word.classList.contains("jpdb-reader-i-plus-one") === insight,
6056word.dataset.miningInsight === "i-plus-one" === insight
6057].every(Boolean);
6058if (unchanged) return false;
6059word.classList.toggle("jpdb-reader-i-plus-one", insight);
6060if (insight) word.dataset.miningInsight = "i-plus-one";
6061else delete word.dataset.miningInsight;
6062return true;
6063}
6064function renderedMiningSentence(word) {
6065return (word.dataset.sentence ?? "").replace(/\s+/g, " ").trim();
6066}
6067function renderedMiningCardIdentity(word) {
6068return renderedWordProviderIdentity(word);
6069}
6070function applyLocalYomuSrsStateToRenderedWord(word, card) {
6071const state = primaryCardState(card.cardState);
6072const expectedStateClasses = new Set([
6073`jpdb-${state}`,
6074currentAccountDataSurfaceIsTrusted() ? `yomu-local-${state}` : ""
6075].filter(Boolean));
6076if (!localYomuSrsWordNeedsUpdate(word, state, expectedStateClasses)) return false;
6077clearRenderedWordCardStateClasses(word);
6078updateRenderedWordPrivateState(word, {
6079bunproState: void 0,
6080bunproPrefillState: void 0,
6081bunproPrefillProvenance: void 0,
6082cardState: state,
6083srsProvider: "yomu-local",
6084stateProvenance: "authoritative"
6085});
6086word.classList.add(`jpdb-${state}`);
6087addTrustedLocalYomuStateClass(word, state);
6088clearRenderedMiningInsightForKnownState(word, state);
6089return true;
6090}
6091function localYomuSrsWordNeedsUpdate(word, state, expectedClasses) {
6092return [
6093renderedWordPrivateValue(word, "cardState") !== state,
6094renderedWordPrivateValue(word, "srsProvider") !== "yomu-local",
6095renderedWordPrivateValue(word, "stateProvenance") !== "authoritative",
6096renderedWordPrivateValue(word, "bunproState") !== void 0,
6097renderedWordPrivateValue(word, "bunproPrefillState") !== void 0,
6098renderedWordPrivateValue(word, "bunproPrefillProvenance") !== void 0,
6099[...expectedClasses].some((className) => !word.classList.contains(className)),
6100Array.from(word.classList).some((className) => isRenderedWordCardStateClass(className) && !expectedClasses.has(className)),
6101renderedMiningInsightIsStale(word, state)
6102].some(Boolean);
6103}
6104function renderedMiningInsightIsStale(word, state) {
6105return [
6106!RENDERED_WORD_MINING_INSIGHT_STATES.has(state),
6107[word.classList.contains("jpdb-reader-i-plus-one"), word.dataset.miningInsight !== void 0].some(Boolean)
6108].every(Boolean);
6109}
6110function addTrustedLocalYomuStateClass(word, state) {
6111if (currentAccountDataSurfaceIsTrusted()) word.classList.add(`yomu-local-${state}`);
6112}
6113function clearRenderedMiningInsightForKnownState(word, state) {
6114if (!RENDERED_WORD_MINING_INSIGHT_STATES.has(state)) clearRenderedWordMiningInsight(word);
6115}
6116function shouldPreserveAuthoritativeState(word, card, incomingState, options) {
6117return [
6118options.statePolicy !== "replace",
6119cardStateProvenance(card) === "provisional",
6120incomingState === "not-in-deck",
6121renderedWordPrivateValue(word, "stateProvenance") === "authoritative",
6122Boolean(renderedWordPrivateValue(word, "cardState"))
6123].every(Boolean);
6124}
6125function applyBunproStateToRenderedWord(word, state) {
6126const previous = renderedWordPrivateValue(word, "bunproState") ?? "";
6127const mode = bunproStateTransitionMode(word, previous, state);
6128const handlers = {
6129ignore: () => false,
6130clear: () => {
6131clearRenderedWordBunproState(word);
6132return true;
6133},
6134apply: () => applyNewBunproState(word, previous, String(state))
6135};
6136return handlers[mode]();
6137}
6138function bunproStateTransitionMode(word, previous, state) {
6139const fillable = BUNPRO_FILLABLE_CARD_STATES.has(renderedWordPrivateText(word, "cardState"));
6140const transitions = [
6141[[!previous, !fillable].every(Boolean), "ignore"],
6142[[!state, !previous].every(Boolean), "ignore"],
6143[!state, "clear"],
6144[previous === state, "ignore"],
6145[true, "apply"]
6146];
6147return transitions.find(([matches]) => matches)[1];
6148}
6149function applyNewBunproState(word, previous, state) {
6150if (previous) word.classList.remove(`jpdb-${previous}`, `bunpro-${previous}`);
6151else captureBunproPrefillState(word);
6152const source = renderedWordPrivateValue(word, "cardSource") ?? "jpdb";
6153word.classList.remove("jpdb-not-in-deck", `${source}-not-in-deck`);
6154word.classList.add(`jpdb-${state}`);
6155if (currentAccountDataSurfaceIsTrusted()) word.classList.add(`bunpro-${state}`);
6156updateRenderedWordPrivateState(word, {
6157cardState: state,
6158bunproState: state,
6159stateProvenance: "authoritative"
6160});
6161return true;
6162}
6163function captureBunproPrefillState(word) {
6164updateRenderedWordPrivateState(word, {
6165bunproPrefillState: renderedWordPrivateValue(word, "cardState") ?? "",
6166bunproPrefillProvenance: renderedWordPrivateValue(word, "stateProvenance") ?? ""
6167});
6168}
6169function clearRenderedWordBunproState(word) {
6170const previous = renderedWordPrivateText(word, "bunproState");
6171removeRenderedBunproStateClasses(word, previous);
6172const prefill = renderedWordPrivateText(word, "bunproPrefillState", "not-in-deck");
6173const prefillProvenance = renderedWordPrivateText(word, "bunproPrefillProvenance");
6174const restored = prefill;
6175updateRenderedWordPrivateState(word, {
6176bunproState: void 0,
6177bunproPrefillState: void 0,
6178bunproPrefillProvenance: void 0,
6179stateProvenance: undefinedIfEmpty(prefillProvenance),
6180cardState: undefinedIfEmpty(restored)
6181});
6182restoreRenderedProviderStateClasses(word, restored);
6183}
6184function renderedWordPrivateText(word, key, fallback = "") {
6185return renderedWordPrivateValue(word, key) ?? fallback;
6186}
6187function undefinedIfEmpty(value) {
6188return value ? value : void 0;
6189}
6190function removeRenderedBunproStateClasses(word, previous) {
6191if (previous) word.classList.remove(`jpdb-${previous}`, `bunpro-${previous}`);
6192}
6193function restoreRenderedProviderStateClasses(word, restored) {
6194if (!restored) return;
6195const source = renderedWordPrivateValue(word, "cardSource") ?? "jpdb";
6196word.classList.add(`jpdb-${restored}`);
6197addTrustedProviderStateClass(word, source, restored);
6198}
6199function addTrustedProviderStateClass(word, source, state) {
6200if ([currentAccountDataSurfaceIsTrusted(), source !== "jpdb"].every(Boolean)) {
6201word.classList.add(`${source}-${state}`);
6202}
6203}
6204function clearRenderedWordMiningInsight(word) {
6205word.classList.remove("jpdb-reader-i-plus-one");
6206delete word.dataset.miningInsight;
6207}
6208function escapeCssAttributeValue(value) {
6209return value.replace(/["\\]/g, "\\$&");
6210}
6211function clearRenderedWordCardStateClasses(word) {
6212Array.from(word.classList).filter(isRenderedWordCardStateClass).forEach((className) => word.classList.remove(className));
6213}
6214function clearRenderedWordDeckMembershipClasses(word, preserveSources = []) {
6215Array.from(word.classList).filter((className) => isRenderedWordDeckMembershipClass(className, preserveSources)).forEach((className) => word.classList.remove(className));
6216if (preserveSources.length) return;
6217delete word.dataset.deckMember;
6218delete word.dataset.deckSource;
6219delete word.dataset.deckNames;
6220}
6221function isRenderedWordCardStateClass(className) {
6222return RENDERED_WORD_CARD_STATE_PREFIXES.some((prefix) => RENDERED_WORD_CARD_STATES.some((state) => className === `${prefix}-${state}`));
6223}
6224function isRenderedWordDeckMembershipClass(className, preserveSources) {
6225if (className === "yomu-deck-member") return false;
6226if (className.startsWith("yomu-deck-")) return true;
6227return RENDERED_WORD_DECK_SOURCE_PREFIXES.some((prefix) => {
6228if (preserveSources.includes(prefix)) return false;
6229return className === `${prefix}-deck-member` || className.startsWith(`${prefix}-deck-`);
6230});
6231}
6232function applyRenderedWordDeckMembership(word, card) {
6233const membership = cardDeckMembership(card);
6234if (!membership.member) {
6235clearRenderedWordDeckMembership(word);
6236return;
6237}
6238word.classList.add(...cardDeckMembershipClassNames(card));
6239projectRenderedWordDeckMembership(word, membership);
6240}
6241function clearRenderedWordDeckMembership(word) {
6242if (word.classList.contains("anki-deck-member")) return;
6243word.classList.remove("yomu-deck-member");
6244clearRenderedWordDeckDataset(word);
6245}
6246function projectRenderedWordDeckMembership(word, membership) {
6247if (!currentAccountDataSurfaceIsTrusted()) {
6248clearRenderedWordDeckDataset(word);
6249return;
6250}
6251word.dataset.deckMember = "true";
6252word.dataset.deckSource = membership.source;
6253if (membership.names.length) word.dataset.deckNames = membership.names.join(", ");
6254else delete word.dataset.deckNames;
6255}
6256function clearRenderedWordDeckDataset(word) {
6257delete word.dataset.deckMember;
6258delete word.dataset.deckSource;
6259delete word.dataset.deckNames;
6260}
6261function isRegisteredRenderedWord(element) {
6262return element.matches('.jpdb-reader-word[data-yomu-word="true"], .jpdb-reader-word[data-vid][data-sid]');
6263}
6264function renderedWordCardSource(card) {
6265return card.source ?? (card.reviewSource === "jiten-api" ? "jiten" : "jpdb");
6266}
6267function renderedWordCardId(card, source = renderedWordCardSource(card)) {
6268return source === "jiten" ? card.jitenWordId ?? card.vid : card.vid;
6269}
6270function renderedWordReadingIndex(card, source = renderedWordCardSource(card)) {
6271return source === "jiten" ? card.jitenReadingIndex ?? card.sid : card.sid;
6272}
6273function yieldToNextTask() {
6274return new Promise((resolve) => window.setTimeout(resolve, 0));
6275}
6276const READER_ROOT_SELECTOR$3 = "[data-jpdb-reader-root]";
6277function runtime() {
6278const targetRuntime = yomuLearningTargetRuntime();
6279if (!targetRuntime) throw new Error("Yomu learning-target runtime did not load.");
6280return targetRuntime;
6281}
6282function activeLearningTarget() {
6283return runtime().activeLearningTarget();
6284}
6285function activeLearningTargetGeneration() {
6286return runtime().activeLearningTargetGeneration();
6287}
6288function activeLearningTargetLanguage() {
6289return runtime().activeLearningTargetLanguage();
6290}
6291function adoptLearningTargetLanguage(value) {
6292return runtime().adoptLearningTargetLanguage(value);
6293}
6294function defaultLearningTargetModule() {
6295return runtime().defaultLearningTargetModule();
6296}
6297function learningTargetModuleFor(language) {
6298return runtime().learningTargetModuleFor(language);
6299}
6300function normalizeLearningTargetLanguage(value) {
6301return runtime().normalizeLearningTargetLanguage(value);
6302}
6303function isTargetLanguageText(text2) {
6304return activeLearningTarget().isLookupableText(text2);
6305}
6306function segmentTargetLanguageText(text2) {
6307return activeLearningTarget().segment(text2);
6308}
6309function targetPointerWordSegments(text2) {
6310return activeLearningTarget().pointerWordSegments(text2);
6311}
6312function targetPointerWordAt(text2, offset) {
6313if (!text2.length) return null;
6314let index = Math.min(Math.max(offset, 0), text2.length - 1);
6315const segments = targetPointerWordSegments(text2);
6316let segment = segments.find((item) => item.start <= index && index < item.end);
6317if (!segment && index > 0) {
6318segment = segments.find((item) => item.start <= index - 1 && index - 1 < item.end);
6319if (segment) index--;
6320}
6321return segment ? { start: segment.start, end: segment.end, offset: index } : null;
6322}
6323function hasTargetPointerWord(text2) {
6324return Boolean(text2) && targetPointerWordSegments(text2).length > 0;
6325}
6326const DECORATION_POLICY_RUNTIME_API_SLOT = Symbol.for("yomu.decoration-policy-runtime-api.v1");
6327function decorationPolicyRuntimeApi() {
6328const api = globalThis[DECORATION_POLICY_RUNTIME_API_SLOT];
6329if (!isDecorationPolicyRuntimeApi(api)) {
6330throw new Error("The Yomu decoration-policy runtime is not installed.");
6331}
6332return api;
6333}
6334function isDecorationPolicyRuntimeApi(value) {
6335if (!value || typeof value !== "object") return false;
6336const candidate = value;
6337return typeof candidate.classifyDecoration === "function" && typeof candidate.safeElementMatches === "function" && typeof candidate.setReviewCardFrontPredicate === "function";
6338}
6339const policy = decorationPolicyRuntimeApi();
6340const {
6341COMPACT_INTERACTIVE_CHROME_CONTROL_SELECTOR,
6342COMPACT_PASSIVE_CHROME_SELECTOR,
6343COMPACT_PASSIVE_INTERACTION_SELECTOR,
6344CONSTRAINED_ROW_VERDICT_TTL_MS,
6345PASSIVE_INTERACTION_BOUNDARY_SELECTOR,
6346PASSIVE_INTERACTION_SELECTOR,
6347UI_CLASS_RE,
6348applyPassiveChromeMarks,
6349boxStyleIsClipCapable,
6350clampRowAllowsInFlowRestRuby,
6351classifyDecoration,
6352closestRubyFragileConstrainedRow,
6353compactInteractiveChromeElement,
6354compactLength,
6355compactPassiveChromeElement,
6356compactScanRubySuppression,
6357composedAncestorElement,
6358contentClipRowShowsRestReadings,
6359cssPixels,
6360decorationStateForWord,
6361decorationSuppressesRuby,
6362hasClippedTextConstraint,
6363hasDefiniteCssSize,
6364hasInlineControlShape,
6365hasLineClamp,
6366hasUiBox,
6367interactivePassiveControl,
6368isClipConstrainedRow,
6369isCompactInteractiveChromeText,
6370isCompactPassiveChromeElement,
6371isCompactPassiveInteractionElement,
6372isEllipsisTextRow,
6373isExplicitControlLink,
6374isLikelyProseElement,
6375isLikelyProseLink,
6376isNavigationChromeContext,
6377isNonEditableListboxTrigger,
6378isPassiveInteractionElement,
6379isPositionedTextOverlay,
6380isReadableProseContext,
6381isYouTubeHost,
6382linkHasControlMedia,
6383linkHasControlShape,
6384noteConstrainedRowLayoutSettled,
6385resetDecorationPolicyCachesForTest,
6386safeComputedStyle,
6387safeElementMatches: safeElementMatches$1,
6388selectorPairs,
6389setReviewCardFrontPredicate,
6390stampDecorationState,
6391youtubeNativeChromeMustRemainPageOwned,
6392youtubeShelfExpansionChromeMustRemainPageOwned
6393} = policy;
6394function rubyFriendlyMirrorLineHeight(style) {
6395const fontSize = cssPixels(style.fontSize) || 16;
6396const existingLineHeight = cssPixels(style.lineHeight) || fontSize * 1.2;
6397return `${Math.ceil(Math.max(existingLineHeight, fontSize * 1.78))}px`;
6398}
6399function detachedReadingLaneLineHeight(style, alreadyReserved) {
6400const fontSize = cssPixels(style.fontSize) || 16;
6401const minimum = Math.ceil(fontSize * 2) + 1;
6402const current = cssPixels(style.lineHeight);
6403if (alreadyReserved) return `${Math.ceil(Math.max(current, minimum))}px`;
6404return current >= minimum ? "" : `${minimum}px`;
6405}
6406function setImportantStyleIfChanged(element, property, value) {
6407if (element.style.getPropertyValue(property) === value && element.style.getPropertyPriority(property) === "important") return;
6408element.style.setProperty(property, value, "important");
6409}
6410const CSS_PIXEL_SIGNIFICANT_DIGITS = 6;
6411const CSS_PIXEL_MINIMUM = 1e-6;
6412function stableCssPixels(value) {
6413if (!Number.isFinite(value) || Math.abs(value) < CSS_PIXEL_MINIMUM) return "0px";
6414const magnitude = Math.floor(Math.log10(Math.abs(value)));
6415const decimalPlaces = Math.max(0, Math.min(12, CSS_PIXEL_SIGNIFICANT_DIGITS - magnitude - 1));
6416const rounded = Number(value.toFixed(decimalPlaces));
6417return `${Object.is(rounded, -0) ? 0 : rounded}px`;
6418}
6419const DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS = "jpdb-reader-document-annotation-portal";
6420const DOCUMENT_ANNOTATION_PORTAL_PAINT_CLASS = "jpdb-reader-document-annotation-paint";
6421const portalWatches = new WeakMap();
6422const structurallyStyledPortalMirrors = new WeakSet();
6423const CLIPPED_PORTAL_SCROLL_SETTLE_MS = 96;
6424function styleDocumentAnnotationPortalMirror(mirror, host) {
6425const style = safeComputedStyle(host);
6426if (!structurallyStyledPortalMirrors.has(mirror)) {
6427mirror.style.cssText = [
6428"all:initial!important",
6429"position:fixed!important",
6430"inset:auto!important",
6431"top:0!important",
6432"left:0!important",
6433"width:0!important",
6434"height:0!important",
6435"overflow:visible!important",
6436"pointer-events:none!important",
6437"user-select:none!important",
6438"-webkit-user-select:none!important",
6439"z-index:auto!important",
6440"contain:layout style!important"
6441].join(";");
6442const paint = documentAnnotationPortalPaint(mirror);
6443paint.style.cssText = [
6444"display:block!important",
6445"position:absolute!important",
6446"inset:0 auto auto 0!important",
6447"width:0!important",
6448"height:0!important",
6449"overflow:visible!important",
6450"pointer-events:none!important",
6451"contain:layout style!important",
6452"transform:none!important",
6453"transform-origin:0 0!important"
6454].join(";");
6455structurallyStyledPortalMirrors.add(mirror);
6456}
6457mirror.style.setProperty("font", style.font, "important");
6458mirror.style.setProperty("font-size", style.fontSize, "important");
6459mirror.style.setProperty("font-weight", style.fontWeight, "important");
6460mirror.style.setProperty("line-height", style.lineHeight, "important");
6461mirror.style.setProperty("letter-spacing", style.letterSpacing, "important");
6462mirror.style.setProperty("direction", style.direction, "important");
6463mirror.style.setProperty("writing-mode", style.writingMode, "important");
6464mirror.style.setProperty("color", style.color, "important");
6465setImportantStyleIfChanged(mirror, "z-index", documentPortalStackingLevel(host));
6466}
6467function documentAnnotationPortalPaint(mirror) {
6468const existing = Array.from(mirror.children).find(
6469(child) => child instanceof HTMLElement && child.classList.contains(DOCUMENT_ANNOTATION_PORTAL_PAINT_CLASS)
6470);
6471if (existing) return existing;
6472const paint = mirror.ownerDocument.createElement("span");
6473paint.className = DOCUMENT_ANNOTATION_PORTAL_PAINT_CLASS;
6474mirror.append(paint);
6475return paint;
6476}
6477function registerDocumentAnnotationPortalMirror(host, mirror, scheduleProjection, projectImmediately, retire) {
6478const document2 = host.ownerDocument;
6479let watch = portalWatches.get(document2);
6480if (!watch) {
6481watch = createPortalWatch(document2);
6482portalWatches.set(document2, watch);
6483}
6484const paint = documentAnnotationPortalPaint(mirror);
6485const entry = {
6486source: host,
6487mirror,
6488paint,
6489sourceAnchor: sourceAnchorRange(host),
6490settled: { source: { x: 0, y: 0 }, root: { x: 0, y: 0 } },
6491applied: { x: 0, y: 0 },
6492preparedClip: null,
6493clipChain: [],
6494clipTopologyEpoch: -1,
6495scheduleProjection,
6496projectImmediately,
6497retire
6498};
6499watch.entries.set(mirror, entry);
6500prepareDocumentAnnotationPortalMirrors([mirror]);
6501settleDocumentAnnotationPortalMirrors([mirror]);
6502}
6503function createPortalWatch(document2) {
6504const lifecycle = new AbortController();
6505const entries2 = new Map();
6506const watch = {
6507entries: entries2,
6508lifecycle,
6509topologyEpoch: 0,
6510scrollSettleEntries: new Set(),
6511scrollSettleTimer: null
6512};
6513const view = document2.defaultView;
6514const visibleEntries = () => {
6515if (document2.hidden) return [];
6516return pruneAndCollectEntries(document2, watch);
6517};
6518const alignForScroll = () => {
6519const live = visibleEntries();
6520if (!live.length) return;
6521const alignments = alignPortalEntries(live);
6522scheduleClippedPortalScrollSettle(document2, watch, alignments);
6523};
6524const alignThenScheduleAll = () => {
6525const live = visibleEntries();
6526if (!live.length) return;
6527cancelClippedPortalScrollSettle(document2, watch);
6528alignPortalEntries(live);
6529live.forEach((entry) => entry.scheduleProjection());
6530};
6531const scheduleVisibleProjection = () => {
6532cancelClippedPortalScrollSettle(document2, watch);
6533const live = visibleEntries();
6534live.forEach((entry) => entry.scheduleProjection());
6535};
6536const projectForTopLayerChange = () => {
6537cancelClippedPortalScrollSettle(document2, watch);
6538const live = visibleEntries();
6539if (!live.length) return;
6540live.forEach((entry) => entry.scheduleProjection());
6541};
6542const projectAffectedTransition = (event) => {
6543const target = event.target;
6544if (!(target instanceof Node)) return;
6545const affected = visibleEntries().filter((entry) => transitionCanMoveSource(target, entry.source));
6546if (!affected.length) return;
6547affected.forEach((entry) => watch.scrollSettleEntries.delete(entry));
6548if (!watch.scrollSettleEntries.size) cancelClippedPortalScrollSettle(document2, watch);
6549affected.forEach((entry) => {
6550entry.clipTopologyEpoch = -1;
6551});
6552alignPortalEntries(affected);
6553affected.forEach((entry) => entry.projectImmediately());
6554};
6555view?.addEventListener("scroll", alignForScroll, {
6556capture: true,
6557passive: true,
6558signal: lifecycle.signal
6559});
6560view?.addEventListener("resize", () => {
6561watch.topologyEpoch += 1;
6562alignThenScheduleAll();
6563}, {
6564passive: true,
6565signal: lifecycle.signal
6566});
6567view?.visualViewport?.addEventListener("scroll", alignForScroll, {
6568passive: true,
6569signal: lifecycle.signal
6570});
6571view?.visualViewport?.addEventListener("resize", alignThenScheduleAll, {
6572passive: true,
6573signal: lifecycle.signal
6574});
6575document2.addEventListener("visibilitychange", scheduleVisibleProjection, {
6576signal: lifecycle.signal
6577});
6578document2.addEventListener("fullscreenchange", projectForTopLayerChange, {
6579signal: lifecycle.signal
6580});
6581document2.addEventListener("toggle", projectForTopLayerChange, {
6582capture: true,
6583signal: lifecycle.signal
6584});
6585document2.addEventListener("transitionend", projectAffectedTransition, {
6586capture: true,
6587passive: true,
6588signal: lifecycle.signal
6589});
6590return watch;
6591}
6592function scheduleClippedPortalScrollSettle(document2, watch, alignments) {
6593for (const alignment of alignments) {
6594const visibleClip = alignment.clip && alignment.clip.right - alignment.clip.left > 0.5 && alignment.clip.bottom - alignment.clip.top > 0.5;
6595const movedInsideClip = Boolean(visibleClip) && (Math.abs(alignment.x) > 0.01 || Math.abs(alignment.y) > 0.01);
6596if (movedInsideClip) watch.scrollSettleEntries.add(alignment.entry);
6597else watch.scrollSettleEntries.delete(alignment.entry);
6598}
6599if (!watch.scrollSettleEntries.size) {
6600cancelClippedPortalScrollSettle(document2, watch);
6601return;
6602}
6603if (watch.scrollSettleTimer !== null) document2.defaultView?.clearTimeout(watch.scrollSettleTimer);
6604const view = document2.defaultView;
6605if (!view) {
6606const pending = [...watch.scrollSettleEntries];
6607watch.scrollSettleEntries.clear();
6608pending.forEach((entry) => entry.scheduleProjection());
6609return;
6610}
6611watch.scrollSettleTimer = view.setTimeout(() => {
6612watch.scrollSettleTimer = null;
6613if (portalWatches.get(document2) !== watch) {
6614watch.scrollSettleEntries.clear();
6615return;
6616}
6617const live = new Set(pruneAndCollectEntries(document2, watch));
6618const pending = [...watch.scrollSettleEntries];
6619watch.scrollSettleEntries.clear();
6620pending.filter((entry) => live.has(entry)).forEach((entry) => entry.scheduleProjection());
6621}, CLIPPED_PORTAL_SCROLL_SETTLE_MS);
6622}
6623function cancelClippedPortalScrollSettle(document2, watch) {
6624if (watch.scrollSettleTimer !== null) document2.defaultView?.clearTimeout(watch.scrollSettleTimer);
6625watch.scrollSettleTimer = null;
6626watch.scrollSettleEntries.clear();
6627}
6628function disposePortalWatch(document2, watch) {
6629cancelClippedPortalScrollSettle(document2, watch);
6630watch.lifecycle.abort();
6631if (portalWatches.get(document2) === watch) portalWatches.delete(document2);
6632}
6633function alignPortalEntries(entries2) {
6634const pending = readPortalAlignments(entries2);
6635for (const alignment of pending) writePortalAlignment(alignment);
6636return pending;
6637}
6638function readPortalAlignments(entries2) {
6639const clips = measurePortalClipBounds(entries2);
6640return entries2.map((entry, index) => {
6641const source = portalSourcePoint(entry);
6642const rootRect = entry.mirror.getBoundingClientRect();
6643const clip = clips[index] ?? null;
6644entry.preparedClip = clip;
6645const root = clip ? { x: clip.left, y: clip.top } : { x: rootRect.left, y: rootRect.top };
6646return {
6647entry,
6648source,
6649root,
6650clip,
6651x: source.x - entry.settled.source.x - (root.x - entry.settled.root.x),
6652y: source.y - entry.settled.source.y - (root.y - entry.settled.root.y)
6653};
6654});
6655}
6656function writePortalAlignment(alignment) {
6657const { entry, clip, x, y } = alignment;
6658applyPortalClipGeometry(entry.mirror, clip);
6659entry.applied = { x, y };
6660if (Math.abs(x) <= 0.01 && Math.abs(y) <= 0.01) {
6661setImportantStyleIfChanged(entry.paint, "transform", "none");
6662} else {
6663setImportantStyleIfChanged(
6664entry.paint,
6665"transform",
6666`translate3d(${stableCssPixels(x)}, ${stableCssPixels(y)}, 0px)`
6667);
6668}
6669}
6670function prepareDocumentAnnotationPortalMirrors(mirrors) {
6671const entries2 = portalEntriesForMirrors(mirrors);
6672const clips = measurePortalClipBounds(entries2);
6673for (const [index, entry] of entries2.entries()) {
6674const clip = clips[index] ?? null;
6675entry.preparedClip = clip;
6676applyPortalClipGeometry(entry.mirror, clip);
6677entry.paint.style.setProperty("transform", "none", "important");
6678entry.applied = { x: 0, y: 0 };
6679}
6680}
6681function preparedDocumentAnnotationPortalClipBounds(mirror) {
6682return portalWatches.get(mirror.ownerDocument)?.entries.get(mirror)?.preparedClip ?? null;
6683}
6684function invalidateDocumentAnnotationPortalClipTopology(mirror) {
6685const entry = portalWatches.get(mirror.ownerDocument)?.entries.get(mirror);
6686if (entry) entry.clipTopologyEpoch = -1;
6687}
6688function settleDocumentAnnotationPortalMirrors(mirrors) {
6689const entries2 = portalEntriesForMirrors(mirrors);
6690const snapshots = entries2.map((entry) => {
6691entry.sourceAnchor = sourceAnchorRange(entry.source);
6692return {
6693entry,
6694source: portalSourcePoint(entry),
6695rootRect: entry.mirror.getBoundingClientRect()
6696};
6697});
6698for (const { entry, source, rootRect } of snapshots) {
6699entry.settled = {
6700source,
6701root: { x: rootRect.left, y: rootRect.top }
6702};
6703entry.applied = { x: 0, y: 0 };
6704}
6705}
6706function documentAnnotationPortalMirrorsWithin(root = document) {
6707const document2 = root instanceof Document ? root : root.ownerDocument;
6708if (!document2) return [];
6709const watch = portalWatches.get(document2);
6710if (!watch) return [];
6711return pruneAndCollectEntries(document2, watch).filter((entry) => root instanceof Document || rootContains(root, entry.source)).map((entry) => entry.mirror);
6712}
6713function measurePortalClipBounds(entries2) {
6714const styles = new Map();
6715const rects = new Map();
6716return entries2.map((entry) => {
6717const watch = portalWatches.get(entry.source.ownerDocument);
6718const epoch = watch?.topologyEpoch ?? 0;
6719if (entry.clipTopologyEpoch !== epoch) {
6720entry.clipChain = portalClipChain(entry.source, styles);
6721entry.clipTopologyEpoch = epoch;
6722}
6723return clipBoundsFromChain(entry.source, entry.clipChain, rects);
6724});
6725}
6726function portalClipChain(source, styles) {
6727const chain = [];
6728for (const element of composedAncestors(source)) {
6729if (element === source.ownerDocument.body || element === source.ownerDocument.documentElement) break;
6730let style = styles.get(element);
6731if (!style) {
6732style = safeComputedStyle(element);
6733styles.set(element, style);
6734}
6735const clipsX = overflowClips(style.overflowX) || paintContainmentClips(style);
6736const clipsY = overflowClips(style.overflowY) || paintContainmentClips(style);
6737if (clipsX || clipsY) chain.push({ element, clipsX, clipsY });
6738}
6739return chain;
6740}
6741function clipBoundsFromChain(source, chain, rects) {
6742if (!chain.length) return null;
6743const view = source.ownerDocument.defaultView;
6744let bounds = {
6745left: 0,
6746top: 0,
6747right: view?.innerWidth ?? source.ownerDocument.documentElement.clientWidth,
6748bottom: view?.innerHeight ?? source.ownerDocument.documentElement.clientHeight
6749};
6750for (const { element, clipsX, clipsY } of chain) {
6751let rect = rects.get(element);
6752if (!rect) {
6753rect = element.getBoundingClientRect();
6754rects.set(element, rect);
6755}
6756if (clipsX) {
6757bounds.left = Math.max(bounds.left, rect.left);
6758bounds.right = Math.min(bounds.right, rect.right);
6759}
6760if (clipsY) {
6761bounds.top = Math.max(bounds.top, rect.top);
6762bounds.bottom = Math.min(bounds.bottom, rect.bottom);
6763}
6764}
6765return bounds;
6766}
6767function documentAnnotationPortalHasNonTranslationTransform(source) {
6768for (const element of composedAncestors(source)) {
6769const transform = safeComputedStyle(element).transform;
6770if (transform && transform !== "none" && !transformIsTranslationOnly(transform)) return true;
6771if (element === source.ownerDocument.body || element === source.ownerDocument.documentElement) break;
6772}
6773return false;
6774}
6775function unregisterDocumentAnnotationPortalMirror(mirror) {
6776const document2 = mirror.ownerDocument;
6777const watch = portalWatches.get(document2);
6778if (!watch) return;
6779const entry = watch.entries.get(mirror);
6780watch.entries.delete(mirror);
6781if (entry) watch.scrollSettleEntries.delete(entry);
6782if (!watch.scrollSettleEntries.size && watch.scrollSettleTimer !== null) {
6783cancelClippedPortalScrollSettle(document2, watch);
6784}
6785if (watch.entries.size) return;
6786disposePortalWatch(document2, watch);
6787}
6788function portalEntriesForMirrors(mirrors) {
6789const entries2 = [];
6790for (const mirror of mirrors) {
6791const entry = portalWatches.get(mirror.ownerDocument)?.entries.get(mirror);
6792if (entry && mirror.isConnected && entry.source.isConnected) entries2.push(entry);
6793}
6794return entries2;
6795}
6796function pruneAndCollectEntries(document2, watch) {
6797const live = [];
6798const retired = [];
6799for (const entry of watch.entries.values()) {
6800if (!entry.mirror.isConnected || !entry.source.isConnected) retired.push(entry);
6801else live.push(entry);
6802}
6803retired.forEach((entry) => watch.entries.delete(entry.mirror));
6804retired.forEach((entry) => watch.scrollSettleEntries.delete(entry));
6805retired.forEach((entry) => entry.retire());
6806if (!watch.scrollSettleEntries.size && watch.scrollSettleTimer !== null) {
6807cancelClippedPortalScrollSettle(document2, watch);
6808}
6809if (!watch.entries.size) {
6810disposePortalWatch(document2, watch);
6811}
6812return live;
6813}
6814function portalSourcePoint(entry) {
6815const rect = validAnchorRect(entry.sourceAnchor) ?? entry.source.getBoundingClientRect();
6816return { x: rect.left, y: rect.top };
6817}
6818function validAnchorRect(range) {
6819if (!range) return null;
6820const container = range.startContainer;
6821if (!container.isConnected) return null;
6822const rect = range.getBoundingClientRect();
6823return Number.isFinite(rect.left) && Number.isFinite(rect.top) && rect.width > 0 && rect.height > 0 ? rect : null;
6824}
6825function sourceAnchorRange(source) {
6826if (typeof Range !== "function" || typeof Range.prototype.getBoundingClientRect !== "function") return null;
6827const walker = source.ownerDocument.createTreeWalker(source, NodeFilter.SHOW_TEXT, {
6828acceptNode(node2) {
6829const text2 = node2.textContent ?? "";
6830return /\S/u.test(text2) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
6831}
6832});
6833const node = walker.nextNode();
6834if (!(node instanceof Text)) return null;
6835const first = node.data.search(/\S/u);
6836if (first < 0) return null;
6837const range = source.ownerDocument.createRange();
6838range.setStart(node, first);
6839range.setEnd(node, Math.min(node.length, first + 1));
6840return range;
6841}
6842function applyPortalClipGeometry(mirror, clip) {
6843if (!clip) {
6844setImportantStyleIfChanged(mirror, "left", "0px");
6845setImportantStyleIfChanged(mirror, "top", "0px");
6846setImportantStyleIfChanged(mirror, "width", "0px");
6847setImportantStyleIfChanged(mirror, "height", "0px");
6848setImportantStyleIfChanged(mirror, "overflow", "visible");
6849return;
6850}
6851setImportantStyleIfChanged(mirror, "left", stableCssPixels(clip.left));
6852setImportantStyleIfChanged(mirror, "top", stableCssPixels(clip.top));
6853setImportantStyleIfChanged(mirror, "width", stableCssPixels(Math.max(0, clip.right - clip.left)));
6854setImportantStyleIfChanged(mirror, "height", stableCssPixels(Math.max(0, clip.bottom - clip.top)));
6855setImportantStyleIfChanged(mirror, "overflow", "hidden");
6856}
6857function transitionCanMoveSource(target, source) {
6858if (target === source) return true;
6859if (!(target instanceof Element)) return false;
6860return target.contains(source) || source.contains(target);
6861}
6862function rootContains(root, source) {
6863if (root === source) return true;
6864return root instanceof Node && root.contains(source);
6865}
6866function overflowClips(value) {
6867return /^(?:auto|clip|hidden|overlay|scroll)$/u.test(value);
6868}
6869function documentPortalStackingLevel(source) {
6870const ancestors = composedAncestors(source).reverse();
6871for (const element of ancestors) {
6872if (element === source.ownerDocument.body || element === source.ownerDocument.documentElement) continue;
6873const style = safeComputedStyle(element);
6874if (!elementCreatesStackingContext(element, style)) continue;
6875return /^-?\d+$/u.test(style.zIndex) ? style.zIndex : "auto";
6876}
6877return "auto";
6878}
6879function elementCreatesStackingContext(element, style) {
6880if (style.position === "fixed" || style.position === "sticky") return true;
6881if (style.zIndex && style.zIndex !== "auto") {
6882const parentDisplay = element.parentElement ? safeComputedStyle(element.parentElement).display : "";
6883if (style.position !== "static" || parentDisplay.includes("flex") || parentDisplay.includes("grid")) return true;
6884}
6885return style.opacity !== "" && Number.parseFloat(style.opacity) < 1 || style.transform !== "" && style.transform !== "none" || style.filter !== "" && style.filter !== "none" || style.backdropFilter !== "" && style.backdropFilter !== "none" || style.perspective !== "" && style.perspective !== "none" || style.isolation === "isolate" || style.mixBlendMode !== "" && style.mixBlendMode !== "normal" || /(?:^|\s)(?:layout|paint|strict|content)(?:\s|$)/u.test(style.contain) || /(?:^|,\s*)(?:transform|opacity|filter|perspective)(?:\s*,|$)/u.test(style.willChange);
6886}
6887function paintContainmentClips(style) {
6888return /(?:^|\s)paint(?:\s|$)/u.test(style.contain) || style.clipPath !== "" && style.clipPath !== "none";
6889}
6890function transformIsTranslationOnly(transform) {
6891if (/^(?:translate(?:X|Y|Z|3d)?\([^)]*\)\s*)+$/iu.test(transform)) return true;
6892const matrix = transform.match(/^matrix\(([^)]+)\)$/u);
6893if (matrix) {
6894const values2 = matrix[1].split(",").map(Number);
6895return values2.length === 6 && values2.every(Number.isFinite) && Math.abs(values2[0] - 1) < 1e-4 && Math.abs(values2[1]) < 1e-4 && Math.abs(values2[2]) < 1e-4 && Math.abs(values2[3] - 1) < 1e-4;
6896}
6897const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/u);
6898if (!matrix3d) return false;
6899const values = matrix3d[1].split(",").map(Number);
6900if (values.length !== 16 || !values.every(Number.isFinite)) return false;
6901const identityIndexes = new Set([0, 5, 10, 15]);
6902const translationIndexes = new Set([12, 13, 14]);
6903return values.every((value, index) => translationIndexes.has(index) || (identityIndexes.has(index) ? Math.abs(value - 1) < 1e-4 : Math.abs(value) < 1e-4));
6904}
6905function composedAncestors(source) {
6906const ancestors = [];
6907const visited = new Set();
6908let current = source;
6909while (current && !visited.has(current)) {
6910ancestors.push(current);
6911visited.add(current);
6912if (current.assignedSlot) current = current.assignedSlot;
6913else if (current.parentElement) current = current.parentElement;
6914else {
6915const root = current.getRootNode();
6916current = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement ? root.host : null;
6917}
6918}
6919return ancestors;
6920}
6921const DOCUMENT_PORTAL_PROSE_DECORATIONS = new Set(["prose-full", "content-ruby"]);
6922const VOLATILE_CONVERSATION_IDENTITY_RE = /(?:^|[-_\s])(?:comment|message|post|reply|chat)(?:[-_\s]|$)/iu;
6923const VOLATILE_PROSE_IDENTITY_RE = /(?:^|[-_\s])(?:content[-_]?text|paragraph|prose.?wrap|description[-_]?text)(?:[-_\s]|$)/iu;
6924function sourcePreservingProseNeedsDocumentPortal(host, candidate) {
6925return documentPortalProseCandidateIsEligible(host, candidate) && hasDocumentPortalProseAncestor(host);
6926}
6927function documentPortalProseCandidateIsEligible(host, candidate) {
6928return [
6929!candidate.insideShadowDOM,
6930host.getRootNode() === host.ownerDocument,
6931DOCUMENT_PORTAL_PROSE_DECORATIONS.has(candidate.decoration ?? "skip"),
6932!candidate.interactivePassive,
6933candidate.preservesSource,
6934!documentAnnotationPortalHasNonTranslationTransform(host)
6935].every(Boolean);
6936}
6937function hasDocumentPortalProseAncestor(host) {
6938for (let current = host, depth = 0; current && depth < 8; current = composedAncestorElement(current), depth += 1) {
6939if (isDocumentPortalProseAncestor(current, host)) return true;
6940}
6941return false;
6942}
6943function isDocumentPortalProseAncestor(current, host) {
6944const identity = `${current.tagName} ${current.id} ${String(current.className || "")}`;
6945return [
6946isLikelyProseElement(current),
6947safeElementMatches$1(current, 'p,article,blockquote,figcaption,[role="article"]'),
6948VOLATILE_CONVERSATION_IDENTITY_RE.test(identity),
6949current === host && VOLATILE_PROSE_IDENTITY_RE.test(identity)
6950].some(Boolean);
6951}
6952const COLOR_SOURCE_CLASS_TOKENS = {
6953status: "status",
6954jpdb: "jpdb",
6955anki: "review",
6956pitch: "pitch",
6957off: "off"
6958};
6959function colorSourceClassName(scope, channel, source) {
6960return `jpdb-reader-${scope}-${channel}-${COLOR_SOURCE_CLASS_TOKENS[source]}`;
6961}
6962function selectedWordColorSourceToken(root, channels, sources) {
6963let selected = null;
6964for (const source of sources) {
6965if (channels.some((channel) => root.classList.contains(colorSourceClassName("word", channel, source)))) {
6966selected = COLOR_SOURCE_CLASS_TOKENS[source];
6967}
6968}
6969return selected;
6970}
6971const YOUTUBE_APP_HOSTS = new Set([
6972"youtube.com",
6973"www.youtube.com",
6974"m.youtube.com",
6975"music.youtube.com",
6976"studio.youtube.com",
6977"kids.youtube.com",
6978"gaming.youtube.com",
6979"youtu.be"
6980]);
6981function isYouTubeAppHostname(hostname = location.hostname) {
6982return YOUTUBE_APP_HOSTS.has(hostname.toLowerCase());
6983}
6984const BLOCKED_HTML_ELEMENTS = new Set(["base", "embed", "frame", "frameset", "iframe", "link", "meta", "noscript", "object", "portal", "script", "style", "foreignobject"]);
6985const BLOCKED_ATTRIBUTES = new Set(["action", "autofocus", "formaction", "is", "nonce", "ping", "srcdoc", "srcset"]);
6986const URL_ATTRIBUTES = new Set(["href", "poster", "src", "xlink:href"]);
6987const SAFE_URL_PROTOCOLS = new Set(["about:", "blob:", "chrome-extension:", "file:", "http:", "https:", "mailto:", "moz-extension:", "safari-web-extension:", "tel:"]);
6988const DATA_URL_PATTERN = /^data:(?:image\/(?:avif|bmp|gif|jpe?g|png|webp)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+)(?:;[^,]*)?,/i;
6989const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
6990let trustedHtmlPolicy;
6991function setInnerHtml(element, html) {
6992if (!replaceWithHtmlFragment(element, html)) {
6993element.textContent = html;
6994}
6995}
6996function replaceWithHtmlFragment(element, html) {
6997try {
6998const ownerDocument = element.ownerDocument || document;
6999const parsedRoot = parsedReplacementRoot(element, html);
7000if (!parsedRoot) return false;
7001const fragment = sanitizedReplacementFragment(ownerDocument, parsedRoot);
7002hydratePrivateElementState(fragment);
7003htmlReplacementTarget(element).replaceChildren(fragment);
7004return true;
7005} catch {
7006return false;
7007}
7008}
7009function parsedReplacementRoot(element, html) {
7010const { source, rootSelector } = contextualSanitizerSource(element, html);
7011const parsed = new DOMParser().parseFromString(trustedHtml(source), "text/html");
7012const root = rootSelector ? parsed.querySelector(rootSelector) : parsed.body;
7013if (root) sanitizeChildren(root, parsed);
7014return root;
7015}
7016function sanitizedReplacementFragment(ownerDocument, parsedRoot) {
7017const fragment = ownerDocument.createDocumentFragment();
7018fragment.append(...Array.from(parsedRoot.childNodes, (node) => ownerDocument.importNode(node, true)));
7019sanitizeChildren(fragment, ownerDocument);
7020return fragment;
7021}
7022function htmlReplacementTarget(element) {
7023return element.localName === "template" && "content" in element ? element.content : element;
7024}
7025function contextualSanitizerSource(element, html) {
7026if (element.namespaceURI === SVG_NAMESPACE) {
7027return {
7028source: `<svg xmlns="${SVG_NAMESPACE}" data-yomu-sanitize-root>${html}</svg>`,
7029rootSelector: "[data-yomu-sanitize-root]"
7030};
7031}
7032switch (element.localName.toLowerCase()) {
7033case "table":
7034return {
7035source: `<table data-yomu-sanitize-root>${html}</table>`,
7036rootSelector: "[data-yomu-sanitize-root]"
7037};
7038case "thead":
7039case "tbody":
7040case "tfoot":
7041return {
7042source: `<table><${element.localName} data-yomu-sanitize-root>${html}</${element.localName}></table>`,
7043rootSelector: "[data-yomu-sanitize-root]"
7044};
7045case "tr":
7046return {
7047source: `<table><tbody><tr data-yomu-sanitize-root>${html}</tr></tbody></table>`,
7048rootSelector: "[data-yomu-sanitize-root]"
7049};
7050case "colgroup":
7051return {
7052source: `<table><colgroup data-yomu-sanitize-root>${html}</colgroup></table>`,
7053rootSelector: "[data-yomu-sanitize-root]"
7054};
7055case "select":
7056return {
7057source: `<select data-yomu-sanitize-root>${html}</select>`,
7058rootSelector: "[data-yomu-sanitize-root]"
7059};
7060case "optgroup":
7061return {
7062source: `<select><optgroup data-yomu-sanitize-root>${html}</optgroup></select>`,
7063rootSelector: "[data-yomu-sanitize-root]"
7064};
7065default:
7066return { source: html, rootSelector: "" };
7067}
7068}
7069function htmlToFirstElement(html) {
7070const trimmed = html.trim();
7071if (!trimmed) return null;
7072const template = document.createElement("template");
7073setInnerHtml(template, trimmed);
7074const first = template.content.firstElementChild;
7075if (!(first instanceof HTMLElement)) return null;
7076first.remove();
7077return first;
7078}
7079function appendToDocumentHead(element) {
7080const target = document.head || document.documentElement || document.body;
7081if (target) {
7082target.appendChild(element);
7083return;
7084}
7085document.addEventListener(
7086"DOMContentLoaded",
7087() => {
7088if (!element.isConnected) appendToDocumentHead(element);
7089},
7090{ once: true }
7091);
7092}
7093function escapeHtml(value) {
7094return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7095}
7096function sanitizeChildren(parent, ownerDocument) {
7097for (const node of Array.from(parent.childNodes)) {
7098if (node.nodeType !== 1) continue;
7099const element = node;
7100const localName = element.localName.toLowerCase();
7101if (BLOCKED_HTML_ELEMENTS.has(localName) || localName.startsWith("animate") || localName === "set") {
7102element.remove();
7103continue;
7104}
7105if (localName.includes("-")) {
7106sanitizeChildren(element, ownerDocument);
7107element.replaceWith(...Array.from(element.childNodes));
7108continue;
7109}
7110sanitizeElement(element, ownerDocument);
7111const childRoot = localName === "template" && "content" in element ? element.content : element;
7112sanitizeChildren(childRoot, ownerDocument);
7113}
7114}
7115function sanitizeElement(element, ownerDocument) {
7116for (const attribute of Array.from(element.attributes)) {
7117const name = attribute.name.toLowerCase();
7118if (name.startsWith("on") || BLOCKED_ATTRIBUTES.has(name)) {
7119element.removeAttribute(attribute.name);
7120continue;
7121}
7122if (URL_ATTRIBUTES.has(name) && !isSafeHtmlUrl(attribute.value)) {
7123element.removeAttribute(attribute.name);
7124continue;
7125}
7126if (name === "style") {
7127const style = sanitizedInlineStyle(attribute.value, ownerDocument);
7128if (style) element.setAttribute(attribute.name, style);
7129else element.removeAttribute(attribute.name);
7130}
7131}
7132if (element.getAttribute("target")?.toLowerCase() === "_blank") {
7133const rel = new Set((element.getAttribute("rel") ?? "").split(/\s+/).filter(
7133Boolean));
7134rel.add("noopener");
7135rel.add("noreferrer");
7136element.setAttribute("rel", [...rel].join(" "));
7137}
7138}
7139function sanitizedInlineStyle(value, ownerDocument) {
7140const declaration = ownerDocument.createElement("span").style;
7141declaration.cssText = value;
7142const containsUnsafeSource = /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|-moz-binding)/i.test(value) || [...value.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/gi)].some((match) => !isSafeHtmlUrl(match[2]));
7143let removedProperty = false;
7144for (const property of Array.from(declaration)) {
7145const propertyValue = declaration.getPropertyValue(property);
7146if (property === "behavior" || property === "-moz-binding" || /(?:expression\s*\(|javascript\s*:|vbscript\s*:|@import|-moz-binding)/i.test(propertyValue) || [...propertyValue.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/gi)].some((match) => !isSafeHtmlUrl(match[2]))) {
7147declaration.removeProperty(property);
7148removedProperty = true;
7149}
7150}
7151return containsUnsafeSource || removedProperty ? declaration.cssText : value;
7152}
7153function isSafeHtmlUrl(value) {
7154const candidate = value.trim().replace(/[\u0000-\u0020\u007f]+/g, "");
7155if (!candidate) return true;
7156if (candidate.startsWith("#")) return true;
7157if (/^data:/i.test(candidate)) return DATA_URL_PATTERN.test(candidate);
7158try {
7159const parsed = new URL(candidate, "https://yomureader.invalid/");
7160return SAFE_URL_PROTOCOLS.has(parsed.protocol) && (parsed.protocol !== "about:" || parsed.href === "about:blank");
7161} catch {
7162return false;
7163}
7164}
7165function trustedHtml(value) {
7166try {
7167const factory = trustedTypesFactory();
7168if (!factory) return value;
7169if (trustedHtmlPolicy === void 0) trustedHtmlPolicy = createTrustedHtmlPolicy(factory);
7170return trustedHtmlPolicy?.createHTML(value) ?? value;
7171} catch {
7172trustedHtmlPolicy = null;
7173return value;
7174}
7175}
7176function trustedTypesFactory() {
7177const root = globalThis;
7178return [root.trustedTypes, typeof window === "undefined" ? void 0 : window.trustedTypes, root.unsafeWindow?.trustedTypes].find(
7179(factory) => Boolean(factory)
7180);
7181}
7182function createTrustedHtmlPolicy(factory) {
7183const existing = factory.getPolicy?.("yomu-reader");
7184if (existing?.createHTML) return existing;
7185const options = { createHTML: (html) => html };
7186return createTrustedHtmlPolicyWithOptions(
7187factory,
7188pageCompartmentValue(options, {
7189cloneFunctions: true,
7190wrapReflectors: true
7191})
7192) ?? createTrustedHtmlPolicyWithOptions(factory, options);
7193}
7194function createTrustedHtmlPolicyWithOptions(factory, options) {
7195try {
7196return factory.createPolicy?.("yomu-reader", options) ?? null;
7197} catch {
7198return null;
7199}
7200}
7201function syncProjectedReadings(owner, projections) {
7202yomuAnnotationsCompanion()?.syncProjectedReadings(owner, projections);
7203}
7204function clearProjectedReadings(owner) {
7205yomuAnnotationsCompanion()?.clearProjectedReadings(owner);
7206}
7207function clearProjectedReadingsWithin(root) {
7208return yomuAnnotationsCompanion()?.clearProjectedReadingsWithin(root) ?? 0;
7209}
7210function pruneProjectedReadings(document2) {
7211yomuAnnotationsCompanion()?.pruneProjectedReadings(document2);
7212}
7213function projectedReadingWordAtPoint(document2, x, y, accepts) {
7214return yomuAnnotationsCompanion()?.projectedReadingWordAtPoint(document2, x, y, accepts) ?? null;
7215}
7216function createPostPaintPass(run) {
7217let pendingScheduler = null;
7218const flush = () => {
7219pendingScheduler = null;
7220run();
7221};
7222return {
7223schedule(view) {
7224const request = view?.requestAnimationFrame;
7225if (typeof request !== "function") {
7226flush();
7227return;
7228}
7229if (pendingScheduler === request) return;
7230const previous = pendingScheduler;
7231pendingScheduler = request;
7232try {
7233request.call(view, flush);
7234} catch (error) {
7235if (pendingScheduler === request) pendingScheduler = previous;
7236throw error;
7237}
7238}
7239};
7240}
7241function viewForNode(node) {
7242if (!node) return null;
7243const document2 = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument;
7244return document2?.defaultView ?? null;
7245}
7246const SHADOW_STYLE_MARKER = "data-yomu-shadow-reader-style";
7247let shadowReaderCssText = "";
7248let sharedShadowSheet;
7249const adoptedShadowRoots = new WeakSet();
7250const clonedShadowStyleNodes = new Set();
7251function supportsConstructableSheets(root) {
7252if (typeof CSSStyleSheet !== "function" || !("adoptedStyleSheets" in root)) return false;
7253if (sharedShadowSheet !== void 0) return sharedShadowSheet !== null;
7254try {
7255sharedShadowSheet = new CSSStyleSheet();
7256sharedShadowSheet.replaceSync(shadowReaderCssText);
7257} catch {
7258sharedShadowSheet = null;
7259}
7260return sharedShadowSheet !== null;
7261}
7262function setShadowReaderCss(css) {
7263if (css === shadowReaderCssText) return;
7264shadowReaderCssText = css;
7265if (sharedShadowSheet) {
7266try {
7267sharedShadowSheet.replaceSync(css);
7268} catch {
7269}
7270}
7271for (const ref of clonedShadowStyleNodes) {
7272const node = ref.deref();
7273if (!node || !node.isConnected) {
7274clonedShadowStyleNodes.delete(ref);
7275continue;
7276}
7277node.textContent = css;
7278}
7279}
7280function ensureReaderStylesForHost(host) {
7281const root = host.getRootNode();
7282if (typeof ShadowRoot === "undefined" || !(root instanceof ShadowRoot)) return;
7283ensureReaderStylesInShadowRoot(root);
7284}
7285function ensureReaderStylesInShadowRoot(root) {
7286if (adoptedShadowRoots.has(root)) return;
7287adoptedShadowRoots.add(root);
7288if (supportsConstructableSheets(root) && sharedShadowSheet) {
7289try {
7290root.adoptedStyleSheets = [...root.adoptedStyleSheets, sharedShadowSheet];
7291return;
7292} catch {
7293}
7294}
7295if (root.querySelector(`style[${SHADOW_STYLE_MARKER}]`)) return;
7296const style = root.ownerDocument.createElement("style");
7297style.setAttribute(SHADOW_STYLE_MARKER, "true");
7298style.textContent = shadowReaderCssText;
7299root.append(style);
7300clonedShadowStyleNodes.add(new WeakRef(style));
7301}
7302function hasPositiveRectArea(rect, right = rect.right || rect.left + rect.width, bottom = rect.bottom || rect.top + rect.height) {
7303return right > rect.left && bottom > rect.top;
7304}
7305function coordinateInRange(value, start, end, slack) {
7306return value >= start - slack && value <= end + slack;
7307}
7308const READABLE_IGNORED_TAGS = new Set(["RT", "RP", "SCRIPT", "STYLE"]);
7309const MAX_CONTEXT_SENTENCE_LENGTH = 180;
7310function unwrapReaderWords(root = document, options = {}) {
7311const words = Array.from(root.querySelectorAll(".jpdb-reader-word")).filter((word) => options.includeReaderRoot || !word.closest(READER_ROOT_SELECTOR$3)).filter((word) => !word.closest("[data-jpdb-reader-surface-ignore]")).filter((word) => !options.excludeSelector || !word.matches(options.excludeSelector));
7312const numberBinds = Array.from(root.querySelectorAll(".jpdb-reader-number-bind")).filter((bind) => options.includeReaderRoot || !bind.closest(READER_ROOT_SELECTOR$3)).filter((bind) => !bind.closest("[data-jpdb-reader-surface-ignore]"));
7313const parents = new Set();
7314words.forEach(clearProjectedReadingsWithin);
7315for (const word of words) {
7316const parent = word.parentNode;
7317if (!parent) continue;
7318parents.add(parent);
7319word.replaceWith(document.createTextNode(readerWordSurfaceText(word)));
7320}
7321for (const bind of numberBinds) {
7322if (bind.nextElementSibling?.classList.contains("jpdb-reader-word")) continue;
7323const parent = bind.parentNode;
7324if (!parent) continue;
7325parents.add(parent);
7326bind.replaceWith(document.createTextNode(bind.textContent ?? ""));
7327}
7328parents.forEach((parent) => parent.normalize());
7329return words.length;
7330}
7331function readerWordSurfaceText(element) {
7332const surface = readerWordChildSurfaceText(element);
7333return surface || element.getAttribute("data-surface") || "";
7334}
7335function readerWordChildSurfaceText(element) {
7336let text2 = "";
7337element.childNodes.forEach((node) => {
7338if (node.nodeType === Node.TEXT_NODE) {
7339text2 += node.textContent ?? "";
7340return;
7341}
7342if (node.nodeType !== Node.ELEMENT_NODE) return;
7343const child = node;
7344if (isSurfaceIgnoredElement$1(child)) return;
7345text2 += readerWordChildSurfaceText(child);
7346});
7347return text2;
7348}
7349function isReaderWordElement(element) {
7350return element instanceof HTMLElement && element.classList.contains("jpdb-reader-word");
7351}
7352function readerWordAtPointInScope(scope, x, y, accepts = () => true) {
7353let best = null;
7354for (const word of readerWordsInScope(scope)) {
7355if (!accepts(word)) continue;
7356const score = readerWordPointScore(word, x, y);
7357if (score === null) continue;
7358if (!best || score < best.score) best = { word, score };
7359}
7360return best?.word ?? null;
7361}
7362function readerWordsInScope(scope) {
7363const ownWord = scope instanceof HTMLElement && isReaderWordElement(scope) ? [scope] : [];
7364return [...ownWord, ...Array.from(scope.querySelectorAll(".jpdb-reader-word"))];
7365}
7366function readerWordPointScore(word, x, y) {
7367let best = null;
7368for (const rect of Array.from(word.getClientRects())) {
7369const score = rectPointScore(rect, x, y);
7370if (score === null) continue;
7371if (best === null || score < best) best = score;
7372}
7373return best;
7374}
7375function rectPointScore(rect, x, y) {
7376const right = rect.right || rect.left + rect.width;
7377const bottom = rect.bottom || rect.top + rect.height;
7378if (!hasPositiveRectArea(rect, right, bottom)) return null;
7379const slack = 0.75;
7380if (!coordinateInRange(x, rect.left, right, slack) || !coordinateInRange(y, rect.top, bottom, slack)) return null;
7381const centerX = rect.left + (right - rect.left) / 2;
7382const centerY = rect.top + (bottom - rect.top) / 2;
7383return Math.hypot(x - centerX, y - centerY);
7384}
7385function nearestReadableSentenceForElement(element, fallback = "") {
7386const surface = readerWordSurfaceText(element).trim() || element.textContent?.trim() || "";
7387const cleanFallback = cleanReadableSentence(fallback);
7388const ocrLine = element.closest(".jpdb-ocr-line");
7389if (ocrLine) return ocrLineSentenceForElement(ocrLine, surface, cleanFallback);
7390const nearest = nearestReadableAncestorSentence(element, surface, cleanFallback);
7391return nearest || fallbackReadableSentence(cleanFallback, surface);
7392}
7393function ocrLineSentenceForElement(ocrLine, surface, cleanFallback) {
7394const line = cleanOcrLineText(ocrLine);
7395if (shouldUseOcrLineText(line, surface)) return line;
7396const multi = cleanOcrSentenceText(ocrLine, line, cleanFallback);
7397const local = ocrSurfaceSentence(multi, surface, cleanFallback);
7398if (local) return local;
7399return cleanReadableSentence(firstReadableText(line, multi, surface, cleanFallback));
7400}
7401function cleanOcrLineText(ocrLine) {
7402return cleanReadableSentence(firstReadableText(ocrLine.dataset.ocrText, ocrLine.getAttribute("aria-label")));
7403}
7404function shouldUseOcrLineText(line, surface) {
7405if (!line) return false;
7406return !surface || textIncludesSearch(line, surface);
7407}
7408function cleanOcrSentenceText(ocrLine, line, cleanFallback) {
7409return cleanReadableSentence(firstReadableText(ocrLine.dataset.sentence, line, cleanFallback));
7410}
7411function ocrSurfaceSentence(multi, surface, cleanFallback) {
7412if (!surface) return "";
7413return sentenceAroundSurface(multi, surface, cleanFallback);
7414}
7415function firstReadableText(...values) {
7416return values.find((value) => Boolean(value)) ?? "";
7417}
7418function nearestReadableAncestorSentence(element, surface, cleanFallback) {
7419let current = element.parentElement;
7420while (isReadableAncestorCandidate(current)) {
7421const sentence = readableAncestorSentence(current, element, surface, cleanFallback);
7422if (sentence) return sentence;
7423current = current.parentElement;
7424}
7425return "";
7426}
7427function isReadableAncestorCandidate(element) {
7428if (!element) return false;
7429return element !== document.body && element !== document.documentElement;
7430}
7431function readableAncestorSentence(element, target, surface, cleanFallback) {
7432if (!canReadSentenceContextFrom(element)) return "";
7433const text2 = readableSurfaceText(element);
7434const range = readableSurfaceRange(element, target);
7435const sentence = range ? sentenceAroundRange(text2, range.start, range.end, cleanFallback) : sentenceAroundSurface(text2, surface, cleanFallback);
7436return isUsefulContextSentence(sentence, cleanFallback, surface) ? sentence : "";
7437}
7438function fallbackReadableSentence(cleanFallback, surface) {
7439return sentenceAroundSurface(cleanFallback, surface) || cleanFallback;
7440}
7441function canReadSentenceContextFrom(element) {
7442return !element.closest(READER_ROOT_SELECTOR$3) || Boolean(element.closest(".jpdb-reader-popover, .jpdb-subtitle-player, .jpdb-subtitle-list, .jpdb-ocr-layer"));
7443}
7444function sentenceAroundSurface(value, surface = "", fallback = "") {
7445const text2 = cleanReadableSentence(value);
7446if (!isJapaneseSentenceContext(text2)) return "";
7447const search = sentenceSearchText(text2, surface, fallback);
7448const index = sentenceSearchIndex(text2, search);
7449if (index < 0) return clampContextText(text2);
7450const hardBounded = hardBoundedSentence(text2, index, search.length);
7451const hardClean = trimSoftSentenceBoundary(hardBounded, search);
7452if (hardClean.length <= MAX_CONTEXT_SENTENCE_LENGTH) return hardClean;
7453return clampLongSentence(hardClean, search);
7454}
7455function sentenceAroundRange(value, start, end, fallback = "") {
7456if (!isJapaneseSentenceContext(cleanReadableSentence(value))) return "";
7457const range = normalizedSentenceRange(value.length, start, end);
7458if (!range) return sentenceAroundSurface(value, fallback, fallback);
7459const hardBounded = hardBoundedSentenceRange(value, range.start, range.end);
7460const localStart = range.start - hardBounded.start;
7461const localEnd = range.end - hardBounded.start;
7462const hardText = cleanReadableSentence(hardBounded.text);
7463const surface = cleanReadableSentence(hardBounded.text.slice(localStart, localEnd)) || fallback;
7464const cleanStart = cleanReadableSentence(hardBounded.text.slice(0, localStart)).length;
7465const cleanEnd = cleanStart + surface.length;
7466const hardClean = trimSoftSentenceBoundaryAroundRange(hardText, cleanStart, cleanEnd);
7467if (!hardClean) return sentenceAroundSurface(value, fallback, fallback);
7468if (hardClean.length <= MAX_CONTEXT_SENTENCE_LENGTH) return hardClean;
7469return clampLongSentence(hardClean, surface);
7470}
7471function normalizedSentenceRange(length, start, end) {
7472if (!Number.isFinite(start) || !Number.isFinite(end) || length <= 0) return null;
7473const safeStart = Math.max(0, Math.min(length - 1, Math.floor(start)));
7474const safeEnd = Math.max(safeStart + 1, Math.min(length, Math.ceil(end)));
7475return { start: safeStart, end: safeEnd };
7476}
7477function sentenceSearchIndex(text2, search) {
7478if (!search) return 0;
7479return text2.indexOf(search);
7480}
7481function isJapaneseSentenceContext(text2) {
7482return Boolean(text2 && isTargetLanguageText(text2));
7483}
7484function sentenceSearchText(text2, surface, fallback) {
7485const cleanSurface = cleanReadableSentence(surface);
7486const cleanFallback = cleanReadableSentence(fallback);
7487if (textIncludesSearch(text2, cleanSurface)) return cleanSurface;
7488if (textIncludesSearch(text2, cleanFallback)) return cleanFallback;
7489return "";
7490}
7491function textIncludesSearch(text2, search) {
7492if (!search) return false;
7493return text2.includes(search);
7494}
7495function clampContextText(text2) {
7496return text2.length <= MAX_CONTEXT_SENTENCE_LENGTH ? text2 : text2.slice(0, MAX_CONTEXT_SENTENCE_LENGTH).trim();
7497}
7498function isUsefulContextSentence(sentence, fallback, surface) {
7499if (!isJapaneseContextSentence(sentence)) return false;
7500if (!containsSurfaceContext(sentence, surface)) return false;
7501return fallback ? isRicherThanFallback(sentence, fallback) : true;
7502}
7503function isJapaneseContextSentence(sentence) {
7504return Boolean(sentence && isTargetLanguageText(sentence));
7505}
7506function containsSurfaceContext(sentence, surface) {
7507return !surface || sentence.includes(surface);
7508}
7509function isRicherThanFallback(sentence, fallback) {
7510if (sentence === fallback) return false;
7511if (sentence.length <= fallback.length + 2) return false;
7512return sentence.length >= 8 || /[。!?]/u.test(sentence);
7513}
7514function readableSurfaceText(node) {
7515if (node.nodeType === Node.TEXT_NODE) return nodeTextContent$1(node);
7516if (node.nodeType !== Node.ELEMENT_NODE) return "";
7517return readableElementSurfaceText(node);
7518}
7519function readableElementSurfaceText(element) {
7520if (isIgnoredReadableElement(element)) return "";
7521let text2 = "";
7522element.childNodes.forEach((child) => {
7523text2 += readableSurfaceText(child);
7524});
7525return text2;
7526}
7527function readableSurfaceRange(root, target) {
7528let offset = 0;
7529let range = null;
7530const visit = (node) => {
7531if (range) return;
7532if (node === target) {
7533const text2 = readableSurfaceText(node);
7534range = { start: offset, end: offset + text2.length };
7535offset += text2.length;
7536return;
7537}
7538if (node.nodeType === Node.TEXT_NODE) {
7539offset += nodeTextContent$1(node).length;
7540return;
7541}
7542if (node.nodeType !== Node.ELEMENT_NODE) return;
7543const element = node;
7544if (isIgnoredReadableElement(element)) return;
7545element.childNodes.forEach(visit);
7546};
7547visit(root);
7548return range;
7549}
7550function isIgnoredReadableElement(element) {
7551return isSurfaceIgnoredElement$1(element) || element.matches('button,svg,use,[aria-hidden="true"],[role="button"]');
7552}
7553function isSurfaceIgnoredElement$1(element) {
7554return READABLE_IGNORED_TAGS.has(element.tagName) || element.matches("[data-jpdb-reader-surface-ignore],.jpdb-reader-furi,.jpdb-ocr-furi");
7555}
7556const SPACE_BEFORE_JAPANESE_PUNCTUATION_RE = new RegExp(
7557`([${KANA}${KANJI_LIKE_WITH_COUNTERS}])\\s+([${JAPANESE_SENTENCE_PUNCTUATION}])`,
7558"gu"
7559);
7560const SPACE_AFTER_JAPANESE_PUNCTUATION_RE = new RegExp(
7561`([${JAPANESE_SENTENCE_PUNCTUATION}])\\s+([${KANA}${KANJI_LIKE_WITH_COUNTERS}])`,
7562"gu"
7563);
7564function cleanReadableSentence(value) {
7565return value.replace(/\s+/g, " ").replace(SPACE_BEFORE_JAPANESE_PUNCTUATION_RE, "$1$2").replace(SPACE_AFTER_JAPANESE_PUNCTUATION_RE, "$1$2").trim();
7566}
7567function hardBoundedSentence(text2, index, length) {
7568const start = sentenceStartIndex(text2, index);
7569const end = sentenceEndIndex(text2, index + length);
7570return text2.slice(start, end).trim();
7571}
7572function hardBoundedSentenceRange(text2, start, end) {
7573const sentenceStart = sentenceStartIndex(text2, start);
7574const sentenceEnd = sentenceEndIndex(text2, end);
7575return {
7576text: text2.slice(sentenceStart, sentenceEnd),
7577start: sentenceStart
7578};
7579}
7580function sentenceStartIndex(text2, index) {
7581const terminators = new Set(activeLearningTarget().sentenceBoundaries.terminators);
7582for (let i = index - 1; i >= 0; i--) {
7583if (terminators.has(text2[i] ?? "")) return i + 1;
7584}
7585return 0;
7586}
7587function sentenceEndIndex(text2, index) {
7588const terminators = new Set(activeLearningTarget().sentenceBoundaries.terminators);
7589for (let i = index; i < text2.length; i++) {
7590if (terminators.has(text2[i] ?? "")) return i + 1;
7591}
7592return text2.length;
7593}
7594function trimSoftSentenceBoundary(sentence, surface) {
7595const clean = sentence.trim();
7596if (!surface) return clean;
7597const index = clean.indexOf(surface);
7598if (index < 0) return clean;
7599const start = softBoundaryStart(clean, index);
7600const end = softBoundaryEnd(clean, index + surface.length);
7601const trimmed = clean.slice(start, end).trim();
7602const omitted = `${clean.slice(0, start)}${clean.slice(end)}`;
7603return shouldUseSoftSentenceTrim(clean, trimmed, omitted) ? trimmed : clean;
7604}
7605function trimSoftSentenceBoundaryAroundRange(sentence, start, end) {
7606const leadingTrim = sentence.length - sentence.trimStart().length;
7607const clean = sentence.trim();
7608if (!clean) return clean;
7609const cleanStart = Math.max(0, Math.min(clean.length, start - leadingTrim));
7610const cleanEnd = Math.max(cleanStart, Math.min(clean.length, end - leadingTrim));
7611const trimStart = softBoundaryStart(clean, cleanStart);
7612const trimEnd = softBoundaryEnd(clean, cleanEnd);
7613const trimmed = clean.slice(trimStart, trimEnd).trim();
7614const omitted = `${clean.slice(0, trimStart)}${clean.slice(trimEnd)}`;
7615return shouldUseSoftSentenceTrim(clean, trimmed, omitted) ? trimmed : clean;
7616}
7617function shouldUseSoftSentenceTrim(clean, trimmed, omitted) {
7618if (!trimmed || trimmed === clean) return false;
7619return clean.length > 48 || activeLearningTarget().sentenceBoundaries.terminators.some((mark) => omitted.includes(mark));
7620}
7621function softBoundaryStart(text2, index) {
7622for (let i = index - 1; i >= 0; i--) {
7623if (isStrongWhitespaceBoundary(text2, i)) return i + 1;
7624}
7625return 0;
7626}
7627function softBoundaryEnd(text2, index) {
7628for (let i = index; i < text2.length; i++) {
7629if (isStrongWhitespaceBoundary(text2, i)) return i;
7630}
7631return text2.length;
7632}
7633function isStrongWhitespaceBoundary(text2, index) {
7634if (!activeLearningTarget().sentenceBoundaries.whitespaceIsBoundary) return false;
7635const char = text2[index] ?? "";
7636if (!/\s/u.test(char)) return false;
7637const before = text2.slice(Math.max(0, index - 24), index);
7638const after = text2.slice(index + 1, Math.min(text2.length, index + 25));
7639return isTargetLanguageText(before) && isTargetLanguageText(after);
7640}
7641function clampLongSentence(sentence, surface) {
7642if (sentence.length <= MAX_CONTEXT_SENTENCE_LENGTH) return sentence;
7643const index = surface ? sentence.indexOf(surface) : -1;
7644if (index < 0) return sentence.slice(0, MAX_CONTEXT_SENTENCE_LENGTH).trim();
7645const halfWindow = Math.floor((MAX_CONTEXT_SENTENCE_LENGTH - surface.length) / 2);
7646const start = Math.max(0, index - Math.max(0, halfWindow));
7647const end = Math.min(sentence.length, start + MAX_CONTEXT_SENTENCE_LENGTH);
7648return sentence.slice(start, end).trim();
7649}
7650function nodeTextContent$1(node) {
7651return node.textContent ?? "";
7652}
7653const deinflection = aggregateRuntimeModules().deinflection;
7654const {
7655deinflectJapaneseTerm,
7656termRulesMatch
7657} = deinflection;
7658function uniqueStrings(values, options = {}) {
7659const seen = new Set();
7660const result = [];
7661for (const value of values) {
7662const normalized = options.trim ? value?.trim() : value;
7663if (normalized === void 0 || normalized === null) continue;
7664if (options.dropEmpty && !normalized) continue;
7665if (seen.has(normalized)) continue;
7666seen.add(normalized);
7667result.push(normalized);
7668}
7669return result;
7670}
7671function uniqueNonEmptyStrings(values) {
7672return uniqueStrings(values, { dropEmpty: true });
7673}
7674function uniqueTrimmedStrings(values) {
7675return uniqueStrings(values, { trim: true, dropEmpty: true });
7676}
7677function stableHash32(value) {
7678let hash = 2166136261;
7679for (let index = 0; index < value.length; index += 1) {
7680hash ^= value.charCodeAt(index);
7681hash = Math.imul(hash, 16777619);
7682}
7683return hash >>> 0;
7684}
7685function stablePositiveHashId(value) {
7686return stableHash32(value) || 1;
7687}
7688function codePointBoundaryAtOrBefore(text2, offset) {
7689const clamped = Math.max(0, Math.min(offset, text2.length));
7690if (clamped > 0 && clamped < text2.length && isLowSurrogate(text2.charCodeAt(clamped)) && isHighSurrogate(text2.charCodeAt(clamped - 1))) {
7691return clamped - 1;
7692}
7693return clamped;
7694}
7695function codePointSafePrefix(text2, maxUtf16Units) {
7696return text2.slice(0, codePointBoundaryAtOrBefore(text2, maxUtf16Units));
7697}
7698function isHighSurrogate(value) {
7699return value >= 55296 && value <= 56319;
7700}
7701function isLowSurrogate(value) {
7702return value >= 56320 && value <= 57343;
7703}
7704const JAPANESE_CHARACTER_RE = new RegExp(
7705`(?:[${KANA}${HALFWIDTH_KATAKANA}]|${KANJI_LIKE_WITH_COUNTERS_PATTERN})`,
7706"u"
7707);
7708const FALLBACK_LOOKUP_TERM_LIMIT = 8;
7709const INFLECTION_BOUNDARY_SEGMENTS = new Set(["は", "が", "を", "に", "へ", "と", "で", "の", "や", "から", "まで", "より", "だけ", "しか", "など", "ね"]);
7710new Set([...INFLECTION_BOUNDARY_SEGMENTS, "な"]);
7711[...INFLECTION_BOUNDARY_SEGMENTS].sort((first, second) => second.length - first.length);
7712const BOGUS_SMALL_TSU_FINAL_RE = /っ[うくぐすずつづぬふぶぷむゆる]$/u;
7713function normalizeFallbackTerm(text2) {
7714return codePointSafePrefix(text2.replace(/\s+/g, " ").trim(), 80);
7715}
7716function bareFallbackCardFromText(text2, language) {
7717const spelling = normalizeFallbackTerm(text2);
7718const id = -stablePositiveHashId(`fallback
7719${language}
7720${spelling}`);
7721const fallbackLookupTerms = fallbackLookupTermsForText(spelling).slice(1);
7722return {
7723vid: id,
7724sid: id,
7725rid: 0,
7726spelling,
7727reading: "",
7728language,
7729frequencyRank: null,
7730partOfSpeech: [],
7731meanings: [],
7732cardState: ["not-in-deck"],
7733provisionalState: true,
7734pitchAccent: [],
7735wordWithReading: null,
7736source: "fallback",
7737...fallbackLookupTerms.length ? { fallbackLookupTerms } : {}
7738};
7739}
7740function fallbackLookupTermsForText(text2) {
7741const source = normalizeFallbackTerm(text2);
7742if (!source) return [];
7743const terms = deinflectJapaneseTerm(source).filter(isUsefulFallbackLookupCandidate).sort(compareFallbackLookupCandidates).map((candidate) =>
7743 normalizeFallbackTerm(candidate.term)).filter(Boolean);
7744return uniqueNonEmptyStrings([source, ...terms]).slice(0, FALLBACK_LOOKUP_TERM_LIMIT);
7745}
7746function fallbackLookupTermsForCard(card) {
7747const terms = uniqueNonEmptyStrings([card.spelling, ...card.fallbackLookupTerms ?? []].map(normalizeFallbackTerm).filter(Boolean));
7748return dictionaryFirstFallbackLookupTerms(terms, hasAmbiguousContinuativeStemCandidate(terms[0] ?? ""));
7749}
7750function isUsefulFallbackLookupCandidate(candidate) {
7751return candidate.depth > 0 && JAPANESE_CHARACTER_RE.test(candidate.term) && candidate.term.length > 1;
7752}
7753function compareJapaneseLookupCandidates(a, b) {
7754return a.depth - b.depth || fallbackRulePriority(a) - fallbackRulePriority(b) || b.term.length - a.term.length || a.term.localeCompare(b.term);
7755}
7756const compareFallbackLookupCandidates = compareJapaneseLookupCandidates;
7757function fallbackRulePriority(candidate) {
7758if (candidate.rules.some((rule) => rule === "vs" || rule === "vs-s" || rule === "suru" || rule === "vk" || rule === "kuru")) return 0;
7759if (candidate.rules.some((rule) => rule === "v1")) return 1;
7760if (candidate.rules.some((rule) => rule.startsWith("v5") || rule === "v5")) return 1;
7761if (candidate.rules.some((rule) => rule === "adj-i" || rule === "i-adj")) return 2;
7762return 3;
7763}
7764function dictionaryFirstFallbackLookupTerms(terms, sourceFirst = false) {
7765const [source, ...candidates] = terms;
7766const terminal = candidates.filter(isTerminalDictionaryFallbackTerm);
7767return uniqueNonEmptyStrings(sourceFirst ? [source ?? "", ...terminal, ...candidates] : [...terminal, ...candidates, source ?? ""]);
7768}
7769function hasAmbiguousContinuativeStemCandidate(source) {
7770return deinflectJapaneseTerm(source).some((candidate) => candidate.depth === 1 && candidate.reasons.length === 1 && candidate.reasons[0] === "continuative stem");
7771}
7772function isTerminalDictionaryFallbackTerm(term) {
7773return !BOGUS_SMALL_TSU_FINAL_RE.test(term) && fallbackLookupTermsForText(term).length <= 1;
7774}
7775function* uncoveredJapaneseRanges(text2, rangeStart, rangeEnd, isCovered) {
7776let gapStart = -1;
7777for (let index = rangeStart; index < rangeEnd; ) {
7778const codePoint = text2.codePointAt(index);
7779if (codePoint === void 0) break;
7780const character = String.fromCodePoint(codePoint);
7781const codePointEnd = index + character.length;
7782const nextIndex = Math.min(rangeEnd, codePointEnd);
7783const uncoveredJapanese = codePointEnd <= rangeEnd && JAPANESE_CHARACTER_RE.test(character) && !isCovered(index, nextIndex);
7784if (uncoveredJapanese) {
7785if (gapStart < 0) gapStart = index;
7786} else if (gapStart >= 0) {
7787yield { start: gapStart, end: index };
7788gapStart = -1;
7789}
7790index = nextIndex;
7791}
7792if (gapStart >= 0) yield { start: gapStart, end: rangeEnd };
7793}
7794function createRenderedWordSpan(token, options) {
7795const span = document.createElement("span");
7796const state = primaryCardState(token.card.cardState);
7797const showPitchAccent = options.showPitchAccent !== false;
7798span.className = readerWordClassName(state, token, { showPitchAccent });
7799registerRenderedWordPrivateState(span, renderedWordPrivateStateForCard(token.card, state));
7800span.dataset.tokenStart = String(token.start);
7801span.dataset.tokenEnd = String(token.end);
7802span.dataset.sentence = token.sentence ?? "";
7803applyRenderedWordLookupDataset(span, token);
7804applyRenderedWordPitchDataset(span, token, showPitchAccent);
7805applyRenderedWordDeckDataset(span, token.card);
7806applyRenderedWordOptions(span, token, options);
7807return span;
7808}
7809function applyRenderedWordLookupDataset(span, token) {
7810if (token.card.spelling) span.dataset.expression = token.card.spelling;
7811if (token.card.reading) span.dataset.reading = token.card.reading;
7812}
7813function applyRenderedWordPitchDataset(span, token, showPitchAccent) {
7814if (!showPitchAccent) return;
7815span.dataset.pitchClass = tokenPitchClass(token);
7816const pitchAccent = token.card.pitchAccent.join("|");
7817if ([Boolean(pitchAccent), !isParticleCard(token.card)].every(Boolean)) span.dataset.pitchAccent = pitchAccent;
7818applyPitchComponentGradient(span, token.card);
7819}
7820function applyRenderedWordDeckDataset(span, card) {
7821const membership = cardDeckMembership(card);
7822if (!membership.member) return;
7823if (!currentAccountDataSurfaceIsTrusted()) return;
7824span.dataset.deckMember = "true";
7825span.dataset.deckSource = membership.source;
7826if (membership.names.length) span.dataset.deckNames = membership.names.join(", ");
7827}
7828function applyRenderedWordOptions(span, token, options) {
7829applyRenderedWordScanOption(span, options);
7830applyRenderedWordProseOption(span, options);
7831applyRenderedWordMiningOption(span, token, options);
7832applyRenderedWordPassiveOption(span, options);
7833}
7834function applyRenderedWordScanOption(span, options) {
7835if (!options.scanWord) return;
7836span.classList.add("jpdb-reader-scan-word");
7837if (!options.proseWrap) span.style.setProperty("display", "inline", "important");
7838}
7839function applyRenderedWordProseOption(span, options) {
7840if (![options.proseWrap, !options.passiveInteraction].every(Boolean)) return;
7841span.classList.add("jpdb-reader-prose-word");
7842span.dataset.jpdbReaderProse = "true";
7843}
7844function applyRenderedWordMiningOption(span, token, options) {
7845if (!options.miningInsightKeys?.has(miningInsightTokenKey(token))) return;
7846span.classList.add("jpdb-reader-i-plus-one");
7847span.dataset.miningInsight = "i-plus-one";
7848}
7849function applyRenderedWordPassiveOption(span, options) {
7850if (!options.passiveInteraction) return;
7851span.classList.add("jpdb-reader-passive-word");
7852span.dataset.jpdbReaderPassive = "true";
7853}
7854function applyPitchComponentGradient(word, card) {
7855const gradient = pitchComponentUnderlineGradient(card);
7856if (!gradient) return;
7857word.dataset.pitchComponents = "true";
7858word.style.setProperty("--jpdb-reader-inline-pitch-gradient", gradient);
7859}
7860const OWNED_SENTENCE_SURFACE_SELECTOR = [
7861".jpdb-reader-text-mirror",
7862".jpdb-reader-control-text-mirror",
7863".jpdb-ocr-line-text",
7864".jpdb-subtitle-primary",
7865".jpdb-subtitle-row-text",
7866".asbplayer-subtitles-container-bottom"
7867].join(",");
7868const STRUCTURAL_SENTENCE_BOUNDARY_TAGS = new Set(
7869"ADDRESS,ARTICLE,ASIDE,BLOCKQUOTE,BR,DD,DETAILS,DIALOG,DIV,DL,DT,FIGCAPTION,FIGURE,H1,H2,H3,H4,H5,H6,HR,LI,MAIN,OL,P,PRE,SECTION,TABLE,TBODY,TD,TFOOT,TH,THEAD,TR,UL".split(",")
7870);
7871function renderedWordSentenceScope(word) {
7872const ownedSurface = word.closest(OWNED_SENTENCE_SURFACE_SELECTOR);
7873if (ownedSurface) return ownedSurface;
7874for (let current = word.parentElement; current && current !== document.body; current = current.parentElement) {
7875if (isCustomElementTextBoundary$1(current) || STRUCTURAL_SENTENCE_BOUNDARY_TAGS.has(current.tagName)) return current;
7876}
7877return word.parentElement ?? word;
7878}
7879function isCustomElementTextBoundary$1(element) {
7880if (!element.localName.includes("-") || !isTargetLanguageText(element.textContent ?? "")) return false;
7881const parent = element.parentElement;
7882return !parent || !isLikelyProseElement(parent);
7883}
7884const TRAILING_DIGITS_RE = /[0-90-9]+$/u;
7885const NUMBER_BIND_CLASS = "jpdb-reader-number-bind";
7886const BLOCK_FLOW_TAG_NAMES = new Set("ADDRESS,ARTICLE,ASIDE,BLOCKQUOTE,DD,DETAILS,DIALOG,DIV,DL,DT,FIELDSET,FIGCAPTION,FIGURE,FOOTER,FORM,H1,H2,H3,H4,H5,H6,HEADER,HR,LI,MAIN,NAV,OL,P,PRE,SECTION,TABLE,TBODY,TD,TFOOT,TH,THEAD,TR,UL".split(","));
7887const EDITABLE_FRAGMENT_ROOT_SELECTOR = '[contenteditable="true"],textarea,input,[role="textbox"]';
7888const EDITABLE_TEXT_SURFACE_SELECTOR = `[contenteditable],[role=textbox],[role=searchbox],[role=combobox][aria-autocomplete="list"],[role=combobox][aria-autocomplete="inline"],[role=combobox][aria-autocomplete="both"],[aria-multiline],[aria-placeholder],[data-placeholder],[data-slate-editor],[data-lexical-editor],[class*="placeholder" i],[class*="ProseMirror" i]`;
7889const BASE_SKIP_SELECTOR = `script,style,noscript,textarea,input,select,option,svg,use,[aria-hidden=true],${EDITABLE_TEXT_SURFACE_SELECTOR}
7889,[role=checkbox],[role=radio],[role=tab],[data-jpdb-reader-surface-ignore],[data-audio],[class*="audio" i],[class*="sound" i],[class*="speaker" i],[class*="voice" i],.jpdb-reader-text-mirror,.jpdb-reader-control-text-mirror,.jpdb-reader-canvas-text-layer,.jpdb-reader-word,.subsection-pitch-accent .subsection`;
7890const BASE_SKIP_SELECTOR_WITHOUT_TAB = BASE_SKIP_SELECTOR.replace(",[role=tab]", "");
7891const BASE_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = BASE_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7892const FORM_BOUNDARY_SKIP_SELECTOR = "form,label,fieldset,legend";
7893const GENERIC_CONTROL_TEXT_SKIP_SELECTOR = `${FORM_BOUNDARY_SKIP_SELECTOR},[role=form],[role=search]`;
7894const PLAYER_CHROME_SKIP_SELECTOR = selectorPairs("control,toggle,player", ["class"]);
7895const SKIP_SELECTOR = `${BASE_SKIP_SELECTOR},${FORM_BOUNDARY_SKIP_SELECTOR},button,summary,rt,rp`;
7896const SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = `${BASE_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN},${FORM_BOUNDARY_SKIP_SELECTOR},button,summary,rt,rp`;
7897const FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR},${FORM_BOUNDARY_SKIP_SELECTOR},button,summary,[data-jpdb-reader-root]`;
7898const FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7899const HARD_FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR},${FORM_BOUNDARY_SKIP_SELECTOR},${PLAYER_CHROME_SKIP_SELECTOR},[data-jpdb-reader-root]`;
7900const HARD_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = HARD_FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7901const PLAYER_CHROME_FREE_HARD_FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR},${FORM_BOUNDARY_SKIP_SELECTOR},[data-jpdb-reader-root]`;
7902const PLAYER_CHROME_FREE_HARD_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = PLAYER_CHROME_FREE_HARD_FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7903const TAB_CHROME_FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR_WITHOUT_TAB},${FORM_BOUNDARY_SKIP_SELECTOR},${PLAYER_CHROME_SKIP_SELECTOR},[data-jpdb-reader-root]`;
7904const TAB_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = TAB_CHROME_FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7905const PLAYER_CHROME_FREE_TAB_CHROME_FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR_WITHOUT_TAB},${FORM_BOUNDARY_SKIP_SELECTOR},[data-jpdb-reader-root]`;
7906const PLAYER_CHROME_FREE_TAB_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = PLAYER_CHROME_FREE_TAB_CHROME_FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7907const FORM_CHROME_FRAGMENT_SKIP_SELECTOR = `${BASE_SKIP_SELECTOR},${PLAYER_CHROME_SKIP_SELECTOR},button,summary,a[href],[role="button"]`;
7908const FORM_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = FORM_CHROME_FRAGMENT_SKIP_SELECTOR.replace(",[aria-hidden=true]", "");
7909const PASSIVE_AWARE_FRAGMENT_SKIP_SELECTOR = `script,style,noscript,textarea,input,select,option,svg,use,[hidden],[aria-hidden="true"],${EDITABLE_TEXT_SURFACE_SELECTOR},.jpdb-reader-text-mirror,.jpdb-reader-control-text-mirror,.jpdb-reader-canvas-text-layer,.jpdb-reader-word,.subsection-pitch-accent .subsection,[data-jpdb-reader-root]`;
7910const PASSIVE_AWARE_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN = PASSIVE_AWARE_FRAGMENT_SKIP_SELECTOR.replace(',[aria-hidden="true"]', "");
7911const FORM_CHROME_BOUNDARY_TAGS = ",FORM,LABEL,FIELDSET,LEGEND,";
7912const DISPLAY_HEADING_RE = /^H[1-6]$/;
7913const DISPLAY_HEADING_SELECTOR = "h1,h2,h3,h4,h5,h6";
7914const FORM_CONTROL_TEXT_MAX_LENGTH = 120;
7915const FORM_CONTROL_SELECT_OPTION_LIMIT = 8;
7916const FORM_CONTROL_TEXT_TARGET_SELECTOR = "select,input,textarea";
7917const PROSE_TAGS = ",P,LI,DD,DT,TD,TH,BLOCKQUOTE,FIGCAPTION,";
7918const READER_RENDERED_TEXT_BLOCK_TAGS = `${PROSE_TAGS}H1,H2,H3,H4,H5,H6,`;
7919const BLOCK_TAGS = new Set("ADDRESS,ARTICLE,ASIDE,BLOCKQUOTE,BR,DD,DETAILS,DIALOG,DIV,DL,DT,FIGCAPTION,FIGURE,H1,H2,H3,H4,H5,H6,HR,LI,MAIN,OL,P,PRE,SECTION,TABLE,TBODY,TD,TFOOT,TH,THEAD,TR,UL".split(","));
7920const READER_WORD_SELECTOR$1 = ".jpdb-reader-word";
7921const READER_TEXT_MIRROR_SELECTOR = ".jpdb-reader-text-mirror";
7922const READER_OWNED_TEXT_SELECTOR = ".jpdb-reader-word,.jpdb-reader-text-mirror,[data-jpdb-reader-root]";
7923const READER_CONTROL_TEXT_MIRROR_SELECTOR = ".jpdb-reader-control-text-mirror";
7924const READER_CANVAS_TEXT_LAYER_SELECTOR = ".jpdb-reader-canvas-text-layer";
7925const READER_CONTROL_PLACEHOLDER_HIDDEN_ATTRIBUTE = "data-jpdb-reader-control-placeholder-hidden";
7926const NON_DESTRUCTIVE_TEXT_HOST_SELECTOR = "yt-formatted-string,yt-attributed-string,.ytAttributedStringHost,.yt-core-attributed-string,.yt-core-attributed-string--white-space-pre-wrap";
7927const TEXT_MIRROR_NATIVE_TEXT_SKIP_SELECTOR = `${READER_TEXT_MIRROR_SELECTOR},script,style,noscript,template,[hidden],[aria-hidden="true"]`;
7928const TEXT_MIRROR_ARIA_LABEL_SKIP_SELECTOR = `${READER_TEXT_MIRROR_SELECTOR},[hidden],[aria-hidden="true"]`;
7929const RENDERED_SCAN_HOST_MAX_TEXT = 1e3;
7930const RENDERED_SCAN_HOST_REJECTION_WINDOW_MS = 15e3;
7931const RENDERED_SCAN_HOST_REJECTION_RESET_MS = 6e4;
7932const RENDERED_SCAN_HOST_RESCAN_DELAYS_MS = [700, 1600, 4e3, 1e4];
7933const NON_DESTRUCTIVE_SCAN_MIRROR_STALE_EVENT = "jpdb-reader-text-mirror-stale";
7934const STALE_MIRROR_REMOVAL_GRACE_MS = 600;
7935const renderedScanHosts = new WeakMap();
7936const textMirrorHosts = new WeakMap();
7937const canvasFallbackTextLayers = new WeakMap();
7938function getSelectionText() {
7939return normalizedSelectedText(activeControlSelectionText(activeSelectableControl())) || documentSelectionText();
7940}
7941function documentSelectionText() {
7942return normalizedSelectedText(window.getSelection()?.toString() ?? "");
7943}
7944function activeControlSelectionText(control) {
7945if (!control) return "";
7946const range = controlSelectionRange(control);
7947return range ? control.value.slice(range.start, range.end) : "";
7948}
7949function activeSelectableControl() {
7950const element = deepActiveElement();
7951if (element instanceof HTMLTextAreaElement) return element;
7952if (!(element instanceof HTMLInputElement)) return null;
7953if (element.type.toLowerCase() === "password") return null;
7954return controlSelectionRange(element) ? element : null;
7955}
7956function deepActiveElement(root = document) {
7957const active = root.activeElement;
7958if (active?.shadowRoot?.activeElement) return deepActiveElement(active.shadowRoot);
7959return active;
7960}
7961function controlSelectionRange(control) {
7962try {
7963const start = control.selectionStart;
7964const end = control.selectionEnd;
7965if (typeof start !== "number" || typeof end !== "number" || start === end) return null;
7966return start < end ? { start, end } : { start: end, end: start };
7967} catch {
7968return null;
7969}
7970}
7971function normalizedSelectedText(text2) {
7972return text2.replace(/\s+/g, " ").trim();
7973}
7974function collectVisibleTextTargets(limit = 40) {
7975return collectTextTargetsIn(document.body, limit, true);
7976}
7977function documentHasJapaneseText(limit = 2e5, roots = document.body ? [document.body] : []) {
7978return documentJapaneseTextProbe(limit, roots).hasJapanese;
7979}
7980function documentJapaneseTextProbe(limit = 2e5, roots = document.body ? [document.body] : []) {
7981if (!roots.length) return { hasJapanese: false, shadowDiscoveryExhausted: false };
7982const lightTextBudget = { inspectedCharacters: 0, limit };
7983for (const root of roots) {
7984if (textWalkerHasJapaneseWithinBudget(visibleTextWalker(root), lightTextBudget)) {
7985return { hasJapanese: true, shadowDiscoveryExhausted: false };
7986}
7987}
7988const shadowBudget = {
7989inspectedElements: 0,
7990exhausted: false
7991};
7992const shadowTextBudget = { inspectedCharacters: 0, limit };
7993let foundShadowJapanese = false;
7994let inspectedLightElements = 0;
7995for (const root of roots) {
7996const rootNode = root;
7997const elements = document.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
7998let node = rootNode instanceof HTMLElement ? rootNode : elements.nextNode();
7999while (node) {
8000if (inspectedLightElements >= STARTUP_LIGHT_DOM_DISCOVERY_ELEMENT_LIMIT) {
8001shadowBudget.exhausted = true;
8002break;
8003}
8004inspectedLightElements += 1;
8005const element = node;
8006if (element.shadowRoot || isCustomElementHost(element)) {
8007if (!consumeShadowLookaheadElement(shadowBudget)) break;
8008const shadowRoot = watchPotentialOpenShadowRootHost(element);
8009if (shadowRoot) {
8010if (startupShadowBranchHasVisibleJapanese(
8011shadowRoot,
8012SHADOW_SCAN_MAX_DEPTH,
8013shadowBudget,
8014shadowTextBudget
8015)) foundShadowJapanese = true;
8016}
8017}
8018if (shadowBudget.exhausted) break;
8019node = elements.nextNode();
8020}
8021if (shadowBudget.exhausted) break;
8022}
8023return {
8024hasJapanese: foundShadowJapanese,
8025shadowDiscoveryExhausted: shadowBudget.exhausted
8026};
8027}
8028function visibleTextWalker(root) {
8029const visibilityCache = new WeakMap();
8030return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
8031acceptNode: (node) => visibleTextNodeFilter(node, visibilityCache)
8032});
8033}
8034function visibleTextNodeFilter(node, visibilityCache) {
8035return canInspectTextNode(node, visibilityCache) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
8036}
8037function canInspectTextNode(node, visibilityCache) {
8038const parent = node.parentElement;
8039if (!parent || parent.closest(READER_ROOT_SELECTOR$3)) return false;
8040if (!hasVisibleComposedTextAncestors(parent, visibilityCache)) return false;
8041const blocked = parent.closest(SKIP_SELECTOR);
8042if (!blocked) return true;
8043return isAnnotatableChipControl(blocked);
8044}
8045function hasVisibleComposedTextAncestors(element, cache) {
8046const cached = cache.get(element);
8047if (cached !== void 0) return cached;
8048const parent = composedAncestorElement(element);
8049const visible = !element.hidden && isVisibleStyle(safeComputedStyle(element)) && (!parent || hasVisibleComposedTextAncestors(parent, cache));
8050cache.set(element, visible);
8051return visible;
8052}
8053const CONTROL_LABEL_TEXT_LIMIT = 60;
8054const ANNOTATABLE_CONTROL_SELECTOR = COMPACT_INTERACTIVE_CHROME_CONTROL_SELECTOR;
8055function isAnnotatableChipControl(blocked) {
8056if (!blocked.matches(ANNOTATABLE_CONTROL_SELECTOR)) return false;
8057if (blocked.matches('[role="combobox"]') && !isNonEditableListboxTrigger(blocked)) return false;
8058const control = blocked.closest(ANNOTATABLE_CONTROL_SELECTOR) ?? blocked;
8059if (isComposerActionControl(control)) return false;
8060const text2 = control.textContent?.replace(/\s+/g, "").trim() ?? "";
8061return text2.length > 0 && text2.length <= CONTROL_LABEL_TEXT_LIMIT && isTargetLanguageText(text2);
8062}
8063function isComposerActionControl(control) {
8064return !!control.parentElement?.closest("[class*=composer i],[id*=composer i]")?.querySelector(EDITABLE_FRAGMENT_ROOT_SELECTOR);
8065}
8066function nodeTextContent(node) {
8067return node.textContent ?? "";
8068}
8069function collectTextTargetsIn(root, limit = 40, visibleOnly = true, options = {}) {
8070const walker = textTargetWalker(root, visibleOnly, options);
8071const targets = [];
8072let node;
8073while (targets.length < limit) {
8074node = walker.nextNode();
8075if (!node) break;
8076const target = textTargetFromAcceptedNode(node);
8077if (target) targets.push(target);
8078}
8079return targets;
8080}
8081function textTargetWalker(root, visibleOnly, options) {
8082return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
8083acceptNode: (node) => textTargetFilterResult(node, visibleOnly, options)
8084});
8085}
8086function textTargetFilterResult(node, visibleOnly, options) {
8087const text2 = nodeTextContent(node).trim();
8088if (!isCandidateScanText(text2)) return NodeFilter.FILTER_REJECT;
8089const parent = node.parentElement;
8090if (!parent) return NodeFilter.FILTER_REJECT;
8091return textTargetParentFilterResult(parent, text2, visibleOnly, options);
8092}
8093function isCandidateScanText(text2) {
8094if (text2.length < 2) return false;
8095return isTargetLanguageText(text2);
8096}
8097function textTargetParentFilterResult(parent, text2, visibleOnly, options) {
8098if (shouldRejectTextTargetParent(parent, text2, visibleOnly, options)) return NodeFilter.FILTER_REJECT;
8099if (shouldSkipTextTargetParent(parent)) return NodeFilter.FILTER_SKIP;
8100return NodeFilter.FILTER_ACCEPT;
8101}
8102function shouldRejectTextTargetParent(parent, text2, visibleOnly, options) {
8103const genericControl = parent.closest(GENERIC_CONTROL_TEXT_SKIP_SELECTOR);
8104if (!options.includeFormChrome && genericControl && !isCompactControlDesce
8104ndantTextTarget(parent, text2)) return true;
8105const blocked = parent.closest(SKIP_SELECTOR);
8106if (blocked && !isAnnotatableChipControl(blocked) && !isVisibleAriaHiddenJapanese(parent, blocked)) return true;
8107if (isInsideExcludedReaderRoot(parent, options)) return true;
8108return shouldRejectTextTargetPresentation(parent, visibleOnly);
8109}
8110function isCompactControlDescendantTextTarget(parent, text2) {
8111if (!isCompactInteractiveChromeText(text2.replace(/\s+/g, ""))) return false;
8112return Boolean(compactInteractiveChromeElement(parent) ?? compactPassiveChromeElement(parent));
8113}
8114function isInsideExcludedReaderRoot(parent, options) {
8115if (options.includeReaderRoot) return false;
8116return Boolean(parent.closest(READER_ROOT_SELECTOR$3));
8117}
8118function shouldRejectTextTargetPresentation(parent, visibleOnly) {
8119return shouldRejectInvisibleTextTarget(parent, visibleOnly);
8120}
8121function shouldSkipTextTargetParent(parent) {
8122let meaningful = 0;
8123for (const node of parent.childNodes) {
8124if (node.nodeType === Node.TEXT_NODE && !node.textContent?.trim()) continue;
8125meaningful += 1;
8126if (meaningful > 12) return true;
8127}
8128return false;
8129}
8130function shouldRejectInvisibleTextTarget(parent, visibleOnly) {
8131if (!visibleOnly) return false;
8132return !isVisible(parent);
8133}
8134function textTargetFromAcceptedNode(node) {
8135const parent = node.parentElement;
8136if (!parent) return null;
8137let decoration = classifyDecoration(parent);
8138if (decoration === "skip") return null;
8139const text2 = nodeTextContent(node).trim();
8140if (decoration !== "interactive-passive" && (isFragileUiText(parent, text2) || isShortCenteredDisplayHeading(parent, text2))) {
8141decoration = "interactive-passive";
8142}
8143const suppressRuby = decorationSuppressesRuby(decoration);
8144const passiveInteraction = isPassiveInteractionElement(parent) || suppressRuby;
8145return {
8146node,
8147text: text2,
8148parent,
8149hasNativeRuby: Boolean(parent.closest("ruby")),
8150decoration,
8151suppressRuby,
8152proseWrap: shouldWrapScanTargetAsProse(parent, suppressRuby, passiveInteraction),
8153layoutSensitive: isLayoutSensitiveScanElement(parent) || isGeometryFragileText(parent, text2),
8154passiveInteraction
8155};
8156}
8157function shouldWrapScanTargetAsProse(parent, suppressRuby, passiveInteraction) {
8158if (suppressRuby || passiveInteraction) return false;
8159return isReadableProseContext(parent);
8160}
8161function collectFragmentTextTargetsIn(root, limit = 40, visibleOnly = true, excludeSelector = "", options = {}) {
8162const state = {
8163targets: [],
8164fragments: [],
8165limit,
8166visibleOnly,
8167excludeSelector,
8168options,
8169shadowDepth: 0
8170};
8171visitFragmentNode(root, state, false, true);
8172flushFragmentTextTarget(state);
8173return state.targets;
8174}
8175function collectFormControlTextTargetsIn(root, limit = 40, visibleOnly = true, options = {}) {
8176const targets = [];
8177const controls = root instanceof HTMLElement && root.matches(FORM_CONTROL_TEXT_TARGET_SELECTOR) ? [root] : [];
8178controls.push(...Array.from(root.querySelectorAll(FORM_CONTROL_TEXT_TARGET_SELECTOR)));
8179for (const control of controls) {
8180if (targets.length >= limit) break;
8181const target = formControlTextTarget(control, visibleOnly, options);
8182if (target) targets.push(target);
8183}
8184return targets;
8185}
8186function formControlTextTarget(control, visibleOnly, options) {
8187if (!isCollectableFormControlTextElement(control, visibleOnly, options)) return null;
8188const text2 = collectableFormControlLookupText(control, options);
8189if (!text2) return null;
8190return {
8191text: text2,
8192parent: control,
8193fragments: [],
8194parserId: options.parserId,
8195layoutSensitive: true,
8196nonDestructive: true,
8197controlTextMirror: true,
8198controlSelectTextMode: options.selectTextMode,
8199passiveInteraction: !options.includeReaderRoot
8200};
8201}
8202function isCollectableFormControlTextElement(control, visibleOnly, options) {
8203if (control.closest(READER_CONTROL_TEXT_MIRROR_SELECTOR)) return false;
8204if (!options.includeReaderRoot && control.closest(READER_ROOT_SELECTOR$3)) return false;
8205if (visibleOnly && !options.includeReaderRoot && isTextEntryFormControl(control)) return false;
8206if (options.excludeSelector && (safeElementMatches$1(control, options.excludeSelector) || control.closest(options.excludeSelector))) return false;
8207if (isDisabledFormControl(control) || isUnlookupableFormControl(control)) return false;
8208return !visibleOnly || isVisible(control);
8209}
8210function isTextEntryFormControl(control) {
8211return control instanceof HTMLTextAreaElement || control instanceof HTMLInputElement && isTextEntryInput(control);
8212}
8213function isTextEntryInput(input) {
8214const type = input.type.toLowerCase();
8215return type === "" || ["email", "number", "search", "tel", "text", "url"].includes(type);
8216}
8217function isDisabledFormControl(control) {
8218return (control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement) && (control.disabled || control.getAttribute("aria-disabled")?.toLowerCase() === "true");
8219}
8220function isUnlookupableFormControl(control) {
8221if (!(control instanceof HTMLInputElement)) return false;
8222return ["hidden", "password", "file", "image"].includes(control.type.toLowerCase());
8223}
8224function collectableFormControlLookupText(control, options = {}) {
8225const text2 = formControlLookupText(control, options);
8226return isCollectableControlText(text2) ? text2 : "";
8227}
8228function formControlLookupText(control, options = {}) {
8229const parts = [];
8230if (control instanceof HTMLSelectElement) {
8231pushUniqueControlText(parts, selectLookupText(control, options.selectTextMode ?? "options"));
8232if (options.selectTextMode === "selected") return parts.join(" / ");
8233}
8234if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) return "";
8235pushUniqueControlText(parts, control.getAttribute("aria-label") ?? "");
8236pushUniqueControlText(parts, control.getAttribute("title") ?? "");
8237return parts.join(" / ");
8238}
8239function selectLookupText(select, mode) {
8240const selectedText = uniqueControlTexts(Array.from(select.selectedOptions).map(optionText));
8241if (mode === "selected") return selectedText.join(" / ");
8242const optionTextList = uniqueControlTexts(Array.from(select.options).map(optionText)).filter((text2) => isTargetLanguageText(text2));
8243const compactOptionList = compactSelectOptionListText(optionTextList);
8244return compactOptionList || selectedText.join(" / ") || optionTextList.slice(0, FORM_CONTROL_SELECT_OPTION_LIMIT).join(" / ");
8245}
8246function compactSelectOptionListText(options) {
8247if (options.length < 2 || options.length > FORM_CONTROL_SELECT_OPTION_LIMIT) return "";
8248const text2 = options.join(" / ");
8249return compactLength(text2) <= FORM_CONTROL_TEXT_MAX_LENGTH ? text2 : "";
8250}
8251function optionText(option) {
8252return normalizedControlText(option.label || option.textContent || "");
8253}
8254function pushUniqueControlText(parts, text2) {
8255const normalized = normalizedControlText(text2);
8256if (!normalized || !isTargetLanguageText(normalized) || parts.includes(normalized)) return;
8257parts.push(normalized);
8258}
8259function uniqueControlTexts(texts) {
8260const result = [];
8261texts.forEach((text2) => pushUniqueControlText(result, text2));
8262return result;
8263}
8264function normalizedControlText(text2) {
8265return text2.replace(/\s+/g, " ").trim();
8266}
8267function isCollectableControlText(text2) {
8268const compact2 = compactLength(text2);
8269return compact2 > 0 && compact2 <= FORM_CONTROL_TEXT_MAX_LENGTH && isTargetLanguageText(text2);
8270}
8271function fragmentText(items) {
8272return items.map((fragment) => fragment.node.data.slice(fragment.start, fragment.end)).join("");
8273}
8274function flushFragmentTextTarget(state) {
8275if (!state.fragments.length || fragmentCollectionComplete(state)) {
8276state.fragments.length = 0;
8277return;
8278}
8279const target = fragmentTextTargetFrom(state.fragments, state.options);
8280if (target) state.targets.push(target);
8281state.fragments.length = 0;
8282}
8283function fragmentTextTargetFrom(fragments, options) {
8284const trimmedFragments = trimTextFragments(fragments);
8285const text2 = fragmentText(trimmedFragments);
8286if (!isCollectableFragmentText(text2, trimmedFragments, options)) return null;
8287const parent = trimmedFragments[0]?.node.parentElement;
8288if (!parent) return null;
8289if (!options.includeReaderRoot && !options.allowShortCenteredHeadings && isShortCenteredDisplayHeading(parent, text2)) return null;
8290const decoration = fragmentTargetDecoration(parent, trimmedFragments);
8291if (decoration === "skip") return null;
8292const suppressRuby = decorationSuppressesRuby(decoration);
8293const passiveInteraction = suppressRuby || trimmedFragments.every((fragment) => fragment.passiveInteraction);
8294return {
8295text: text2,
8296parent,
8297fragments: trimmedFragments,
8298decoration,
8299suppressRuby,
8300proseWrap: shouldWrapScanTargetAsProse(parent, suppressRuby, passiveInteraction),
8301layoutSensitive: trimmedFragments.some((fragment) => fragment.layoutSensitive),
8302passiveInteraction,
8303forceInlineRender: options.forceInlineRender,
8304suppressRepaintLoopMirror: options.suppressRepaintLoopMirror,
8305...shadowDomTargetMetadata(parent)
8306};
8307}
8308function shadowDomTargetMetadata(parent) {
8309const root = parent.getRootNode();
8310if (!(root instanceof ShadowRoot)) return {};
8311return {
8312insideShadowDOM: true,
8313nonDestructive: true,
8314shadowHost: root.host instanceof HTMLElement ? root.host : void 0,
8315shadowRoot: root
8316};
8317}
8318function fragmentTargetDecoration(parent, fragments) {
8319const parentDecoration = classifyDecoration(parent);
8320if (parentDecoration === "skip") return "skip";
8321let passiveInteraction = parentDecoration === "interactive-passive";
8322const seen = new Set([parent]);
8323for (const fragment of fragments) {
8324const element = fragment.node.parentElement;
8325if (!element || seen.has(element)) continue;
8326seen.add(element);
8327const decoration = classifyDecoration(element);
8328if (decoration === "skip") return "skip";
8329if (decoration === "interactive-passive") passiveInteraction = true;
8330}
8331return passiveInteraction ? "interactive-passive" : parentDecoration;
8332}
8333function isCollectableFragmentText(text2, fragments, options) {
8334if (!isTargetLanguageText(text2)) return false;
8335if (compactFragmentTextLength(text2) >= (options.minLength ?? 2)) return true;
8336return fragments.some((fragment) => fragment.hasNativeRuby);
8337}
8338function compactFragmentTextLength(text2) {
8339return text2.replace(/\s+/g, "").length;
8340}
8341function visitFragmentNode(node, state, hasNativeRuby = false, isRoot = false) {
8342if (fragmentCollectionComplete(state)) return;
8343if (node.nodeType === Node.TEXT_NODE) {
8344collectFragmentTextNode(node, state, hasNativeRuby);
8345return;
8346}
8347if (node.nodeType !== Node.ELEMENT_NODE) return;
8348visitFragmentElement(node, state, hasNativeRuby, isRoot);
8349}
8350function collectFragmentTextNode(node, state, hasNativeRuby) {
8351const text2 = node.textContent ?? "";
8352if (shouldIgnoreFragmentTextNode(text2, state.options)) return;
8353const parent = node.parentElement;
8354if (text2) state.fragments.push({
8355node,
8356start: 0,
8357end: text2.length,
8358hasNativeRuby,
8359layoutSensitive: parent ? isLayoutSensitiveScanElement(parent) : false,
8360passiveInteraction: parent ? isFragmentPassiveInteractionElement(parent, state.options) : false
8361});
8362}
8363function shouldIgnoreFragmentTextNode(text2, options) {
8364return Boolean(options.mergeBlockFragments && !text2.trim());
8365}
8366function visitFragmentElement(element, state, hasNativeRuby, isRoot) {
8367if (shouldIgnoreFragmentElement(element, state.options, isRoot)) return;
8368if (shouldFlushAndSkipFragmentElement(element, state, isRoot)) {
8369flushFragmentTextTarget(state);
8370return;
8371}
8372const isBlock = isBlockFragmentElement(element, state.options);
8373flushFragmentBlockBoundary(isBlock, state);
8374visitFragmentElementChildren(element, state, nextFragmentRubyState(element, hasNativeRuby));
8375flushFragmentBlockBoundary(isBlock, state);
8376visitFragmentShadowRoot(element, state);
8377}
8378const SHADOW_SCAN_MAX_DEPTH = 4;
8379const SHADOW_JAPANESE_LOOKAHEAD_ELEMENT_LIMIT = 160;
8380const STARTUP_LIGHT_DOM_DISCOVERY_ELEMENT_LIMIT = 4096;
8381function visitFragmentShadowRoot(element, state) {
8382const shadowRoot = watchPotentialOpenShadowRootHost(element);
8383if (!shadowRoot) return;
8384if (state.shadowDepth >= SHADOW_SCAN_MAX_DEPTH) {
8385deferDepthCappedShadowHost(element);
8386return;
8387}
8388if (!shadowBranchHasJapanese(shadowRoot, SHADOW_SCAN_MAX_DEPTH - state.shadowDepth)) return;
8389flushFragmentTextTarget(state);
8390if (fragmentCollectionComplete(state)) {
8391deferDepthCappedShadowHost(element);
8392return;
8393}
8394state.shadowDepth += 1;
8395const shadowChildren = Array.from(shadowRoot.childNodes);
8396for (const child of shadowChildren) {
8397visitFragmentNode(child, state, false);
8398if (fragmentCollectionComplete(state)) break;
8399}
8400flushFragmentTextTarget(state);
8401state.shadowDepth -= 1;
8402}
8403function shadowBranchHasJapanese(root, remainingDepth, budget = { inspectedElements: 0, exhausted: false }) {
8404let foundJapanese = isTargetLanguageText(root.textContent ?? "");
8405if (remainingDepth <= 1) {
8406const foundNested = shadowRootHasNestedShadowRoot(root, budget);
8407return foundJapanese || foundNested;
8408}
8409const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8410let node = walker.nextNode();
8411while (node) {
8412if (!consumeShadowLookaheadElement(budget)) return true;
8413const element = node;
8414const nested = watchPotentialOpenShadowRootHost(element);
8415if (nested) {
8416if (shadowBranchHasJapanese(nested, remainingDepth - 1, budget)) foundJapanese = true;
8417}
8418node = walker.nextNode();
8419}
8420return foundJapanese;
8421}
8422function startupShadowBranchHasVisibleJapanese(root, remainingDepth, elementBudget, textBudget) {
8423let foundJapanese = textWalkerHasJapaneseWithinBudget(visibleTextWalker(root), textBudget);
8424const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8425let node = walker.nextNode();
8426while (node) {
8427if (!consumeShadowLookaheadElement(elementBudget)) return foundJapanese;
8428const element = node;
8429const nested = watchPotentialOpenShadowRootHost(element);
8430if (nested) {
8431if (remainingDepth <= 1) {
8432elementBudget.exhausted = true;
8433} else if (startupShadowBranchHasVisibleJapanese(
8434nested,
8435remainingDepth - 1,
8436elementBudget,
8437textBudget
8438)) {
8439foundJapanese = true;
8440}
8441}
8442if (elementBudget.exhausted) return foundJapanese;
8443node = walker.nextNode();
8444}
8445return foundJapanese;
8446}
8447function textWalkerHasJapaneseWithinBudget(walker, budget) {
8448if (budget.inspectedCharacters >= budget.limit) return false;
8449let node;
8450while (node = walker.nextNode()) {
8451const text2 = nodeTextContent(node);
8452const remaining = budget.limit - budget.inspectedCharacters;
8453const sampled = text2.slice(0, remaining);
8454budget.inspectedCharacters += sampled.length;
8455if (isTargetLanguageText(sampled)) return true;
8456if (budget.inspectedCharacters >= budget.limit) return false;
8457}
8458return false;
8459}
8460function shadowRootHasNestedShadowRoot(root, budget) {
8461let foundNested = false;
8462const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8463let node = walker.nextNode();
8464while (node) {
8465if (!consumeShadowLookaheadElement(budget)) return true;
8466const element = node;
8467const nested = watchPotentialOpenShadowRootHost(element);
8468if (nested) {
8469foundNested = true;
8470}
8471node = walker.nextNode();
8472}
8473return foundNested;
8474}
8475function consumeShadowLookaheadElement(budget) {
8476if (budget.inspectedElements >= SHADOW_JAPANESE_LOOKAHEAD_ELEMENT_LIMIT) {
8477budget.exhausted = true;
8478return false;
8479}
8480budget.inspectedElements += 1;
8481return true;
8482}
8483function isCustomElementHost(element) {
8484return element.tagName.includes("-");
8485}
8486const depthCappedShadowHosts = new Set();
8487const depthCappedShadowHostSeen = new WeakSet();
8488function deferDepthCappedShadowHost(element) {
8489if (depthCappedShadowHostSeen.has(element)) return;
8490depthCappedShadowHostSeen.add(element);
8491depthCappedShadowHosts.add(new WeakRef(element));
8492}
8493function drainDepthCappedShadowHosts() {
8494const hosts = [];
8495for (const ref of depthCappedShadowHosts) {
8496const host = ref.deref();
8497if (host) depthCappedShadowHostSeen.delete(host);
8498if (host?.isConnected) hosts.push(host);
8499}
8500depthCappedShadowHosts.clear();
8501return hosts;
8502}
8503function shouldIgnoreFragmentElement(element, options, isRoot = false) {
8504return isRubyAnnotationElement(element) || isSurfaceIgnoredElement(element) && !(isRoot && options.parseSurfaceIgnoredRoot) || isExcludedReaderRootElement(element, options);
8505}
8506function isRubyAnnotationElement(element) {
8507return element.tagName === "RT" || element.tagName === "RP";
8508}
8509function isSurfaceIgnoredElement(element) {
8510return element.matches("[data-jpdb-reader-surface-ignore]");
8511}
8512function isExcludedReaderRootElement(element, options) {
8513return !options.includeReaderRoot && Boolean(element.closest(READER_ROOT_SELECTOR$3));
8514}
8515function shouldFlushAndSkipFragmentElement(element, state, isRoot) {
8516if (fragmentElementMustRemainPageOwned(element, isRoot)) return true;
8517if (matchesSkippedFragmentElement(element, state, isRoot)) return true;
8518if (shouldSkipInvisibleFragmentElement(element, state.visibleOnly)) return true;
8519return shouldSkipFragmentTextPresentation(element, state.options);
8520}
8521function fragmentElementMustRemainPageOwned(element, isRoot) {
8522if (!isYouTubeAppHostname()) return false;
8523if (!isRoot && !safeElementMatches$1(element, PASSIVE_INTERACTION_SELECTOR)) return false;
8524return youtubeNativeChromeMustRemainPageOwned(element);
8525}
8526function matchesSkippedFragmentElement(element, state, isRoot) {
8527if (state.excludeSelector && fragmentSelectorSkipsElement(
8528element,
8529state.excludeSelector,
8530selectorWithoutAriaHiddenToken(state.excludeSelector)
8531)) return true;
8532if (isComposerActionControl(element)) return true;
8533if (isRoot && element.closest(EDITABLE_FRAGMENT_ROOT_SELECTOR) && !isSafeEditableSurfaceFragmentRoot(element, state.options)) return true;
8534return !isRoot && shouldSkipFragmentElement(element, state.options);
8535}
8536function isSafeEditableSurfaceFragmentRoot(element, options) {
8537return Boolean(options.includePassiveInteractions && safeElementMatches$1(element, PASSIVE_INTERACTION_SELECTOR) && isNavigationChromeContext(element));
8538}
8539function shouldSkipInvisibleFragmentElement(element, visibleOnly) {
8540if (!hasVisibleTextStyle(element) && !hasVisibleTextMirror(element)) return true;
8541if (!visibleOnly || hasVisibleTextMirror(element)) return false;
8542const rect = element.getBoundingClientRect();
8543if (isVisibleRect(rect)) return false;
8544return !isBoxlessFragmentWrapper(rect) || !hasVisibleJapaneseFragmentDescendant(element);
8545}
8546const VISIBLE_FRAGMENT_DESCENDANT_LOOKAHEAD_LIMIT = 512;
8547function isBoxlessFragmentWrapper(rect) {
8548return rect.width <= 0 || rect.height <= 0;
8549}
8550function hasVisibleJapaneseFragmentDescendant(element) {
8551const shadowBudget = { inspectedElements: 0, exhausted: false };
8552const lightJapanese = isTargetLanguageText(element.textContent ?? "");
8553if (element.shadowRoot && isVisible(element) && shadowBranchHasJapanese(element.shadowRoot, 2, shadowBudget)) return true;
8554const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
8555for (let inspected = 0, node = walker.nextNode(); node && inspected < VISIBLE_FRAGMENT_DESCENDANT_LOOKAHEAD_LIMIT; inspected += 1, node = walker.nextNode()) {
8556const descendant = node;
8557if (lightJapanese && isTargetLanguageText(descendant.textContent ?? "") && isVisible(descendant)) return true;
8558const shadow = descendant.shadowRoot;
8559if (shadow && isVisible(descendant) && shadowBranchHasJapanese(shadow, 2, shadowBudget)) return true;
8560}
8561return false;
8562}
8563function hasVisibleTextStyle(element) {
8564return isVisibleStyle(safeComputedStyle(element));
8565}
8566function hasVisibleTextMirror(element) {
8567return Array.from(element.children).some((child) => child instanceof HTMLElement && child.matches(READER_TEXT_MIRROR_SELECTOR) && isVisible(child));
8568}
8569function shouldSkipFragmentTextPresentation(element, options) {
8570const text2 = element.textContent?.trim() ?? "";
8571if (shouldSkipFragmentHeading(element, text2, options)) return true;
8572if (shouldSkipFragmentUiText(element, text2, options)) return true;
8573return isReaderRenderedTextBlock(element);
8574}
8575function shouldSkipFragmentHeading(element, text2, options) {
8576return Boolean(!options.heading && !options.allowShortCenteredHeadings && text2 && isShortCenteredDisplayHeading(element, text2));
8577}
8578function shouldSkipFragmentUiText(element, text2, options) {
8579return Boolean(!options.allowUiText && text2 && isFragileUiText(element, text2));
8580}
8581function isBlockFragmentElement(element, options) {
8582return !options.mergeBlockFragments && isFragmentParagraphBoundary(element, options) && !isInlineSentenceListItem(element);
8583}
8584function flushFragmentBlockBoundary(isBlock, state) {
8585if (isBlock) flushFragmentTextTarget(state);
8586}
8587function visitFragmentElementChildren(element, state, hasNativeRuby) {
8588for (const child of Array.from(element.childNodes)) {
8589visitFragmentNode(child, state, hasNativeRuby);
8590if (fragmentCollectionComplete(state)) break;
8591}
8592}
8593function nextFragmentRubyState(element, hasNativeRuby) {
8594return hasNativeRuby || element.tagName === "RUBY" || element.tagName === "RB";
8595}
8596function fragmentCollectionComplete(state) {
8597return state.targets.length >= state.limit;
8598}
8599function shouldSkipFragmentElement(element, options) {
8600if (options.includePassiveInteractions) return fragmentSelectorSkipsElement(
8601element,
8602PASSIVE_AWARE_FRAGMENT_SKIP_SELECTOR,
8603PASSIVE_AWARE_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN
8604);
8605if (options.includeFormChrome) return fragmentSelectorSkipsElement(
8606element,
8607FORM_CHROME_FRAGMENT_SKIP_SELECTOR,
8608FORM_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN
8609);
8610if (options.includeTabChrome) {
8611return options.includePlayerChrome ? fragmentSelectorSkipsElement(element, PLAYER_CHROME_FREE_TAB_CHROME_FRAGMENT_SKIP_SELECTOR, PLAYER_CHROME_FREE_TAB_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN) : fragmentSelectorSkipsElement(element, TAB_CHROME_FRAGMENT_SKIP_SELECTOR, TAB_CHROME_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN);
8612}
8613if (options.includeUiChrome) {
8614return options.includePlayerChrome ? fragmentSelectorSkipsElement(element, PLAYER_CHROME_FREE_HARD_FRAGMENT_SKIP_SELECTOR, PLAYER_CHROME_FREE_HARD_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN) : fragmentSelectorSkipsElement(element, HARD_FRAGMENT_SKIP_SELECTOR, HARD_FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN);
8615}
8616return fragmentSelectorSkipsElement(element, FRAGMENT_SKIP_SELECTOR, FRAGMENT_SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN);
8617}
8618function fragmentSelectorSkipsElement(element, selector, selectorWithoutAriaHidden) {
8619if (!safeElementMatches$1(element, selector)) return false;
8620if (!safeElementMatches$1(element, '[aria-hidden="true"]')) return true;
8621if (safeElementMatches$1(element, selectorWithoutAriaHidden)) return true;
8622return !ariaHiddenSubtreeHasVisibleJapanese(element);
8623}
8624function selectorWithoutAriaHiddenToken(selector) {
8625return selector.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "[aria-hidden=true]" && entry !== '[aria-hidden="true"]').join(",");
8626}
8627function isVisibleAriaHiddenJapanese(parent, blocked) {
8628if (!(blocked instanceof HTMLElement) || !safeElementMatches$1(blocked, '[aria-hidden="true"]')) return false;
8629if (safeElementMatches$1(blocked, SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN)) return false;
8630if (parent.closest(SKIP_SELECTOR_WITHOUT_ARIA_HIDDEN)) return false;
8631return elementOwnsVisibleJapanese(parent);
8632}
8633function ariaHiddenSubtreeHasVisibleJapanese(root) {
8634if (!isTargetLanguageText(root.textContent ?? "")) return false;
8635if (elementOwnsVisibleJapanese(root)) return true;
8636const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8637for (let inspected = 0, node = walker.nextNode(); node && inspected < VISIBLE_FRAGMENT_DESCENDANT_LOOKAHEAD_LIMIT; inspected += 1, node = walker.nextNode()) {
8638if (elementOwnsVisibleJapanese(node)) return true;
8639}
8640return false;
8641}
8642function elementOwnsVisibleJapanese(element) {
8643if (!elementHasOwnJapaneseText(element)) return false;
8644if (isAriaHiddenAccessibleNameDuplicate(element)) return false;
8645return isVisible(element);
8646}
8647function elementHasOwnJapaneseText(element) {
8648for (const node of Array.from(element.childNodes)) {
8649if (node.nodeType === Node.TEXT_NODE && isTargetLanguageText(node.textContent ?? "")) return true;
8650}
8651return false;
8652}
8653function isAriaHiddenAccessibleNameDuplicate(element) {
8654const hiddenRoot = element.closest('[aria-hidden="true"]');
8655if (!hiddenRoot) return false;
8656const labelled = element.closest("[aria-label]");
8657const accessibleName = normalizedControlText(labelled?.getAttribute("aria-label") ?? "");
8658const paintedLabel = normalizedControlText(hiddenRoot.textContent ?? "");
8659if (accessibleName && accessibleName !== paintedLabel) return true;
8660const control = hiddenRoot.closest(
8661`${PASSIVE_INTERACTION_SELECTOR},${COMPACT_PASSIVE_INTERACTION_SELECTOR}`
8662);
8663return Boolean(control && controlHasVisibleJapaneseOutside(control, hiddenRoot));
8664}
8665function controlHasVisibleJapaneseOutside(control, hiddenRoot) {
8666const walker = control.ownerDocument.createTreeWalker(control, NodeFilter.SHOW_TEXT);
8667for (let node = walker.nextNode(); node; node = walker.nextNode()) {
8668if (hiddenRoot.contains(node) || !isTargetLanguageText(node.textContent ?? "")) continue;
8669const parent = node.parentElement;
8670if (!parent || parent.closest("rt,rp,.jpdb-reader-detached-furi,[hidden],script,style,noscript,template")) continue;
8671if (isVisible(parent)) return true;
8672}
8673return false;
8674}
8675function isFragmentParagraphBoundary(element, options) {
8676return isPassiveInteractionBoundaryElement(element, options) || options.includeFormChrome && FORM_CHROME_BOUNDARY_TAGS.includes(`,${element.tagName},`) || isCustomElementTextBoundary(element) || isParagraphBoundary(element);
8677}
8678function isCustomElementTextBoundary(element) {
8679if (!element.localName.includes("-") || !isTargetLanguageText(element.textContent ?? "")) return false;
8680const parent = element.parentElement;
8681return !parent || !isLikelyProseElement(parent);
8682}
8683function isPassiveInteractionBoundaryElement(element, options) {
8684if (!options.includePassiveInteractions) return false;
8685const explicitInteraction = safeElementMatches$1(element, PASSIVE_INTERACTION_SELECTOR);
8686const compactInteraction = safeElementMatches$1(element, COMPACT_PASSIVE_INTERACTION_SELECTOR);
8687const compactChrome = safeElementMatches$1(element, COMPACT_PASSIVE_CHROME_SELECTOR);
8688if (!explicitInteraction && compactInteraction && !isCompactPassiveInteractionElement(element)) {
8689return false;
8690}
8691if (!explicitInteraction && !compactInteraction && compactChrome && !isCompactPassiveChromeElement(element)) {
8692return false;
8693}
8694if (!safeElementMatches$1(element, PASSIVE_INTERACTION_BOUNDARY_SELECTOR)) return false;
8695return !isInlineProsePassiveLink(element);
8696}
8697function isInlineProsePassiveLink(element) {
8698if (!element.matches('a[href],[role="link"]')) return false;
8699const parent = element.parentElement;
8700return Boolean(parent && isLikelyProseElement(parent) && element.closest('article, main, [role="main"]'));
8701}
8702function isInlineSentenceListItem(element) {
8703return element.tagName === "LI" && Boolean(element.closest(".japanese_sentence"));
8704}
8705function isReaderRenderedTextBlock(element) {
8706return READER_RENDERED_TEXT_BLOCK_TAGS.includes(`,${element.tagName},`) && Boolean(element.querySelector(".jpdb-reader-word")) && !hasRawJapaneseOutsideReaderWords(element);
8707}
8708function hasRawJapaneseOutsideReaderWords(element) {
8709const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
8710acceptNode(node) {
8711const parent = node.parentElement;
8712if (!parent || parent.closest(".jpdb-reader-word,.jpdb-reader-text-mirror,.jpdb-reader-control-text-mirror,[data-jpdb-reader-root],script,style,noscript,rt,rp")) {
8713return NodeFilter.FILTER_REJECT;
8714}
8715return isTargetLanguageText(node.textContent ?? "") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
8716}
8717});
8718return Boolean(walker.nextNode());
8719}
8720function isFragmentPassiveInteractionElement(element, options) {
8721if (isPassiveInteractionElement(element)) return true;
8722return Boolean(options.readerRootPassiveInteractions && element.closest(READER_ROOT_SELECTOR$3) && element.closest(PASSIVE_INTERACTION_SELECTOR));
8723}
8724function isLayoutSensitiveScanElement(element) {
8725if (element && isInsideOwnedReaderRoot(element)) return false;
8726let current = element;
8727while (current && current !== document.body && current !== document.documentElement) {
8728if (isLayoutSensitiveTextBox(current)) return true;
8729current = current.parentElement;
8730}
8731return false;
8732}
8733const LAYOUT_SENSITIVE_MAX_BOX_HEIGHT = 96;
8734function isLayoutSensitiveTextBox(element) {
8735const style = safeComputedStyle(element);
8736if (isReadablePrimaryDisplayHeadingElement(element)) return false;
8737if (hasLineClamp(style)) return true;
8738if (isEllipsisTextRow(style)) return true;
8739if (!hasClippedTextConstraint(style) && !isPositionedTextOverlay(style)) return false;
8740return element.getBoundingClientRect().height <= LAYOUT_SENSITIVE_MAX_BOX_HEIGHT;
8741}
8742function trimTextFragments(fragments) {
8743const trimmed = fragments.map((fragment) => ({ ...fragment }));
8744trimFragmentStart(trimmed);
8745trimFragmentEnd(trimmed);
8746return trimmed;
8747}
8748function trimFragmentStart(fragments) {
8749while (fragments.length) {
8750const first = fragments[0];
8751trimFragmentLeadingWhitespace(first);
8752if (hasFragmentText(first)) break;
8753fragments.shift();
8754}
8755}
8756function trimFragmentEnd(fragments) {
8757while (fragments.length) {
8758const last = fragments[fragments.length - 1];
8759trimFragmentTrailingWhitespace(last);
8760if (hasFragmentText(last)) break;
8761fragments.pop();
8762}
8763}
8764function trimFragmentLeadingWhitespace(fragment) {
8765while (fragmentHasLeadingWhitespace(fragment)) fragment.start += 1;
8766}
8767function trimFragmentTrailingWhitespace(fragment) {
8768while (fragmentHasTrailingWhitespace(fragment)) fragment.end -= 1;
8769}
8770function fragmentHasLeadingWhitespace(fragment) {
8771return hasFragmentText(fragment) && isWhitespaceAt(fragment.node.data, fragment.start);
8772}
8773function fragmentHasTrailingWhitespace(fragment) {
8774return hasFragmentText(fragment) && isWhitespaceAt(fragment.node.data, fragment.end - 1);
8775}
8776function hasFragmentText(fragment) {
8777return fragment.start < fragment.end;
8778}
8779function isWhitespaceAt(value, index) {
8780return /\s/u.test(value[index] ?? "");
8781}
8782function isFragmentTextTarget$1(target) {
8783return "fragments" in target;
8784}
8785function isCurrentScanTarget(target) {
8786if (scanTargetDecorationIsStale(target)) return false;
8787if (isFragmentTextTarget$1(target)) return isCurrentFragmentScanTarget(target);
8788return target.parent.isConnected && target.node.isConnected && target.node.parentElement === target.parent && (target.node.textContent ?? "").trim() === target.text;
8789}
8790function scanTargetDecorationIsStale(target) {
8791if (!target.decoration || !target.parent.isConnected) return false;
8792const current = isFragmentTextTarget$1(target) ? fragmentTargetDecoration(target.parent, target.fragments) : classifyDecoration(target.parent);
8793if (isFragmentTextTarget$1(target) && target.decorationProfileOverride) {
8794return current === "skip" && target.decoration !== "skip";
8795}
8796return current !== target.decoration;
8797}
8798function isCurrentFragmentScanTarget(target) {
8799if (!target.parent.isConnected) return false;
8800if (target.controlTextMirror) return formControlLookupText(target.parent, { selectTextMode: target.controlSelectTextMode }) === target.text;
8801if (!target.fragments.length) return Boolean(target.nonDestructive && isTargetLanguageText(target.text));
8802const text2 = target.fragments.map((fragment) => {
8803if (!fragment.node.isConnected || !fragment.node.parentElement) return null;
8804return fragment.node.data.slice(fragment.start, fragment.end);
8805});
8806return text2.every((value) => value !== null) && text2.join("") === target.text;
8807}
8808const REPAINT_LOOP_THRESHOLD = 4;
8809const REPAINT_LOOP_WINDOW_MS = 3e3;
8810const loopingScanHosts = new WeakSet();
8811const scanHostRepaintLog = new WeakMap();
8812function scanHostIsRepaintLooping(host, text2) {
8813if (loopingScanHosts.has(host)) return true;
8814const now = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
8815const previous = scanHostRepaintLog.get(host);
8816const times = (previous && previous.text === text2 ? previous.times : []).filter((time) => now - time < REPAINT_LOOP_WINDOW_MS);
8817times.push(now);
8818scanHostRepaintLog.set(host, { text: text2, times });
8819if (times.length < REPAINT_LOOP_THRESHOLD) return false;
8820loopingScanHosts.add(host);
8821return true;
8822}
8823function scanTargetRequiresWholeSourceMirror(target) {
8824if (target.nonDestructive || target.insideShadowDOM) return true;
8825const host = nonDestructiveScanHost(target);
8826if (scanHostRequiresSourcePreservingMirror(host)) return true;
8827return !target.suppressRepaintLoopMirror && scanHostIsRepaintLooping(host, target.text);
8828}
8829const FRAMEWORK_OWNERSHIP_KEY_RE = /^(?:__reactFiber\$|__reactProps\$|__reactInternalInstance\$|__reactContainer\$|__vue__|__vnode|__vueParentComponent|__ngContext__|__svelte)/;
8830const FRAMEWORK_OWNERSHIP_ANCESTOR_LIMIT = 6;
8831const frameworkManagedElements = new WeakSet();
8832function elementHasFrameworkOwnershipMarker(element) {
8833for (const key of Object.getOwnPropertyNames(element)) {
8834if (FRAMEWORK_OWNERSHIP_KEY_RE.test(key)) return true;
8835}
8836return false;
8837}
8838function elementIsFrameworkManaged(element) {
8839let current = element;
8840for (let depth = 0; current && depth < FRAMEWORK_OWNERSHIP_ANCESTOR_LIMIT; depth++, current = current.parentElement) {
8841if (frameworkManagedElements.has(current)) return true;
8842if (elementHasFrameworkOwnershipMarker(current)) {
8843frameworkManagedElements.add(current);
8844return true;
8845}
8846}
8847return false;
8848}
8849function scanHostRequiresSourcePreservingMirror(host) {
8850if (host.closest('[contenteditable="true"]')) return false;
8851return elementIsFrameworkManaged(host);
8852}
8853function stampTargetDecoration(target, host) {
8854const decoration = target.decoration;
8855if (!decoration) return;
8856stampDecorationState(host, decoration);
8857if (decoration !== "interactive-passive") return;
8858const control = interactivePassiveControl(target.parent);
8859if (control) stampDecorationState(control, decoration);
8860applyPassiveChromeMarks(compactScanRubySuppression(target.parent).marks);
8861}
8862function applyTokensToScanTarget(target, tokens, settings2) {
8863if (target.controlTextMirror) {
8864applyTokensToControlTextMirrorTarget(target, tokens, settings2);
8865return;
8866}
8867if (target.parent instanceof HTMLCanvasElement) {
8868applyTokensToCanvasFallbackTarget(target, tokens, settings2);
8869return;
8870}
8871if (target.insideShadowDOM) {
8872stampTargetDecoration(target, nonDestructiveScanHost(target));
8873applyTokensToNonDestructiveScanTarget(target, tokens, settings2);
8874return;
8875}
8876if (isFragmentTextTarget$1(target) && targetRequiresReactiveLeafMirrors(target)) {
8877applyTokensToReactiveLeafMirrors(target, tokens, settings2);
8878return;
8879}
8880const nonDestructiveHost = nonDestructiveScanHost(target);
8881stampTargetDecoration(target, nonDestructiveHost);
8882const sourcePreservingFrameworkHost = !target.nonDestructive && scanHostRequiresSourcePreservingMirror(nonDestructiveHost);
8883const repaintLooping = !target.nonDestructive && !target.suppressRepaintLoopMirror && !sourcePreservingFrameworkHost ? scanHostIsRepaintLooping(nonDestructiveHost, target.text) : false;
8884const canUseRequestedNonDestructiveMirror = target.nonDestructive && !nonDestructiveTargetShouldRenderInline(target, nonDestructiveHost);
8885if ((!target.forceInlineRender || repaintLooping) && (canUseRequestedNonDestructiveMirror || sourcePreservingFrameworkHost || repaintLooping)) {
8886applyTokensToNonDestructiveScanTarget(target, tokens, settings2);
8887return;
8888}
8889if (isFragmentTextTarget$1(target)) applyTokensToFragmentTarget(target, tokens, settings2);
8890else applyTokensToTextNode(target, tokens, settings2);
8891}
8892function nonDestructiveTargetShouldRenderInline(target, host) {
8893if (!isFragmentTextTarget$1(target)) return false;
8894if (!target.fragments.length) return false;
8895if (scanHostRequiresSourcePreservingMirror(host)) return false;
8896return targetLeavesVisibleBlockDescendantTextUncovered(target, host);
8897}
8898function targetRequiresReactiveLeafMirrors(target) {
8899const parents = target.fragments.map((fragment) => fragment.node.parentElement).filter((parent) => Boolean(parent));
8900const uniqueParents = [...new Set(parents)];
8901if (uniqueParents.length < 2) return false;
8902if (uniqueParents.some((parent) => uniqueParents.every((candidate) => parent.contains(candidate)))) return false;
8903return Boolean(target.nonDestructive) || uniqueParents.some((parent) => scanHostRequiresSourcePreservingMirror(parent));
8904}
8905function applyTokensToReactiveLeafMirrors(target, tokens, settings2) {
8906const indexed = indexTextFragments(target.fragments);
8907for (const run of reactiveLeafRuns(indexed)) {
8908const text2 = target.text.slice(run.globalStart, run.globalEnd);
8909const runTokens = tokens.map((token) => tokenPieceForReactiveLeaf(token, run.globalStart, run.globalEnd)).filter((token) => token !== null);
8910if (!runTokens.length || !isTargetLanguageText(text2)) continue;
8911const crossesLeafBoundary = tokens.some((token) => token.start < run.globalStart && token.end > run.globalStart || token.start < run.globalEnd && token.end > run.globalEnd);
8912const leafTarget = {
8913...target,
8914text: text2,
8915parent: run.parent,
8916fragments: run.fragments.map((fragment) => ({
8917node: fragment.node,
8918start: fragment.start,
8919end: fragment.end,
8920hasNativeRuby: fragment.hasNativeRuby,
8921layoutSensitive: fragment.layoutSensitive,
8922passiveInteraction: fragment.passiveInteraction
8923})),
8924nonDestructive: true,
8925suppressRuby: target.suppressRuby || crossesLeafBoundary
8926};
8927const host = nonDestructiveScanHost(leafTarget);
8928stampTargetDecoration(leafTarget, host);
8929applyTokensToNonDestructiveScanTarget(leafTarget, runTokens, settings2);
8930}
8931}
8932function reactiveLeafRuns(fragments) {
8933const runs = [];
8934for (const fragment of fragments) {
8935const parent = fragment.node.parentElement;
8936if (!parent) continue;
8937const previous = runs[runs.length - 1];
8938if (previous?.parent === parent && previous.globalEnd === fragment.globalStart) {
8939previous.fragments.push(fragment);
8940previous.globalEnd = fragment.globalEnd;
8941} else {
8942runs.push({
8943parent,
8944fragments: [fragment],
8945globalStart: fragment.globalStart,
8946globalEnd: fragment.globalEnd
8947});
8948}
8949}
8950return runs;
8951}
8952function tokenPieceForReactiveLeaf(token, runStart, runEnd) {
8953const start = Math.max(token.start, runStart);
8954const end = Math.min(token.end, runEnd);
8955if (end <= start) return null;
8956const delta = -runStart;
8957return {
8958...token,
8959start: start + delta,
8960end: end + delta,
8961length: end - start,
8962rubies: token.rubies.filter((ruby) => ruby.start >= start && ruby.end <= end).map((ruby) => ({ ...ruby, start: ruby.start + delta, end: ruby.end + delta }))
8963};
8964}
8965function targetLeavesVisibleBlockDescendantTextUncovered(target, host) {
8966if (!host.querySelector(":scope p,:scope div,:scope li,:scope dl,:scope dt,:scope dd,:scope section,:scope article,:scope blockquote")) return false;
8967const covered = new Set(target.fragments.map((fragment) => fragment.node));
8968const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
8969acceptNode: (node) => {
8970const parent = node.parentElement;
8971if (!parent || covered.has(node)) return NodeFilter.FILTER_REJECT;
8972if (parent.closest(MIRROR_PLAN_TEXT_SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
8973const block = parent.closest("p,div,li,dl,dt,dd,section,article,blockquote");
8974if (!block || block === host || !host.contains(block)) return NodeFilter.FILTER_REJECT;
8975return (node.textContent ?? "").trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
8976}
8977});
8978return Boolean(walker.nextNode());
8979}
8980const destructivePaintTextNodes = new WeakSet();
8981function registerDestructivePaintTextNodes(root) {
8982if (root.nodeType === Node.TEXT_NODE) {
8983destructivePaintTextNodes.add(root);
8984return;
8985}
8986const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
8987for (let node = walker.nextNode(); node; node = walker.nextNode()) {
8988if (!(node.parentElement && node.parentElement.closest(READER_WORD_SELECTOR$1))) {
8989destructivePaintTextNodes.add(node);
8990}
8991}
8992}
8993function applyTokensToTextNode(target, tokens, settings2) {
8994if (!tokens.length || !target.node.parentElement) return;
8995if (target.insideShadowDOM || target.node.getRootNode() instanceof ShadowRoot) {
8996applyTokensToNonDestructiveScanTarget({ ...target, insideShadowDOM: true }, tokens, settings2);
8997return;
8998}
8999const text2 = target.text;
9000const safeTokens = nonOverlappingTokens(tokens, text2);
9001if (!safeTokens.length) return;
9002const fragment = renderTokenizedTextFragment(target, safeTokens, settings2);
9003registerDestructivePaintTextNodes(fragment);
9004target.node.replaceWith(fragment);
9005styleDetachedReadingElements(target.parent, target.parent);
9006stabilizeDetachedReadings(target.parent, closestRubyFragileConstrainedRow(target.parent));
9007markRenderedScanTarget(target);
9008}
9009function renderTokenizedTextFragment(target, tokens, settings2) {
9010const fragment = renderTokenizedScanText(target.text, tokens, settings2, {
9011parent: target.parent,
9012hasNativeRuby: target.hasNativeRuby,
9013decoration: target.decoration,
9014suppressRuby: target.suppressRuby,
9015proseWrap: target.proseWrap,
9016passiveInteraction: target.passiveInteraction
9017});
9018return wrapDirectFlexGridTextRun(fragment, target.parent);
9019}
9020function renderTokenizedScanText(text2, tokens, settings2, target) {
9021const fragment = document.createDocumentFragment();
9022const suppressRuby = scanTargetSuppressesRuby(target.parent, target.suppressRuby, !target.mirrorRender, target.decoration);
9023const passiveInteraction = target.passiveInteraction || suppressRuby && !target.suppressRubyDoesNotImplyPassive;
9024const renderSettings = furiganaSettingsForTarget(settings2, target.parent);
9025let offset = 0;
9026const tokenPlans = tokens.map((token) => ({
9027token,
9028tokenWithSentence: tokenWithReadableSentence(token, text2, token.sentence)
9029}));
9030const miningInsightKeys = miningInsightTokenKeys(tokenPlans.map((plan) => plan.tokenWithSentence));
9031for (const plan of tokenPlans) {
9032const { token, tokenWithSentence } = plan;
9033appendPlainTextBeforeToken(fragment, text2, offset, token.start, true);
9034fragment.append(renderToken(text2.slice(token.start, token.end), tokenWithSentence, renderSettings, {
9035allowRuby: !target.hasNativeRuby && (!suppressRuby || (target.detachedReadings ?? true)),
9036detachedReadings: target.detachedReadings ?? suppressRuby,
9037kanjiNavigation: kanjiNavigationForElement(target.parent),
9038scanWord: true,
9039proseWrap: target.proseWrap === true,
9040passiveInteraction,
9041preserveTokenRubies: true,
9042miningInsightKeys
9043}));
9044offset = token.end;
9045}
9046appendPlainTextBeforeToken(fragment, text2, offset, text2.length);
9047return fragment;
9048}
9049function nonDestructiveHostRenderPlan(host, target, tokens) {
9050const fragments = nonDestructiveTargetFragments(target);
9051const { hostText, nodeOffsets, whitespaceJoints } = hostOriginalTextWithNodeOffsets(host);
9052if (hostText && hostText === target.text) return { text: hostText, tokens, whitespaceJoints, hostText };
9053if (!fragments.length || !hostText) return { text: target.text, tokens, hostText };
9054const indexed = indexTextFragments(fragments);
9055const dropped = [];
9056const remapped = tokens.map((token) => {
9057const remappedToken = remapTokenIntoHostText(token, indexed, nodeOffsets, hostText);
9058if (!remappedToken) dropped.push(token);
9059return remappedToken;
9060}).filter((token) => token !== null);
9061const pruned = nonOverlappingTokens(remapped, hostText);
9062const prunedSet = new Set(pruned);
9063dropped.push(...remapped.filter((token) => !prunedSet.has(token)));
9064const rendered = withHostRemapGapFallbackTokens(pruned, dropped, indexed, nodeOffsets, hostText);
9065return {
9066text: hostText,
9067tokens: withRetainedNeighbourTokens(host, hostText, targetHostRanges(fragments, nodeOffsets), rendered),
9068whitespaceJoints,
9069hostText
9070};
9071}
9072function withHostRemapGapFallbackTokens(tokens, dropped, indexed, nodeOffsets, hostText) {
9073if (!dropped.length) return tokens;
9074const additions = [];
9075for (const token of dropped) {
9076for (const range of hostRangesForTargetRange(token.start, token.end, indexed, nodeOffsets, hostText)) {
9077collectHostGapFallbackTokens(tokens, hostText, range.start, range.end, additions);
9078}
9079}
9080if (!additions.length) return tokens;
9081return nonOverlappingTokens(
9082[...tokens, ...additions].sort((first, second) => first.start - second.start || second.end - second.start - (first.end - first.start)),
9083hostText
9084);
9085}
9086function hostRangesForTargetRange(start, end, indexed, nodeOffsets, hostText) {
9087const ranges = [];
9088let runStart = -1;
9089let expectedNext = -1;
9090for (let offset = Math.max(0, start); offset < end; offset += 1) {
9091const hostIndex = hostIndexForTargetOffset(offset, indexed, nodeOffsets);
9092const sourceChar = sourceCharForTargetOffset(offset, indexed);
9093const aligned = hostIndex !== null && hostIndex >= 0 && hostIndex < hostText.length && sourceChar !== null && hostText[hostIndex] === sourceChar;
9094if (aligned && hostIndex === expectedNext && runStart >= 0) {
9095expectedNext = hostIndex + 1;
9096continue;
9097}
9098if (runStart >= 0) ranges.push({ start: runStart, end: expectedNext });
9099runStart = aligned ? hostIndex : -1;
9100expectedNext = aligned ? hostIndex + 1 : -1;
9101}
9102if (runStart >= 0) ranges.push({ start: runStart, end: expectedNext });
9103return ranges;
9104}
9105function hostIndexForTargetOffset(offset, indexed, nodeOffsets) {
9106for (const fragment of indexed) {
9107if (offset < fragment.globalStart || offset >= fragment.globalEnd) continue;
9108const base = nodeOffsets.get(fragment.node);
9109if (base === void 0) return null;
9110return base + fragment.start + (offset - fragment.globalStart);
9111}
9112return null;
9113}
9114function sourceCharForTargetOffset(offset, indexed) {
9115for (const fragment of indexed) {
9116if (offset < fragment.globalStart || offset >= fragment.globalEnd) continue;
9117return fragment.node.data[fragment.start + (offset - fragment.globalStart)] ?? null;
9118}
9119return null;
9120}
9121function collectHostGapFallbackTokens(tokens, hostText, rangeStart, rangeEnd, additions) {
9122const isCovered = (start, end) => tokens.some((token) => token.start < end && start < token.end) || additions.some((token) => token.start < end && start < token.end);
9123for (const gap of uncoveredJapaneseRanges(hostText, rangeStart, rangeEnd, isCovered)) {
9124appendSegmentedHostFallbackTokens(hostText, gap.start, gap.end, additions);
9125}
9126}
9127function appendSegmentedHostFallbackTokens(hostText, gapStart, gapEnd, additions) {
9128for (const segment of segmentTargetLanguageText(hostText.slice(gapStart, gapEnd))) {
9129additions.push({
9130card: bareFallbackCardFromText(segment.text, activeLearningTargetLanguage()),
9131start: gapStart + segment.start,
9132end: gapStart + segment.end,
9133length: segment.end - segment.start,
9134rubies: [],
9135pitchClass: "",
9136sentence: hostText
9137});
9138}
9139}
9140function targetHostRanges(fragments, nodeOffsets) {
9141const ranges = [];
9142for (const fragment of fragments) {
9143const base = nodeOffsets.get(fragment.node);
9144if (base === void 0) return null;
9145const start = base + Math.max(0, fragment.start);
9146const end = base + Math.min(fragment.node.data.length, fragment.end);
9147if (end > start) ranges.push({ start, end });
9148}
9149return ranges;
9150}
9151function hostRangesOverlap(first, second) {
9152return first.start < second.end && second.start < first.end;
9153}
9154function withRetainedNeighbourTokens(host, hostText, ownRanges, tokens) {
9155if (!ownRanges?.length) return tokens;
9156const mirror = currentTextMirror(host);
9157if (!mirror || !textMirrorRenderIsIntact(mirror) || mirror.dataset.sourceText !== hostText) return tokens;
9158const entry = nonDestructiveRenderCache.get(host);
9159if (!entry || entry.epoch !== nonDestructiveRenderCacheEpoch || entry.planText !== hostText) return tokens;
9160const retained = entry.tokens.filter((token) => token.start >= 0 && token.end > token.start && token.end <= hostText.length && !ownRanges.some((range) => hostRangesOverlap(token, range)));
9161if (!retained.length) return tokens;
9162return nonOverlappingTokens(
9163[...tokens, ...retained].sort((first, second) => first.start - second.start || second.end - second.start - (first.end - first.start)),
9164hostText
9165);
9166}
9167const MIRROR_PLAN_TEXT_SKIP_SELECTOR = `${READER_OWNED_TEXT_SELECTOR},script,style,noscript,template,[hidden],rt,rp`;
9168function hostOriginalTextWithNodeOffsets(host) {
9169const nodeOffsets = new Map();
9170const whitespaceJoints = [];
9171const styles = new MirrorPlanStyleProbe(host);
9172let hostText = "";
9173let previousNode = null;
9174const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
9175acceptNode: (node) => {
9176const parent = node.parentElement;
9177if (!parent || parent.closest(MIRROR_PLAN_TEXT_SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
9178if (styles.isComputedHidden(parent)) return NodeFilter.FILTER_REJECT;
9179if (!node.data.trim() && styles.dropsWhitespaceOnlyChild(parent)) return NodeFilter.FILTER_REJECT;
9180return NodeFilter.FILTER_ACCEPT;
9181}
9182});
9183for (let node = walker.nextNode(); node; node = walker.nextNode()) {
9184if (previousNode && !styles.rendersInterNodeWhitespace(previousNode, node)) {
9185whitespaceJoints.push(hostText.length);
9186}
9187previousNode = node;
9188nodeOffsets.set(node, hostText.length);
9189hostText += node.data;
9190}
9191return { hostText, nodeOffsets, whitespaceJoints };
9192}
9193function isItemizedContainerDisplay(display) {
9194return display === "flex" || display === "grid" || display === "inline-flex" || display === "inline-grid" || display === "table" || display === "inline-table" || display === "table-row" || display === "table-row-group" || display === "table-header-group" || display === "table-footer-group";
9195}
9196function preservesWhitespace(whiteSpace) {
9197return whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "pre-line" || whiteSpace === "break-spaces";
9198}
9199class MirrorPlanStyleProbe {
9200constructor(host) {
9201this.host = host;
9202}
9203styleCache = new Map();
9204hiddenCache = new Map();
9205flattenedCache = new Map();
9206styleOf(element) {
9207let style = this.styleCache.get(element);
9208if (!style) {
9209style = safeComputedStyle(element);
9210this.styleCache.set(element, style);
9211}
9212return style;
9213}
9214displayOf(element) {
9215const display = this.styleOf(element).display;
9216if (display) return display;
9217return BLOCK_TAGS.has(element.tagName) ? "block" : "inline";
9218}
9219isComputedHidden(element) {
9220if (element === this.host) return false;
9221if (this.isDisplayNoneHidden(element)) return true;
9222if (this.hostVisibilityInjected) return false;
9223const visibility = this.styleOf(element).visibility;
9224return visibility === "hidden" || visibility === "collapse";
9225}
9226get hostVisibilityInjected() {
9227return this.host.style.getPropertyValue("visibility") === "hidden";
9228}
9229isDisplayNoneHidden(element) {
9230if (element === this.host) return false;
9231const cached = this.hiddenCache.get(element);
9232if (cached !== void 0) return cached;
9233const hidden = this.styleOf(element).display === "none" || (element.parentElement ? this.isDisplayNoneHidden(element.parentElement) : false);
9234this.hiddenCache.set(element, hidden);
9235return hidden;
9236}
9237dropsWhitespaceOnlyChild(parent) {
9238const container = this.throughContents(parent);
9239return container !== null && isItemizedContainerDisplay(this.displayOf(container));
9240}
9241throughContents(element) {
9242let current = element;
9243while (current && this.displayOf(current) === "contents") current = current.parentElement;
9244return current;
9245}
9246rendersInterNodeWhitespace(previous, current) {
9247const lca = this.commonAncestorElement(previous, current);
9248if (!lca) return true;
9249const container = this.throughContents(lca);
9250if (!container) return true;
9251const previousItem = this.flattenedItemChild(container, previous);
9252const currentItem = this.flattenedItemChild(container, current);
9253if (isItemizedContainerDisplay(this.displayOf(container))) {
9254if (!(previousItem instanceof Text && currentItem instanceof Text)) return false;
9255return this.flattenedContiguousText(container, previousItem, currentItem);
9256}
9257if (preservesWhitespace(this.styleOf(container).whiteSpace)) return true;
9258return this.participatesInline(previousItem) && this.participatesInline(currentItem);
9259}
9260participatesInline(node) {
9261if (!(node instanceof HTMLElement)) return true;
9262const display = this.displayOf(node);
9263return display === "inline" || display.startsWith("inline-") || display.startsWith("ruby");
9264}
9265flattenedItemChild(container, node) {
9266let item = node;
9267for (let element = node.parentElement; element && element !== container; element = element.parentElement) {
9268if (this.displayOf(element) !== "contents") item = element;
9269}
9270return item;
9271}
9272flattenedContiguousText(container, first, second) {
9273const flattened = this.flattenedChildren(container);
9274const start = flattened.indexOf(first);
9275const end = flattened.indexOf(second);
9276if (start < 0 || end <= start) return false;
9277return flattened.slice(start + 1, end).every((node) => node.nodeType === Node.TEXT_NODE);
9278}
9279flattenedChildren(container) {
9280const cached = this.flattenedCache.get(container);
9281if (cached) return cached;
9282const flattened = [];
9283const visit = (element) => {
9284for (const child of Array.from(element.childNodes)) {
9285if (child instanceof HTMLElement && this.displayOf(child) === "contents") visit(child);
9286else flattened.push(child);
9287}
9288};
9289visit(container);
9290this.flattenedCache.set(container, flattened);
9291return flattened;
9292}
9293commonAncestorElement(a, b) {
9294const ancestors = new Set();
9295for (let element = a.parentElement; element; element = element.parentElement) ancestors.add(element);
9296for (let element = b.parentElement; element; element = element.parentElement) {
9297if (ancestors.has(element)) return element;
9298}
9299return null;
9300}
9301}
9302function nonDestructiveTargetFragments(target) {
9303if (isFragmentTextTarget$1(target)) return target.fragments;
9304const data = target.node.data;
9305const lead = data.length - data.trimStart().length;
9306return [{
9307node: target.node,
9308start: lead,
9309end: lead + target.text.length,
9310hasNativeRuby: Boolean(target.hasNativeRuby),
9311layoutSensitive: target.layoutSensitive,
9312passiveInteraction: target.passiveInteraction
9313}];
9314}
9315function remapTokenIntoHostText(token, indexed, nodeOffsets, hostText) {
9316const start = findFragmentBoundary(indexed, token.start, "start");
9317const end = findFragmentBoundary(indexed, token.end, "end");
9318if (!start || !end) return null;
9319const hostStart = hostTextOffsetForBoundary(start, nodeOffsets);
9320const hostEnd = hostTextOffsetForBoundary(end, nodeOffsets);
9321if (hostStart === null || hostEnd === null) return null;
9322if (hostStart < 0 || hostEnd <= hostStart || hostEnd > hostText.length) return null;
9323const surface = textFragmentSurface(indexed, token.start, token.end);
9324if (!surface || hostText.slice(hostStart, hostEnd) !== surface) return null;
9325return shiftTokenOffsets(token, hostStart - token.start);
9326}
9327function hostTextOffsetForBoundary(boundary, nodeOffsets) {
9328const base = nodeOffsets.get(boundary.fragment.node);
9329return base === void 0 ? null : base + boundary.localOffset;
9330}
9331function textFragmentSurface(indexed, start, end) {
9332if (end <= start) return "";
9333return indexed.filter((fragment) => fragment.globalEnd > start && fragment.globalStart < end).map((fragment) => {
9334const localStart = fragment.start + Math.max(start, fragment.globalStart) - fragment.globalStart;
9335const localEnd = fragment.start + Math.min(end, fragment.globalEnd) - fragment.globalStart;
9336return fragment.node.data.slice(localStart, localEnd);
9337}).join("");
9338}
9339function shiftTokenOffsets(token, delta) {
9340if (delta === 0) return token;
9341return {
9342...token,
9343start: token.start + delta,
9344end: token.end + delta,
9345rubies: token.rubies.map((ruby) => ({ ...ruby, start: ruby.start + delta, end: ruby.end + delta }))
9346};
9347}
9348function nonDestructiveMirrorRenderContext(host, target, tokens, settings2) {
9349const plan = nonDestructiveHostRenderPlan(host, target, nonOverlappingTokens(tokens, target.text));
9350const text2 = plan.text;
9351const safeTokens = plan.tokens;
9352const renderPlan = preservesWhitespace(safeComputedStyle(host).whiteSpace) ? { text: text2, tokens: safeTokens } : whitespaceCollapsedNonDestructiveRender(text2, safeTokens, plan.whitespaceJoints);
9353const suppressRuby = scanTargetSuppressesRuby(host, target.suppressRuby, false, target.decoration);
9354const renderSettings = furiganaSettingsForTarget(settings2, host);
9355const { clipRow, detachedReadings } = textMirrorClipMode(
9356host,
9357renderPlan,
9358renderSettings,
9359targetHasNativeRuby(target)
9360);
9361const signature = nonDestructiveScanSignature(target, safeTokens, renderSettings, suppressRuby, detachedReadings);
9362const whitespaceJointsKey = (plan.whitespaceJoints ?? []).join(",");
9363return {
9364text: text2,
9365safeTokens,
9366renderPlan,
9367renderSettings,
9368signature,
9369whitespaceJointsKey,
9370clipRow,
9371detachedReadings,
9372suppressRuby,
9373hostText: plan.hostText
9374};
9375}
9376function applyTokensToNonDestructiveScanTarget(target, tokens, settings2) {
9377const host = nonDestructiveScanHost(target);
9378if (!host.isConnected) return;
9379const context = nonDestructiveMirrorRenderContext(host, target, tokens, settings2);
9380if (reuseCurrentTextMirror(host, context)) return;
9381removeTextMirror(host);
9382if (!context.safeTokens.length) return;
9383mountNonDestructiveTextMirror(host, target, settings2, context);
9384}
9385function reuseCurrentTextMirror(host, context) {
9386const existing = currentTextMirror(host);
9387const structureMatches = [
9388existing && textMirrorRenderIsIntact(existing),
9389existing?.dataset.sourceText === context.text,
9390(existing?.dataset.whitespaceJoints ?? "") === context.whitespaceJointsKey,
9391existing?.classList.contains("jpdb-reader-additive-text-mirror")
9392].every(Boolean);
9393if (!structureMatches || !existing) return false;
9394const exactMatch = existing.dataset.renderSignature === context.signature;
9395if (!exactMatch && !existingMirrorHasMoreCompleteAnnotations(existing, context)) return false;
9396if (!exactMatch) refreshRetainedMirrorCardStatus(existing, context);
9397const state = textMirrorHosts.get(host);
9398if (state) reassertTextMirrorHostStyles(host, state);
9399if (context.detachedReadings || existing.dataset.yomuDetachedReadings === "true") {
9400stabilizeDetachedReadings(existing, context.clipRow, true);
9401}
9402return true;
9403}
9404function existingMirrorHasMoreCompleteAnnotations(existing, context) {
9405const existingSettings = tokenIndependentMirrorSignature(existing.dataset.renderSignature ?? "");
9406const incomingSettings = tokenIndependentMirrorSignature(context.signature);
9407if (!existingSettings || existingSettings !== incomingSettings) return false;
9408const wordsByRange = indexMirrorWordsByRange(existing);
9409if (!wordsByRange) return false;
9410const matches = context.renderPlan.tokens.map((token) => matchMirrorWordRange(wordsByRange, token, context.renderPlan.text));
9411if (matches.some((match) => match === null)) return false;
9412const rangeMatches = matches.filter(isMirrorWordRangeMatch);
9413const facts = rangeMatches.map(({ word, token }) => compareMirrorAnnotationFacts(word, token));
9414if (facts.some((comparison) => !comparison.compatible)) return false;
9415return facts.some((comparison) => comparison.existingIsRicher) || mirrorHasAnnotatedOmittedRange(wordsByRange, rangeMatches);
9416}
9417function indexMirrorWordsByRange(existing) {
9418const words = Array.from(existing.querySelectorAll(".jpdb-reader-word"));
9419if (!words.length) return null;
9420const indexed = new Map(words.map((word) => [
9421mirrorRangeKey(word.dataset.tokenStart, word.dataset.tokenEnd),
9422word
9423]));
9424return indexed.size === words.length ? indexed : null;
9425}
9426function matchMirrorWordRange(wordsByRange, token, text2) {
9427const key = mirrorRangeKey(token.start, token.end);
9428const word = wordsByRange.get(key);
9429const sameRange = word && Number(word.dataset.tokenStart) === token.start && Number(word.dataset.tokenEnd) === token.end;
9430const sameSurface = word?.dataset.surface === text2.slice(token.start, token.end);
9431return word && sameRange && sameSurface ? { key, word, token } : null;
9432}
9433function mirrorRangeKey(start, end) {
9434return `${start}:${end}`;
9435}
9436function isMirrorWordRangeMatch(match) {
9437return match !== null;
9438}
9439function compareMirrorAnnotationFacts(word, token) {
9440const existingPitch = PITCH_CLASSES.has(word.dataset.pitchClass ?? "") ? word.dataset.pitchClass ?? "" : "";
9441const incomingPitch = PITCH_CLASSES.has(tokenPitchClass(token)) ? tokenPitchClass(token) : "";
9442const comparisons = [
9443compareAnnotationFact(renderedWordReading(word), renderedTokenReading(token)),
9444compareAnnotationFact(existingPitch, incomingPitch),
9445compareAnnotationFact(word.dataset.pitchAccent ?? "", token.card.pitchAccent.join("|"))
9446];
9447return {
9448compatible: comparisons.every(annotationFactCanRetainExisting),
9449existingIsRicher: comparisons.includes("existing-richer")
9450};
9451}
9452function compareAnnotationFact(existing, incoming) {
9453if (existing && incoming) return existing === incoming ? "same" : "conflict";
9454if (existing) return "existing-richer";
9455if (incoming) return "incoming-richer";
9456return "same";
9457}
9458function annotationFactCanRetainExisting(comparison) {
9459return comparison === "same" || comparison === "existing-richer";
9460}
9461function mirrorHasAnnotatedOmittedRange(wordsByRange, matches) {
9462const incomingRanges = new Set(matches.map((match) => match.key));
9463return Array.from(wordsByRange).some(([key, word]) => !incomingRanges.has(key) && mirrorWordHasAnnotationFacts(word));
9464}
9465function mirrorWordHasAnnotationFacts(word) {
9466return Boolean(renderedWordReading(word) || PITCH_CLASSES.has(word.dataset.pitchClass ?? "") || word.dataset.pitchAccent);
9467}
9468function refreshRetainedMirrorCardStatus(existing, context) {
9469const wordsByRange = new Map(
9470Array.from(existing.querySelectorAll(".jpdb-reader-word")).map((word) => [`${word.dataset.tokenStart}:${word.dataset.tokenEnd}`, word])
9471);
9472for (const token of context.renderPlan.tokens) {
9473const word = wordsByRange.get(`${token.start}:${token.end}`);
9474if (word) setRenderedWordCardStatus(word, token.card);
9475}
9476}
9477function tokenIndependentMirrorSignature(signature) {
9478try {
9479const parsed = JSON.parse(signature);
9480if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
9481const settings2 = { ...parsed };
9482delete settings2.tokens;
9483delete settings2.readings;
9484return JSON.stringify(settings2);
9485} catch {
9486return null;
9487}
9488}
9489function renderedWordReading(word) {
9490return (word.dataset.reading || Array.from(word.querySelectorAll("rt,.jpdb-reader-detached-furi")).map((reading) => reading.textContent?.trim() ?? "").join("")).trim();
9491}
9492function renderedTokenReading(token) {
9493return (token.card.reading || token.rubies.map((ruby) => ruby.text).join("")).trim();
9494}
9495function createNonDestructiveTextMirror(context) {
9496const mirror = document.createElement("span");
9497mirror.className = "jpdb-reader-text-mirror";
9498mirror.dataset.jpdbReaderTextMirror = "true";
9499mirror.dataset.sourceText = context.text;
9500mirror.dataset.renderSignature = context.signature;
9501mirror.dataset.whitespaceJoints = context.whitespaceJointsKey;
9502mirror.setAttribute("aria-hidden", "true");
9503mirror.classList.add("jpdb-reader-additive-text-mirror");
9504if (context.detachedReadings) mirror.dataset.yomuDetachedReadings = "true";
9505return mirror;
9506}
9507function textMirrorMount(host, clipRow, target) {
9508if (sourcePreservingProseNeedsDocumentPortal(host, {
9509decoration: target.decoration,
9510insideShadowDOM: Boolean(target.insideShadowDOM),
9511interactivePassive: Boolean(interactivePassiveControl(host)),
9512preservesSource: Boolean(target.nonDestructive || scanHostRequiresSourcePreservingMirror(host))
9513})) {
9514return {
9515configure(mirror) {
9516mirror.classList.add(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS);
9517mirror.dataset.yomuDocumentPortal = "volatile-prose";
9518delete mirror.dataset.yomuReadingLaneCandidate;
9519const state = styleDocumentPortalTextMirrorHost(host);
9520styleDocumentAnnotationPortalMirror(mirror, host);
9521return state;
9522},
9523append(mirror) {
9524(host.ownerDocument.body ?? host.ownerDocument.documentElement).append(mirror);
9525},
9526projectionRoot: host.ownerDocument
9527};
9528}
9529return {
9530configure(mirror) {
9531const state = styleTextMirrorHost(host);
9532styleTextMirror(mirror, host, false);
9533styleConstrainedTextMirror(mirror, clipRow);
9534return state;
9535},
9536append: (mirror) => host.append(mirror),
9537projectionRoot: host.getRootNode()
9538};
9539}
9540function mountNonDestructiveTextMirror(host, target, settings2, context) {
9541const mirror = createNonDestructiveTextMirror(context);
9542const mount = textMirrorMount(host, context.clipRow, target);
9543const controlMirror = target.decoration === "interactive-passive";
9544if (controlMirror) mirror.dataset.yomuControlMirror = "true";
9545if (context.detachedReadings && !controlMirror) {
9546mirror.dataset.yomuReadingLaneCandidate = "true";
9547}
9548const state = mount.configure(mirror);
9549try {
9550if (controlMirror && !context.detachedReadings) stabilizeReadingFreeControlMirror(mirror, host);
9551const paintRoot = state.documentPortal ? documentAnnotationPortalPaint(mirror) : mirror;
9552paintRoot.append(renderTokenizedScanText(context.renderPlan.text, context.renderPlan.tokens, context.renderSettings, {
9553parent: host,
9554hasNativeRuby: targetHasNativeRuby(target),
9555mirrorRender: true,
9556decoration: target.decoration,
9557suppressRuby: context.suppressRuby,
9558detachedReadings: context.detachedReadings,
9559passiveInteraction: target.passiveInteraction || context.suppressRuby
9560}));
9561if (!mirror.textContent?.trim()) {
9562removeTextMirror(host);
9563return;
9564}
9565stampMirrorWordSourceRanges(mirror, context.safeTokens, context.hostText);
9566ensureReaderStylesForHost(host);
9567mount.append(mirror);
9568registerTextMirrorOwner(mirror, host);
9569state.mirror = new WeakRef(mirror);
9570if (state.documentPortal) {
9571registerDocumentAnnotationPortalMirror(
9572host,
9573mirror,
9574() => scheduleDocumentPortalMirrorProjection(mirror, host),
9575() => projectOneDocumentPortalTextMirror(mirror, host),
9576() => removeTextMirror(host)
9577);
9578}
9579styleAdditiveMirrorPaint(mirror);
9580if (context.detachedReadings) {
9581styleDetachedReadingElements(mirror, host);
9582stabilizeDetachedReadings(mirror, context.clipRow, true);
9583}
9584scheduleAdditiveMirrorProjection(mount.projectionRoot);
9585syncTextMirrorVisibilityToPage(host, mirror);
9586observeTextMirrorHost(host);
9587rememberNonDestructiveRenderForReplay(host, target, context.text, context.safeTokens, context.hostText, settings2);
9588} catch (error) {
9589removeTextMirror(host);
9590throw error;
9591}
9592}
9593function stabilizeReadingFreeControlMirror(mirror, host) {
9594const height = host.getBoundingClientRect().height;
9595if (height > 0) mirror.style.setProperty("line-height", `${height}px`, "important");
9596}
9597function stampMirrorWordSourceRanges(mirror, tokens, hostText = "") {
9598const words = Array.from(mirror.querySelectorAll(".jpdb-reader-word.jpdb-reader-scan-word"));
9599const renderText = mirror.dataset.sourceText ?? "";
9600const hostStarts = mirrorHostSourceStarts(renderText, tokens, hostText);
9601if (hostStarts) mirror.dataset.yomuHostSourceText = hostText;
9602else delete mirror.dataset.yomuHostSourceText;
9603for (const [index, word] of words.entries()) {
9604const token = tokens[index];
9605if (!token) continue;
9606if (hostStarts) clearMirrorWordSourceRange(word);
9607const start = hostStarts ? hostStarts.get(token) : token.start;
9608if (start === void 0) continue;
9609const surface = renderText.slice(token.start, token.end);
9610word.dataset.yomuSourceStart = String(start);
9611word.dataset.yomuSourceEnd = String(start + surface.length);
9612stampProjectedRubySourceRanges(word, surface, token, start);
9613}
9614}
9615function clearMirrorWordSourceRange(word) {
9616delete word.dataset.yomuSourceStart;
9617delete word.dataset.yomuSourceEnd;
9618for (const wrapper of word.querySelectorAll(".jpdb-reader-detached-ruby")) {
9619delete wrapper.dataset.yomuSourceStart;
9620delete wrapper.dataset.yomuSourceEnd;
9621}
9622}
9623function mirrorHostSourceStarts(renderText, tokens, hostText) {
9624if (!hostText || !renderText || hostText === renderText) return null;
9625const starts = new Map();
9626let cursor = 0;
9627for (const token of tokens) {
9628const surface = renderText.slice(token.start, token.end);
9629if (!surface) continue;
9630const start = hostText.indexOf(surface, cursor);
9631if (start < 0) continue;
9632starts.set(token, start);
9633cursor = start + surface.length;
9634}
9635return starts.size ? starts : null;
9636}
9637function mirrorSourceHostText(mirror) {
9638return mirror.dataset.yomuHostSourceText ?? mirror.dataset.sourceText ?? "";
9639}
9640function stampProjectedRubySourceRanges(word, surface, token, sourceStart) {
9641const rubies = effectiveTokenRubies(surface, token, true);
9642word.querySelectorAll(".jpdb-reader-detached-ruby").forEach((wrapper, rubyIndex) => {
9643const ruby = rubies[rubyIndex];
9644const local = ruby ? localRubyRange(surface, token, ruby) : null;
9645if (!local) return;
9646wrapper.dataset.yomuSourceStart = String(sourceStart + local.start);
9647wrapper.dataset.yomuSourceEnd = String(sourceStart + local.end);
9648});
9649}
9650const SOURCE_FRAGMENT_CLASS = "jpdb-reader-source-fragment";
9651function projectAdditiveTextMirror(mirror, host) {
9652const documentPortal = mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS);
9653if (documentPortal) {
9654prepareDocumentAnnotationPortalMirrors([mirror]);
9655}
9656projectPreparedAdditiveTextMirror(mirror, host, documentPortal ? documentTopLayerConcealsPortal(host.ownerDocument) : false);
9657if (documentPortal) settleDocumentAnnotationPortalMirrors([mirror]);
9658}
9659function projectPreparedAdditiveTextMirror(mirror, host, topLayerConcealed = false) {
9660const documentPortal = mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS);
9661const context = additiveMirrorProjectionContext(mirror, host);
9662if (typeof context === "string") {
9663clearProjectedReadings(mirror);
9664if (context === "source-changed") clearAdditiveMirrorSourceProjection(mirror);
9665return;
9666}
9667if (documentPortal) context.topLayerConcealed = topLayerConcealed;
9668const words = mirror.querySelectorAll(
9669".jpdb-reader-word[data-yomu-source-start][data-yomu-source-end]"
9670);
9671const sourceRects = cachedMirrorSourceRectReader(context);
9672const hasReadings = mirror.querySelector(
9673".jpdb-reader-detached-ruby[data-yomu-source-start][data-yomu-source-end] .jpdb-reader-detached-furi"
9674) !== null;
9675const readingsConcealed = !hasReadings || !context.host.isConnected || pageConcealsTextMirrorHost(context.host) || Boolean(context.documentPortal && context.topLayerConcealed);
9676const projections = Array.from(words).map((word) => readAdditiveMirrorWordProjection(
9677word,
9678context,
9679sourceRects,
9680readingsConcealed
9681));
9682const readingProjections = [];
9683const projected = projections.map((projection) => writeAdditiveMirrorWordProjection(projection, context, readingProjections)).some(Boolean);
9684syncProjectedReadings(mirror, readingProjections);
9685if (projected) setDataAttributeIfChanged(mirror, "data-yomu-source-projected", "true");
9686else removeAttributeIfPresent(mirror, "data-yomu-source-projected");
9687removeAttributeIfPresent(mirror, "data-yomu-source-stale");
9688styleAdditiveMirrorPaint(mirror, true);
9689}
9690function additiveMirrorProjectionContext(mirror, host) {
9691if (typeof Range !== "function" || typeof Range.prototype.getClientRects !== "function") return "unmeasurable";
9692const source = hostOriginalTextWithNodeOffsets(host);
9693if (!host.isConnected) return "unmeasurable";
9694if (mirrorSourceHostText(mirror) !== source.hostText) return "source-changed";
9695const clipRow = closestRubyFragileConstrainedRow(host);
9696const readingClipRect = clipRow?.getBoundingClientRect() ?? null;
9697if (mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) {
9698const origin = documentAnnotationPortalPaint(mirror).getBoundingClientRect();
9699const view = host.ownerDocument.defaultView;
9700const width = view?.innerWidth ?? host.ownerDocument.documentElement.clientWidth;
9701const height = view?.innerHeight ?? host.ownerDocument.documentElement.clientHeight;
9702const mirrorRect2 = clientRect(origin.left, origin.top, width, height);
9703return {
9704host,
9705source,
9706mirrorRect: mirrorRect2,
9707scaleX: 1,
9708scaleY: 1,
9709clipRow,
9710clipRect: combinedProjectionClipBounds(
9711preparedDocumentAnnotationPortalClipBounds(mirror),
9712readingClipRect
9713),
9714readingClipRect,
9715documentPortal: true
9716};
9717}
9718const hostRect = host.getBoundingClientRect();
9719setInlineStyleIfChanged(mirror, "inset", "0px auto auto 0px");
9720setInlineStyleIfChanged(mirror, "width", stableCssPixels(host.clientWidth || hostRect.width));
9721setInlineStyleIfChanged(mirror, "height", stableCssPixels(host.clientHeight || hostRect.height));
9722setInlineStyleIfChanged(mirror, "padding", "0px");
9723setInlineStyleIfChanged(mirror, "transform", "none");
9724const mirrorRect = mirror.getBoundingClientRect();
9725if (mirrorRect.width <= 0 || mirrorRect.height <= 0) return "unmeasurable";
9726return {
9727host,
9728source,
9729mirrorRect,
9730scaleX: mirror.offsetWidth > 0 ? mirrorRect.width / mirror.offsetWidth : 1,
9731scaleY: mirror.offsetHeight > 0 ? mirrorRect.height / mirror.offsetHeight : 1,
9732clipRow,
9733clipRect: readingClipRect,
9734readingClipRect
9735};
9736}
9737function cachedMirrorSourceRectReader(context) {
9738const cache = new Map();
9739const nodeOffsets = liveMirrorSourceOffsets(context);
9740return (start, end) => {
9741const key = `${start}:${end}`;
9742const cached = cache.get(key);
9743if (cached) return cached;
9744const rects = sourceClientRects(context.host, nodeOffsets, start, end);
9745cache.set(key, rects);
9746return rects;
9747};
9748}
9749function readAdditiveMirrorWordProjection(word, context, sourceRectsFor, readingsConcealed) {
9750const start = Number.parseInt(word.dataset.yomuSourceStart ?? "", 10);
9751const end = Number.parseInt(word.dataset.yomuSourceEnd ?? "", 10);
9752const sourceRects = sourceRectsFor(start, end);
9753const fragments = sourceFragmentProjections(sourceRects, context);
9754const readings = fragments.length ? readProjectedWordReadings(word, context, sourceRectsFor, readingsConcealed) : [];
9755return { word, sourceRects, fragments, readings };
9756}
9757function writeAdditiveMirrorWordProjection(projection, context, readings) {
9758const { word, sourceRects, fragments } = projection;
9759if (!fragments.length) {
9760clearProjectedSourceWord(word);
9761return false;
9762}
9763styleProjectedSourceWord(word);
9764syncSourceFragments(word, fragments, sourceRects, context);
9765for (const { ruby, projection: readingProjection } of projection.readings) {
9766positionProjectedElement(ruby, readingProjection.rect, context.mirrorRect, context.scaleX, context.scaleY);
9767readings.push(readingProjection);
9768}
9769return true;
9770}
9771function sourceFragmentProjections(sourceRects, context) {
9772let gradientOffset = 0;
9773return sourceRects.flatMap((rect) => {
9774const sourceGradientOffset = gradientOffset;
9775gradientOffset += rect.width / context.scaleX;
9776const clipped = context.documentPortal && context.clipRect ? intersectClientRect(rect, context.clipRect) : rect;
9777if (!context.documentPortal && context.clipRect && !rectsIntersect(rect, context.clipRect)) return [];
9778if (!clipped) return [];
9779return [{
9780rect: clipped,
9781gradientOffset: sourceGradientOffset + (clipped.left - rect.left) / context.scaleX
9782}];
9783});
9784}
9785function combinedProjectionClipBounds(first, second) {
9786if (!first) return second;
9787if (!second) return first;
9788return {
9789left: Math.max(first.left, second.left),
9790top: Math.max(first.top, second.top),
9791right: Math.min(first.right, second.right),
9792bottom: Math.min(first.bottom, second.bottom)
9793};
9794}
9795function clientRect(left, top, width, height) {
9796const right = left + width;
9797const bottom = top + height;
9798return {
9799x: left,
9800y: top,
9801left,
9802top,
9803right,
9804bottom,
9805width,
9806height,
9807toJSON: () => ({})
9808};
9809}
9810function intersectClientRect(rect, clip) {
9811const left = Math.max(rect.left, clip.left);
9812const top = Math.max(rect.top, clip.top);
9813const right = Math.min(rect.right, clip.right);
9814const bottom = Math.min(rect.bottom, clip.bottom);
9815return right > left && bottom > top ? clientRect(left, top, right - left, bottom - top) : null;
9816}
9817const PROJECTED_SOURCE_WORD_STYLE_PROPERTIES = ["position", "inset", "width", "height", "margin"];
9818const PROJECTED_SOURCE_ELEMENT_STYLE_PROPERTIES = ["position", "left", "top", "width", "height", "margin"];
9819const PROJECTED_SOURCE_ELEMENT_OFFSET_STYLE_PROPERTIES = ["left", "top", "width", "height", "margin"];
9820function writeProjectedGeometry(element, properties, values) {
9821for (const property of properties) {
9822setInlineStyleIfChanged(element, property, values[property], "important");
9823}
9824}
9825function styleProjectedSourceWord(word) {
9826setDataAttributeIfChanged(word, "data-yomu-source-projected", "true");
9827writeProjectedGeometry(word, PROJECTED_SOURCE_WORD_STYLE_PROPERTIES, {
9828position: "absolute",
9829inset: "0px",
9830width: "auto",
9831height: "auto",
9832margin: "0px"
9833});
9834}
9835function clearAdditiveMirrorSourceProjection(mirror) {
9836removeAttributeIfPresent(mirror, "data-yomu-source-projected");
9837setDataAttributeIfChanged(mirror, "data-yomu-source-stale", "true");
9838for (const word of mirror.querySelectorAll(".jpdb-reader-word[data-yomu-source-projected]")) {
9839setInlineStyleIfChanged(word, "--jpdb-reader-word-decoration-source", "transparent");
9840clearProjectedSourceWord(word);
9841}
9842}
9843function clearProjectedSourceWord(word) {
9844word.querySelectorAll(`.${SOURCE_FRAGMENT_CLASS}`).forEach((fragment) => fragment.remove());
9845for (const wrapper of word.querySelectorAll(".jpdb-reader-detached-ruby")) {
9846PROJECTED_SOURCE_ELEMENT_OFFSET_STYLE_PROPERTIES.forEach((property) => removeInlineStyleIfPresent(wrapper, property));
9847setInlineStyleIfChanged(wrapper, "position", "relative", "important");
9848}
9849PROJECTED_SOURCE_WORD_STYLE_PROPERTIES.forEach((property) => removeInlineStyleIfPresent(word, property));
9850removeAttributeIfPresent(word, "data-yomu-source-projected");
9851}
9852function syncSourceFragments(word, fragments, sourceRects, context) {
9853const gradientWidth = sourceRects.reduce((width, rect) => width + rect.width / context.scaleX, 0);
9854const existing = Array.from(word.querySelectorAll(`:scope > .${SOURCE_FRAGMENT_CLASS}`));
9855fragments.forEach(({ rect, gradientOffset }, index) => {
9856const existingFragment = existing[index];
9857const fragment = existingFragment ?? createSourceFragment(word.ownerDocument);
9858setInlineStyleIfChanged(fragment, "--jpdb-reader-source-gradient-width", stableCssPixels(gradientWidth));
9859setInlineStyleIfChanged(fragment, "--jpdb-reader-source-gradient-offset", stableCssPixels(-gradientOffset));
9860positionProjectedElement(fragment, rect, context.mirrorRect, context.scaleX, context.scaleY);
9861if (!existingFragment) word.append(fragment);
9862});
9863existing.slice(fragments.length).forEach((fragment) => fragment.remove());
9864}
9865function createSourceFragment(document2) {
9866const fragment = document2.createElement("span");
9867fragment.className = SOURCE_FRAGMENT_CLASS;
9868fragment.setAttribute("aria-hidden", "true");
9869return fragment;
9870}
9871function readProjectedWordReadings(word, context, sourceRectsFor, readingsConcealed) {
9872const readings = [];
9873const rubies = word.querySelectorAll(
9874".jpdb-reader-detached-ruby[data-yomu-source-start][data-yomu-source-end]"
9875);
9876for (const ruby of rubies) {
9877const projection = projectedRubyReading(ruby, context, sourceRectsFor, readingsConcealed);
9878if (projection) readings.push({ ruby, projection });
9879}
9880return readings;
9881}
9882function liveMirrorSourceOffsets(context) {
9883if (context.sourceLost) return NO_SOURCE_OFFSETS;
9884if (mirrorSourceNodesConnected(context.source.nodeOffsets)) return context.source.nodeOffsets;
9885const refreshed = hostOriginalTextWithNodeOffsets(context.host);
9886if (refreshed.hostText !== context.source.hostText) {
9887context.sourceLost = true;
9888return NO_SOURCE_OFFSETS;
9889}
9890context.source = refreshed;
9891return refreshed.nodeOffsets;
9892}
9893const NO_SOURCE_OFFSETS = new Map();
9894function mirrorSourceNodesConnected(nodeOffsets) {
9895for (const node of nodeOffsets.keys()) {
9896if (!node.isConnected) return false;
9897}
9898return true;
9899}
9900function projectedRubyReading(ruby, context, sourceRectsFor, initiallyConcealed) {
9901const reading = ruby.querySelector(".jpdb-reader-detached-furi");
9902if (!reading) return null;
9903const start = Number.parseInt(ruby.dataset.yomuSourceStart ?? "", 10);
9904const end = Number.parseInt(ruby.dataset.yomuSourceEnd ?? "", 10);
9905const measure = () => {
9906if (!context.host.isConnected || pageConcealsTextMirrorHost(context.host) || context.documentPortal && context.topLayerConcealed) return null;
9907const clipRect = context.clipRow?.getBoundingClientRect() ?? null;
9908return sourceClientRects(context.host, liveMirrorSourceOffsets(context), start, end).find((rect2) => !clipRect || rectsIntersect(rect2, clipRect)) ?? null;
9909};
9910const rect = initiallyConcealed ? null : sourceRectsFor(start, end).find((candidate) => !context.readingClipRect || rectsIntersect(candidate, context.readingClipRect)) ?? null;
9911if (!rect) return null;
9912return { source: reading, anchor: context.host, rect, measure };
9913}
9914function sourceClientRects(host, nodeOffsets, start, end) {
9915if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
9916const startBoundary = sourceRangeBoundary(nodeOffsets, start, "start");
9917const endBoundary = sourceRangeBoundary(nodeOffsets, end, "end");
9918if (!startBoundary || !endBoundary) return [];
9919const range = host.ownerDocument.createRange();
9920range.setStart(startBoundary.node, startBoundary.offset);
9921range.setEnd(endBoundary.node, endBoundary.offset);
9922return mergeSourceLineRects(Array.from(range.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0));
9923}
9924function mergeSourceLineRects(rects) {
9925const sorted = [...rects].sort((left, right) => left.top - right.top || left.left - right.left);
9926const merged = [];
9927for (const rect of sorted) {
9928const previous = merged.at(-1);
9929const sameLine = previous && Math.abs(previous.top - rect.top) <= 1 && Math.abs(previous.bottom - rect.bottom) <= 1;
9930if (!sameLine || rect.left > previous.right + 1) {
9931merged.push(rect);
9932continue;
9933}
9934const left = Math.min(previous.left, rect.left);
9935const top = Math.min(previous.top, rect.top);
9936const right = Math.max(previous.right, rect.right);
9937const bottom = Math.max(previous.bottom, rect.bottom);
9938merged[merged.length - 1] = {
9939left,
9940top,
9941right,
9942bottom,
9943x: left,
9944y: top,
9945width: right - left,
9946height: bottom - top,
9947toJSON: () => ({})
9948};
9949}
9950return merged;
9951}
9952function positionProjectedElement(element, rect, mirrorRect, scaleX, scaleY) {
9953writeProjectedGeometry(element, PROJECTED_SOURCE_ELEMENT_STYLE_PROPERTIES, {
9954position: "absolute",
9955left: stableCssPixels((rect.left - mirrorRect.left) / scaleX),
9956top: stableCssPixels((rect.top - mirrorRect.top) / scaleY),
9957width: stableCssPixels(rect.width / scaleX),
9958height: stableCssPixels(rect.height / scaleY),
9959margin: "0px"
9960});
9961}
9962function rectsIntersect(left, right) {
9963return left.right > right.left + 0.5 && left.left < right.right - 0.5 && left.bottom >
9963 right.top + 0.5 && left.top < right.bottom - 0.5;
9964}
9965function projectAdditiveTextMirrors(root = document) {
9966const entries2 = [];
9967const mirrors = new Set(queryAllInAnnotationRoots(root, ".jpdb-reader-additive-text-mirror"));
9968documentAnnotationPortalMirrorsWithin(root).forEach((mirror) => mirrors.add(mirror));
9969for (const mirror of mirrors) {
9970const host = registeredTextMirrorHostFor(mirror);
9971if (!host) continue;
9972if (!host.isConnected) {
9973if (mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) removeTextMirror(host);
9974continue;
9975}
9976if (classifyDecoration(host) === "skip") {
9977removeTextMirror(host);
9978continue;
9979}
9980entries2.push({ mirror, host });
9981}
9982const portals = entries2.filter(({ mirror }) => mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS));
9983const document2 = root instanceof Document ? root : root.ownerDocument;
9984const topLayerConcealed = portals.length > 0 && document2 ? documentTopLayerConcealsPortal(document2) : false;
9985for (const { mirror, host } of entries2) {
9986if (mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) {
9987syncTextMirrorVisibilityToPage(host, mirror, topLayerConcealed);
9988}
9989}
9990if (settleTextMirrorReadingLanes(entries2)) {
9991entries2.forEach(({ mirror }) => clearProjectedReadings(mirror));
9992scheduleAdditiveMirrorProjection(root);
9993return;
9994}
9995prepareDocumentAnnotationPortalMirrors(portals.map(({ mirror }) => mirror));
9996if (typeof Range === "function" && typeof Range.prototype.getClientRects === "function") {
9997for (const { mirror, host } of entries2) {
9998projectPreparedAdditiveTextMirror(mirror, host, topLayerConcealed);
9999}
10000}
10001settleDocumentAnnotationPortalMirrors(portals.map(({ mirror }) => mirror));
10002projectInPlaceDetachedReadings(root);
10003if (document2) pruneProjectedReadings(document2);
10004}
10005function projectInPlaceDetachedReadings(root) {
10006const owners = new Map();
10007for (const wrapper of queryAllInAnnotationRoots(root, ".jpdb-reader-detached-ruby")) {
10008if (wrapper.closest(".jpdb-reader-additive-text-mirror")) continue;
10009const reading = wrapper.querySelector(".jpdb-reader-detached-furi");
10010const owner = wrapper.closest(".jpdb-reader-word");
10011if (!reading || !owner) continue;
10012const projections = owners.get(owner) ?? [];
10013owners.set(owner, projections);
10014const measure = () => {
10015if (!owner.isConnected || pageConcealsTextMirrorHost(owner)) return null;
10016const rect2 = wrapper.getBoundingClientRect();
10017const clip = closestRubyFragileConstrainedRow(owner)?.getBoundingClientRect();
10018return rect2.width > 0 && rect2.height > 0 && (!clip || rectsIntersect(rect2, clip)) ? rect2 : null;
10019};
10020const rect = measure();
10021if (!rect) continue;
10022projections.push({ source: reading, anchor: owner, rect, measure });
10023}
10024for (const [owner, projections] of owners) syncProjectedReadings(owner, projections);
10025}
10026function settleTextMirrorReadingLanes(entries2) {
10027const updates = [];
10028for (const { mirror, host } of entries2) {
10029if (mirror.dataset.yomuReadingLaneCandidate !== "true") continue;
10030const state = textMirrorHosts.get(host);
10031if (!state) continue;
10032if (state.documentPortal) {
10033if (state.reservedLineHeight) releaseTextMirrorReadingLane(host, state, mirror);
10034continue;
10035}
10036const inlineLineHeight = host.style.getPropertyValue("line-height");
10037const inlineLineHeightPriority = host.style.getPropertyPriority("line-height");
10038let baselineChanged = false;
10039const ownsReservation = Boolean(state.reservedLineHeight) && inlineLineHeight === state.reservedLineHeight && inlineLineHeightPriority === "important";
10040if (!ownsReservation && (state.reservedLineHeight || inlineLineHeight !== state.lineHeight || inlineLineHeightPriority !== state.lineHeightPriority)) {
10041state.lineHeight = inlineLineHeight;
10042state.lineHeightPriority = inlineLineHeightPriority;
10043state.reservedLineHeight = "";
10044baselineChanged = true;
10045}
10046const multiline = !closestRubyFragileConstrainedRow(host) && sourceTextSpansMultipleLines(host);
10047const lineHeight = multiline ? detachedReadingLaneLineHeight(safeComputedStyle(host), Boolean(state.reservedLineHeight)) : "";
10048if (baselineChanged || lineHeight !== state.reservedLineHeight) {
10049updates.push({ mirror, host, state, lineHeight });
10050}
10051}
10052for (const { mirror, host, state, lineHeight } of updates) {
10053if (!host.isConnected || textMirrorHosts.get(host) !== state || currentTextMirror(host) !== mirror) continue;
10054if (lineHeight) {
10055state.reservedLineHeight = lineHeight;
10056host.style.setProperty("line-height", lineHeight, "important");
10057mirror.style.setProperty("line-height", lineHeight);
10058} else {
10059releaseTextMirrorReadingLane(host, state, mirror);
10060}
10061}
10062return updates.length > 0;
10063}
10064function releaseTextMirrorReadingLane(host, state, mirror) {
10065const injected = state.reservedLineHeight;
10066if (injected) {
10067const current = host.style.getPropertyValue("line-height");
10068const priority2 = host.style.getPropertyPriority("line-height");
10069if (current === injected && priority2 === "important") {
10070restoreStyleProperty(host, "line-height", injected, state.lineHeight, state.lineHeightPriority);
10071} else {
10072state.lineHeight = current;
10073state.lineHeightPriority = priority2;
10074}
10075state.reservedLineHeight = "";
10076}
10077mirror.style.removeProperty("line-height");
10078}
10079const pendingAdditiveMirrorProjectionRoots = new Set();
10080const additiveMirrorProjectionPass = createPostPaintPass(() => {
10081const roots = [...pendingAdditiveMirrorProjectionRoots];
10082pendingAdditiveMirrorProjectionRoots.clear();
10083for (const pendingRoot of roots) projectAdditiveTextMirrors(pendingRoot);
10084});
10085function scheduleAdditiveMirrorProjection(root = document) {
10086pendingAdditiveMirrorProjectionRoots.add(root);
10087additiveMirrorProjectionPass.schedule(viewForNode(root));
10088}
10089function scheduleProjectedAnnotationLayoutRefresh(root = document) {
10090scheduleAdditiveMirrorProjection(root);
10091}
10092function scheduleDocumentPortalMirrorProjection(mirror, host) {
10093if (!mirror.isConnected || !host.isConnected || currentDocumentPortalTextMirror(host) !== mirror) return;
10094pendingDocumentPortalProjections.set(mirror, host);
10095documentPortalProjectionPass.schedule(viewForNode(mirror));
10096}
10097const pendingDocumentPortalProjections = new Map();
10098const documentPortalProjectionPass = createPostPaintPass(() => {
10099const entries2 = [...pendingDocumentPortalProjections].filter(([mirror, host]) => mirror.isConnected && host.isConnected && currentDocumentPortalTextMirror(host) === mirror);
10100pendingDocumentPortalProjections.clear();
10101if (!entries2.length) return;
10102const byDocument = new Map();
10103for (const [mirror, host] of entries2) {
10104const documentEntries = byDocument.get(host.ownerDocument) ?? [];
10105documentEntries.push({ mirror, host });
10106byDocument.set(host.ownerDocument, documentEntries);
10107}
10108for (const [document2, documentEntries] of byDocument) {
10109const mirrors = documentEntries.map(({ mirror }) => mirror);
10110prepareDocumentAnnotationPortalMirrors(mirrors);
10111const topLayerConcealed = documentTopLayerConcealsPortal(document2);
10112for (const { mirror, host } of documentEntries) {
10113syncTextMirrorVisibilityToPage(host, mirror, topLayerConcealed);
10114projectPreparedAdditiveTextMirror(mirror, host, topLayerConcealed);
10115}
10116settleDocumentAnnotationPortalMirrors(mirrors);
10117pruneProjectedReadings(document2);
10118}
10119});
10120function projectOneDocumentPortalTextMirror(mirror, host) {
10121if (!mirror.isConnected || !host.isConnected || currentDocumentPortalTextMirror(host) !== mirror) {
10122if (textMirrorHosts.has(host)) removeTextMirror(host);
10123return;
10124}
10125prepareDocumentAnnotationPortalMirrors([mirror]);
10126const topLayerConcealed = documentTopLayerConcealsPortal(host.ownerDocument);
10127syncTextMirrorVisibilityToPage(host, mirror, topLayerConcealed);
10128projectPreparedAdditiveTextMirror(mirror, host, topLayerConcealed);
10129settleDocumentAnnotationPortalMirrors([mirror]);
10130pruneProjectedReadings(host.ownerDocument);
10131}
10132function mirrorHostSourceGeometry(host, cache) {
10133const cached = cache.get(host);
10134if (cached) return cached;
10135const source = hostOriginalTextWithNodeOffsets(host);
10136const geometry = {
10137hostText: source.hostText,
10138nodeOffsets: source.nodeOffsets,
10139runRects: source.hostText ? sourceRangeRects(host, source.nodeOffsets, 0, source.hostText.length) : []
10140};
10141cache.set(host, geometry);
10142return geometry;
10143}
10144function sourceRangeRects(host, nodeOffsets, start, end) {
10145const startBoundary = sourceRangeBoundary(nodeOffsets, start, "start");
10146const endBoundary = sourceRangeBoundary(nodeOffsets, end, "end");
10147if (!startBoundary || !endBoundary) return [];
10148const range = host.ownerDocument.createRange();
10149range.setStart(startBoundary.node, startBoundary.offset);
10150range.setEnd(endBoundary.node, endBoundary.offset);
10151return Array.from(range.getClientRects());
10152}
10153function mirroredWordSourceGeometry(word, hosts = new Map()) {
10154const mirror = word.closest(".jpdb-reader-text-mirror.jpdb-reader-additive-text-mirror");
10155if (!mirror || typeof Range.prototype.getClientRects !== "function") return null;
10156const host = registeredTextMirrorHostFor(mirror);
10157if (!host?.isConnected) return null;
10158const start = Number.parseInt(word.dataset.yomuSourceStart ?? "", 10);
10159const end = Number.parseInt(word.dataset.yomuSourceEnd ?? "", 10);
10160if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
10161const geometry = mirrorHostSourceGeometry(host, hosts);
10162if (mirrorSourceHostText(mirror) !== geometry.hostText || end > geometry.hostText.length) return null;
10163return { rects: sourceRangeRects(host, geometry.nodeOffsets, start, end), runRects: geometry.runRects };
10164}
10165function readerWordSourcePointScore(word, x, y) {
10166const geometry = mirroredWordSourceGeometry(word);
10167return geometry ? sourceRectsPointScore(geometry.rects, x, y) : null;
10168}
10169function readerWordAtSourcePointInScope(scope, x, y, accepts = () => true) {
10170const hosts = new Map();
10171let best = null;
10172let nearest = null;
10173const selector = ".jpdb-reader-additive-text-mirror .jpdb-reader-word[data-yomu-source-start][data-yomu-source-end]";
10174for (const word of scope.querySelectorAll(selector)) {
10175if (!accepts(word)) continue;
10176const geometry = mirroredWordSourceGeometry(word, hosts);
10177if (!geometry) continue;
10178const score = sourceRectsPointScore(geometry.rects, x, y);
10179if (score !== null) {
10180if (!best || score < best.score) best = { word, score };
10181continue;
10182}
10183if (best || !pointInsideSourceRects(geometry.runRects, x, y)) continue;
10184const distance = sourceRectsPointDistance(geometry.rects, x, y);
10185if (distance !== null && (!nearest || distance < nearest.distance)) nearest = { word, distance };
10186}
10187return best?.word ?? nearest?.word ?? null;
10188}
10189function sourceRangeBoundary(nodeOffsets, sourceOffset, side) {
10190let boundary = null;
10191for (const [node, nodeStart] of nodeOffsets) {
10192const nodeEnd = nodeStart + node.data.length;
10193if (sourceOffset < nodeStart || sourceOffset >
10193 nodeEnd) continue;
10194const candidate = { node, offset: sourceOffset - nodeStart };
10195if (side === "start" && sourceOffset < nodeEnd) return candidate;
10196boundary = candidate;
10197if (side === "end" && sourceOffset > nodeStart) return candidate;
10198}
10199return boundary;
10200}
10201const SOURCE_RECT_HIT_SLACK = 0.75;
10202function sourceRectPointDistance(rect, x, y) {
10203if (rect.width <= 0 || rect.height <= 0) return null;
10204const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
10205const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
10206return dx * dx + dy * dy;
10207}
10208function sourceRectPointScore(rect, x, y) {
10209const distance = sourceRectPointDistance(rect, x, y);
10210if (distance === null) return null;
10211if (x < rect.left - SOURCE_RECT_HIT_SLACK || x > rect.right + SOURCE_RECT_HIT_SLACK || y < rect.top - SOURCE_RECT_HIT_SLACK || y > rect.bottom + SOURCE_RECT_HIT_SLACK) return null;
10212return distance;
10213}
10214function sourceRectsPointScore(rects, x, y) {
10215let best = null;
10216for (const rect of rects) {
10217const score = sourceRectPointScore(rect, x, y);
10218if (score !== null && (best === null || score < best)) best = score;
10219}
10220return best;
10221}
10222function sourceRectsPointDistance(rects, x, y) {
10223let best = null;
10224for (const rect of rects) {
10225const distance = sourceRectPointDistance(rect, x, y);
10226if (distance !== null && (best === null || distance < best)) best = distance;
10227}
10228return best;
10229}
10230function pointInsideSourceRects(rects, x, y) {
10231return rects.some((rect) => sourceRectPointScore(rect, x, y) !== null);
10232}
10233function styleDetachedReadingElements(root, host) {
10234const detachedRubies = Array.from(root.querySelectorAll(".jpdb-reader-detached-ruby"));
10235if (!detachedRubies.length) return;
10236const hostStyle = safeComputedStyle(host);
10237const hostFontSize = Number.parseFloat(hostStyle.fontSize) || 16;
10238const readingFontSize = Math.min(10, Math.max(6, hostFontSize * 0.46));
10239for (const wrapper of detachedRubies) {
10240setInlineStyleIfChanged(wrapper, "position", "relative", "important");
10241setInlineStyleIfChanged(wrapper, "display", "inline-block", "important");
10242setInlineStyleIfChanged(wrapper, "line-height", "1", "important");
10243setInlineStyleIfChanged(wrapper, "vertical-align", "baseline", "important");
10244setInlineStyleIfChanged(wrapper, "white-space", "nowrap", "important");
10245}
10246for (const reading of root.querySelectorAll(".jpdb-reader-detached-furi")) {
10247setInlineStyleIfChanged(reading, "display", "none", "important");
10248setInlineStyleIfChanged(reading, "font-size", `${readingFontSize}px`);
10249setInlineStyleIfChanged(reading, "font-weight", "700");
10250setInlineStyleIfChanged(reading, "line-height", "1", "important");
10251setInlineStyleIfChanged(reading, "text-decoration", "none", "important");
10252setInlineStyleIfChanged(reading, "user-select", "none");
10253setInlineStyleIfChanged(reading, "-webkit-user-select", "none");
10254if (reading.style.getPropertyValue("color")) reading.style.removeProperty("color");
10255setInlineStyleIfChanged(reading, "-webkit-text-fill-color", "currentColor", "important");
10256}
10257}
10258function setInlineStyleIfChanged(element, property, value, priority2 = "") {
10259if (element.style.getPropertyValue(property) === value && element.style.getPropertyPriority(property) === priority2) return;
10260element.style.setProperty(property, value, priority2);
10261}
10262function removeInlineStyleIfPresent(element, property) {
10263if (!element.style.getPropertyValue(property) && !element.style.getPropertyPriority(property)) return;
10264element.style.removeProperty(property);
10265}
10266function setDataAttributeIfChanged(element, name, value) {
10267if (element.getAttribute(name) === value) return;
10268element.setAttribute(name, value);
10269}
10270function removeAttributeIfPresent(element, name) {
10271if (!element.hasAttribute(name)) return;
10272element.removeAttribute(name);
10273}
10274const ADDITIVE_DECORATION_SOURCES = ["status", "jpdb", "anki", "pitch"];
10275const ADDITIVE_HIGHLIGHT_SOURCES = ADDITIVE_DECORATION_SOURCES.filter((source) => source !== "pitch");
10276function styleAdditiveMirrorPaint(root, projectedWordsOnly = false) {
10277if (!root.classList.contains("jpdb-reader-additive-text-mirror")) return;
10278setInlineStyleIfChanged(root, "-webkit-text-fill-color", "transparent", "important");
10279const documentElement = root.ownerDocument.documentElement;
10280const source = selectedWordColorSourceToken(documentElement, ["highlight", "underline", "text"], ADDITIVE_DECORATION_SOURCES);
10281const words = root.querySelectorAll(".jpdb-reader-word");
10282const paint = source ? `var(--jpdb-reader-source-${source}-decoration, transparent)` : "transparent";
10283const highlightSource = selectedWordColorSourceToken(documentElement, ["highlight"], ADDITIVE_HIGHLIGHT_SOURCES);
10284const softPaint = highlightSource ? `var(--jpdb-reader-source-${highlightSource}-soft, transparent)` : "";
10285for (const word of words) {
10286const visible = !projectedWordsOnly || word.dataset.yomuSourceProjected === "true";
10287styleAdditiveMirrorWordPaint(word, paint, softPaint, visible);
10288}
10289}
10290function styleAdditiveMirrorWordPaint(word, paint, softPaint, visible) {
10291removeInlineStyleIfPresent(word, "text-decoration-color");
10292removeInlineStyleIfPresent(word, "--jpdb-reader-additive-decoration");
10293setInlineStyleIfChanged(word, "--jpdb-reader-word-decoration-source", visible ? paint : "transparent");
10294const visibleSoftPaint = visible ? softPaint : "";
10295if (visibleSoftPaint) setInlineStyleIfChanged(word, "--jpdb-reader-mirror-status-soft", visibleSoftPaint);
10296else removeInlineStyleIfPresent(word, "--jpdb-reader-mirror-status-soft");
10297}
10298function stabilizeDetachedReadings(root, clipRow, filterWordsToClip = false) {
10299if (filterWordsToClip && root.dataset.yomuSourceProjected !== "true") filterDetachedWordsToClip(root, clipRow);
10300scheduleAdditiveMirrorProjection(root.getRootNode());
10301}
10302function filterDetachedWordsToClip(root, clipRow) {
10303const words = Array.from(root.querySelectorAll(".jpdb-reader-word"));
10304for (const word of words) {
10305if (word.dataset.yomuDetachedWordHidden !== "outside-clip") continue;
10306delete word.dataset.yomuDetachedWordHidden;
10307word.style.removeProperty("visibility");
10308}
10309if (root.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) return;
10310const clipRect = clipRow?.getBoundingClientRect();
10311if (!clipRect || clipRect.width <= 0 || clipRect.height <= 0) return;
10312for (const word of words) {
10313const bases = Array.from(word.querySelectorAll(".jpdb-reader-detached-ruby .jpdb-reader-ruby-base"));
10314const rects = (bases.length ? bases : [word]).map((base) => base.getBoundingClientRect());
10315const visible = rects.some((rect) => rect.bottom > clipRect.top + 0.5 && rect.top < clipRect.bottom - 0.5 && rect.right > clipRect.left + 0.5 && rect.left < clipRect.right - 0.5);
10316if (visible) continue;
10317word.dataset.yomuDetachedWordHidden = "outside-clip";
10318word.style.setProperty("visibility", "hidden", "important");
10319}
10320}
10321const settledMirrorProjectionGeometry = new WeakMap();
10322function mirrorProjectionGeometrySignature(root) {
10323const rect = root.getBoundingClientRect();
10324return `${rect.left}:${rect.top}:${rect.width}:${rect.height}:${root.textContent ?? ""}`;
10325}
10326function styleConstrainedTextMirror(mirror, clipRow) {
10327if (!clipRow) return;
10328constrainMirrorToClampBox(mirror, clipRow);
10329}
10330function textMirrorClipMode(host, renderPlan, settings2, hasNativeRuby) {
10331const clipRow = closestRubyFragileConstrainedRow(host);
10332const hasReadings = renderPlan.tokens.some((token) => shouldRenderRuby(
10333renderPlan.text.slice(token.start, token.end),
10334token,
10335settings2,
10336!hasNativeRuby,
10337true
10338));
10339const detachedReadings = hasReadings;
10340return {
10341clipRow,
10342detachedReadings
10343};
10344}
10345function constrainMirrorToClampBox(mirror, clipRow) {
10346const height = clipRow.clientHeight;
10347if (height > 0) mirror.style.setProperty("max-height", `${height}px`);
10348mirror.style.setProperty("overflow", "hidden");
10349}
10350function whitespaceCollapsedNonDestructiveRender(text2, tokens, whitespaceJoints) {
10351if (!whitespaceJoints?.length && !/\s{2,}|\r|\n/u.test(text2)) return { text: text2, tokens };
10352const { normalized, offsets } = collapseWhitespaceWithOffsets(text2, whitespaceJoints);
10353if (normalized === text2) return { text: text2, tokens };
10354return {
10355text: normalized,
10356tokens: tokens.map((token) => remapTokenOffsets(token, offsets, normalized))
10357};
10358}
10359function collapseWhitespaceWithOffsets(text2, whitespaceJoints = []) {
10360const offsets = new Array(text2.length + 1);
10361const joints = new Set(whitespaceJoints);
10362let normalized = "";
10363let index = 0;
10364while (index < text2.length) {
10365if (/\s/u.test(text2[index] ?? "")) {
10366const start = index;
10367let touchesJoint = joints.has(start);
10368while (index < text2.length && /\s/u.test(text2[index] ?? "")) {
10369index += 1;
10370if (joints.has(index)) touchesJoint = true;
10371}
10372const mapped = normalized.length;
10373if (normalized.length > 0 && index < text2.length && !touchesJoint && !(isCjkChar(lastFullChar(normalized)) && isCjkChar(String.fromCodePoint(text2.codePointAt(index) ?? 0)))) {
10374normalized += " ";
10375}
10376for (let offset = start; offset < index; offset += 1) offsets[offset] = mapped;
10377continue;
10378}
10379offsets[index] = normalized.length;
10380normalized += text2[index];
10381index += 1;
10382}
10383offsets[text2.length] = normalized.length;
10384return { normalized, offsets };
10385}
10386function isCjkChar(char) {
10387return Boolean(char) && /[ -ヿ㐀-鿿豈-﫿!-゚\u{20000}-\u{3FFFF}]/u.test(char ?? "");
10388}
10389function lastFullChar(text2) {
10390if (!text2) return void 0;
10391const chars = Array.from(text2.slice(-2));
10392return chars[chars.length - 1];
10393}
10394function remapTokenOffsets(token, offsets, sentence) {
10395const start = offsets[token.start] ?? token.start;
10396const end = offsets[token.end] ?? token.end;
10397return {
10398...token,
10399start,
10400end,
10401length: Math.max(0, end - start),
10402sentence,
10403rubies: token.rubies.map((ruby) => {
10404const rubyStart = offsets[ruby.start] ?? ruby.start;
10405const rubyEnd = offsets[ruby.end] ?? ruby.end;
10406return {
10407...ruby,
10408start: rubyStart,
10409end: rubyEnd,
10410length: Math.max(0, rubyEnd - rubyStart)
10411};
10412})
10413};
10414}
10415const textMirrorOwners = new WeakMap();
10416function registerTextMirrorOwner(mirror, host) {
10417textMirrorOwners.set(mirror, host);
10418}
10419function textMirrorBelongsToHost(mirror, host) {
10420const owner = textMirrorOwners.get(mirror);
10421if (owner && textMirrorHosts.has(owner)) return owner === host;
10422let ancestor = mirror.parentElement;
10423while (ancestor && ancestor !== host) {
10424if (textMirrorHosts.has(ancestor)) return false;
10425ancestor = ancestor.parentElement;
10426}
10427return ancestor === host;
10428}
10429function ownedTextMirrors(host) {
10430return Array.from(host.querySelectorAll(READER_TEXT_MIRROR_SELECTOR)).filter((mirror) => textMirrorBelongsToHost(mirror, host));
10431}
10432function currentTextMirror(host) {
10433return currentDocumentPortalTextMirror(host) ?? currentInHostTextMirror(host);
10434}
10435function currentDocumentPortalTextMirror(host) {
10436const state = textMirrorHosts.get(host);
10437const tracked = state?.documentPortal ? state.mirror?.deref() : void 0;
10438return tracked?.isConnected && textMirrorBelongsToHost(tracked, host) ? tracked : null;
10439}
10440function documentPortalReaderWordScopeForSource(target) {
10441let current = target instanceof HTMLElement ? target : target.parentElement;
10442for (let depth = 0; current && depth < 16; depth += 1, current = composedAncestorElement(current)) {
10443const inHost = currentInHostTextMirror(current);
10444if (inHost?.querySelector(READER_WORD_SELECTOR$1)) return null;
10445const portal = currentDocumentPortalTextMirror(current);
10446if (portal?.querySelector(READER_WORD_SELECTOR$1)) return portal;
10447}
10448return null;
10449}
10450function documentPortalSourceHostForReaderWord(word) {
10451const mirror = word.closest(READER_TEXT_MIRROR_SELECTOR);
10452if (!mirror?.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) return null;
10453const host = textMirrorOwners.get(mirror);
10454return host && currentDocumentPortalTextMirror(host) === mirror ? host : null;
10455}
10456function currentInHostTextMirror(host) {
10457const direct = Array.from(host.children).find((child) => child instanceof HTMLElement && child.matches(READER_TEXT_MIRROR_SELECTOR) && textMirrorBelongsToHost(child, host));
10458if (direct) return direct;
10459return ownedTextMirrors(host)[0] ?? null;
10460}
10461function textMirrorAlreadyRenders(host, text2) {
10462const mirror = currentTextMirror(host);
10463if (!mirror || !textMirrorRenderIsIntact(mirror)) return false;
10464const source = mirror.dataset.sourceText ?? "";
10465const renders = normalizedMirrorHostText(source) === normalizedMirrorHostText(text2);
10466if (renders && mirror.style.getPropertyValue("visibility") === "hidden") {
10467syncTextMirrorVisibilityToPage(host, mirror);
10468}
10469return renders;
10470}
10471function mirrorAnnotatedSourceRanges(mirror) {
10472const ranges = [];
10473for (const word of mirror.querySelectorAll(READER_WORD_SELECTOR$1)) {
10474const start = Number.parseInt(word.dataset.yomuSourceStart ?? "", 10);
10475const end = Number.parseInt(word.dataset.yomuSourceEnd ?? "", 10);
10476if (Number.isNaN(start) || Number.isNaN(end) || end <= start) continue;
10477ranges.push({ start, end });
10478}
10479return ranges;
10480}
10481function scanTargetAlreadyAnnotated(target) {
10482if (!(target.parent instanceof HTMLElement)) return false;
10483if (textMirrorAlreadyRenders(target.parent, target.text)) return true;
10484const host = nonDestructiveScanHost(target);
10485const mirror = currentTextMirror(host);
10486if (!mirror || !textMirrorRenderIsIntact(mirror)) return false;
10487const fragments = nonDestructiveTargetFragments(target);
10488if (!fragments.length) return false;
10489const { hostText, nodeOffsets } = hostOriginalTextWithNodeOffsets(host);
10490if (!hostText || mirrorSourceHostText(mirror) !== hostText) return false;
10491const ranges = targetHostRanges(fragments, nodeOffsets);
10492if (!ranges?.length) return false;
10493const words = mirrorAnnotatedSourceRanges(mirror);
10494let annotated = false;
10495for (const range of ranges) {
10496if (!HAS_JAPANESE_LETTER.test(hostText.slice(range.start, range.end))) continue;
10497if (!words.some((word) => hostRangesOverlap(word, range))) return false;
10498annotated = true;
10499}
10500if (!annotated) return false;
10501if (mirror.style.getPropertyValue("visibility") === "hidden") syncTextMirrorVisibilityToPage(host, mirror);
10502return true;
10503}
10504function textMirrorRenderIsIntact(mirror) {
10505return Boolean(mirror.querySelector(READER_WORD_SELECTOR$1));
10506}
10507function nonDestructiveScanSignature(target, tokens, settings2, suppressRuby = Boolean(target.suppressRuby), detachedReadings = suppressRuby) {
10508return JSON.stringify({
10509readings: detachedReadings ? "detached" : suppressRuby ? "none" : "ruby",
10510mode: settings2.furiganaMode,
10511hidden: settings2.furiganaHiddenStateGroups,
10512colors: settings2.wordColorStates,
10513tokens: tokens.map((token) => ({
10514s: token.start,
10515e: token.end,
10516v: token.card.vid,
10517r: token.card.rid,
10518source: token.card.source,
10519state: token.card.cardState,
10520pitch: token.pitchClass,
10521ruby: token.rubies
10522}))
10523});
10524}
10525const controlTextMirrorHosts = new WeakMap();
10526function applyTokensToControlTextMirrorTarget(target, tokens, settings2) {
10527const host = target.parent;
10528if (!host.isConnected) return;
10529const text2 = target.text;
10530const safeTokens = nonOverlappingTokens(tokens, text2);
10531const placeholderOverlay = isPlaceholderControlTextMirror(host, text2);
10532const suppressRuby = placeholderOverlay || scanTargetSuppressesRuby(host, target.suppressRuby, false);
10533const detachedReadings = suppressRuby && !placeholderOverlay;
10534const renderSettings = furiganaSettingsForTarget(settings2, host);
10535const signature = nonDestructiveScanSignature(target, safeTokens, renderSettings, suppressRuby, detachedReadings);
10536const existing = currentControlTextMirror(host);
10537if (existing?.dataset.sourceText === text2 && existing.dataset.renderSignature === signature) return;
10538removeControlTextMirror(host);
10539if (!safeTokens.length) return;
10540const mirror = document.createElement("span");
10541mirror.className = "jpdb-reader-control-text-mirror";
10542mirror.dataset.jpdbReaderControlTextMirror = "true";
10543mirror.dataset.jpdbReaderControlMirrorKind = placeholderOverlay ? "placeholder" : "inline";
10544mirror.dataset.sourceText = text2;
10545mirror.dataset.renderSignature = signature;
10546const state = styleControlTextMirror(mirror, host, placeholderOverlay);
10547mirror.append(renderTokenizedScanText(text2, safeTokens, renderSettings, {
10548parent: host,
10549hasNativeRuby: false,
10550decoration: target.decoration,
10551mirrorRender: true,
10552suppressRuby,
10553detachedReadings,
10554passiveInteraction: target.passiveInteraction,
10555suppressRubyDoesNotImplyPassive: placeholderOverlay
10556}));
10557if (!mirror.textContent?.trim()) {
10558restoreControlTextMirrorHost(host, state);
10559return;
10560}
10561ensureReaderStylesForHost(host);
10562host.insertAdjacentElement("afterend", mirror);
10563observeControlTextMirrorHost(host, state);
10564}
10565function currentControlTextMirror(host) {
10566const sibling = host.nextElementSibling;
10567return sibling instanceof HTMLElement && sibling.matches(READER_CONTROL_TEXT_MIRROR_SELECTOR) ? sibling : null;
10568}
10569function canvasFallbackRenderContext(target, tokens, settings2) {
10570const canvas = target.parent;
10571if (!(canvas instanceof HTMLCanvasElement) || !canvas.isConnected) return null;
10572const host = canvas.parentElement;
10573if (!host) return null;
10574const text2 = target.text;
10575const safeTokens = nonOverlappingTokens(tokens, text2);
10576const nativeCanvas = canvas.parentElement?.classList.contains("lesson-canvas-clipper") ?? false;
10577const suppressRuby = [nativeCanvas, Boolean(target.suppressRuby)].includes(true);
10578const renderSettings = furiganaSettingsForTarget(settings2, canvas);
10579const signature = nonDestructiveScanSignature(target, safeTokens, renderSettings, suppressRuby);
10580return { canvas, host, text: text2, safeTokens, nativeCanvas, suppressRuby, renderSettings, signature };
10581}
10582function applyTokensToCanvasFallbackTarget(target, tokens, settings2) {
10583const context = canvasFallbackRenderContext(target, tokens, settings2);
10584if (!context) return;
10585if (canvasFallbackTextLayerMatches(context)) return;
10586removeCanvasFallbackTextLayer(context.canvas);
10587if (!context.safeTokens.length) return;
10588mountCanvasFallbackTextLayer(target, context);
10589}
10590function canvasFallbackTextLayerMatches(context) {
10591const existing = currentCanvasFallbackTextLayer(context.canvas);
10592return [
10593existing?.dataset.sourceText === context.text,
10594existing?.dataset.renderSignature === context.signature
10595].every(Boolean);
10596}
10597function mountCanvasFallbackTextLayer(target, context) {
10598const layer = document.createElement("div");
10599layer.className = context.nativeCanvas ? "jpdb-reader-canvas-text-layer jpdb-reader-native-canvas" : "jpdb-reader-canvas-text-layer";
10600layer.dataset.sourceText = context.text;
10601layer.dataset.renderSignature = context.signature;
10602const hasRuby = context.safeTokens.some((token) => token.rubies.length > 0) && !context.suppressRuby;
10603styleCanvasFallbackTextLayer(layer, context.canvas, hasRuby, context.nativeCanvas);
10604layer.append(renderTokenizedScanText(context.text, context.safeTokens, context.renderSettings, {
10605parent: context.canvas,
10606hasNativeRuby: targetHasNativeRuby(target),
10607mirrorRender: true,
10608decoration: target.decoration,
10609suppressRuby: context.suppressRuby,
10610passiveInteraction: context.nativeCanvas || target.passiveInteraction
10611}));
10612if (!layer.textContent?.trim()) return;
10613const state = mountCanvasTextLayer(context.canvas, context.host, layer, context.nativeCanvas);
10614canvasFallbackTextLayers.set(context.canvas, state);
10615ensureReaderStylesForHost(context.host);
10616context.host.append(layer);
10617}
10618function currentCanvasFallbackTextLayer(canvas) {
10619const state = canvasFallbackTextLayers.get(canvas);
10620return state?.layer.isConnected ? state.layer : null;
10621}
10622function styleCanvasFallbackTextLayer(layer, canvas, hasRuby, n) {
10623const style = safeComputedStyle(canvas);
10624layer.style.setProperty("position", "absolute");
10625layer.style.setProperty("left", `${canvas.offsetLeft}px`);
10626layer.style.setProperty("top", `${canvas.offsetTop}px`);
10627layer.style.setProperty("width", `${canvas.offsetWidth || canvas.width}px`);
10628layer.style.setProperty("min-height", `${canvas.offsetHeight || canvas.height}px`);
10629layer.style.setProperty("box-sizing", "border-box");
10630layer.style.setProperty("overflow", "visible");
10631layer.style.setProperty("visibility", "visible", "important");
10632layer.style.setProperty("pointer-events", n ? "none" : "auto");
10633layer.style.setProperty("white-space", "pre-wrap");
10634layer.style.setProperty("font", style.font);
10635layer.style.setProperty("font-size", style.fontSize);
10636layer.style.setProperty("font-weight", style.fontWeight);
10637layer.style.setProperty("line-height", hasRuby ? rubyFriendlyMirrorLineHeight(style) : style.lineHeight);
10638layer.style.setProperty("letter-spacing", style.letterSpacing);
10639layer.style.setProperty("text-align", style.textAlign);
10640layer.style.setProperty("color", style.color);
10641layer.style.setProperty("z-index", "1");
10642if (n) layer.style.setProperty("opacity", "0");
10643}
10644function mountCanvasTextLayer(canvas, host, layer, n) {
10645const hostStyle = safeComputedStyle(host);
10646const state = {
10647layer,
10648canvasVisibility: canvas.style.getPropertyValue("visibility"),
10649canvasVisibilityPriority: canvas.style.getPropertyPriority("visibility"),
10650hostPosition: host.style.getPropertyValue("position"),
10651hostPositionPriority: host.style.getPropertyPriority("position"),
10652hostPositionAdjusted: hostStyle.position === "static"
10653};
10654if (state.hostPositionAdjusted) host.style.setProperty("position", "relative", "important");
10655if (!n) canvas.style.setProperty("visibility", "hidden", "important");
10656return state;
10657}
10658function removeCanvasFallbackTextLayer(canvas) {
10659const state = canvasFallbackTextLayers.get(canvas);
10660state?.layer.remove();
10661if (state) {
10662restoreStyleProperty(canvas, "visibility", "hidden", state.canvasVisibility, state.canvasVisibilityPriority);
10663const host = canvas.parentElement;
10664if (host && state.hostPositionAdjusted) restoreStyleProperty(host, "position", "relative", state.hostPosition, state.hostPositionPriority);
10665}
10666canvasFallbackTextLayers.delete(canvas);
10667}
10668function isPlaceholderControlTextMirror(host, text2) {
10669if (!(host instanceof HTMLInputElement || host instanceof HTMLTextAreaElement)) return false;
10670if (host.value.trim()) return false;
10671return normalizedControlText(host.getAttribute("placeholder") ?? "") === normalizedControlText(text2);
10672}
10673function styleControlTextMirror(mirror, host, placeholderOverlay) {
10674const style = safeComputedStyle(host);
10675mirror.style.setProperty("font", style.font);
10676mirror.style.setProperty("color", style.color);
10677const state = {
10678onChange: () => removeControlTextMirror(host),
10679placeholderHidden: false,
10680placeholderHiddenAttribute: host.getAttribute(READER_CONTROL_PLACEHOLDER_HIDDEN_ATTRIBUTE),
10681parent: void 0,
10682parentPosition: "",
10683parentPositionPriority: "",
10684parentPositionAdjusted: false
10685};
10686if (!placeholderOverlay || !(host instanceof HTMLInputElement || host instanceof HTMLTextAreaElement)) return state;
10687host.setAttribute(READER_CONTROL_PLACEHOLDER_HIDDEN_ATTRIBUTE, "true");
10688state.placeholderHidden = true;
10689const parent = host.parentElement;
10690if (parent) {
10691const parentStyle = safeComputedStyle(parent);
10692state.parent = parent;
10693state.parentPosition = parent.style.getPropertyValue("position");
10694state.parentPositionPriority = parent.style.getPropertyPriority("position");
10695state.parentPositionAdjusted = parentStyle.position === "static";
10696if (state.parentPositionAdjusted) parent.style.setProperty("position", "relative", "important");
10697}
10698const borderLeft = cssPixels(style.borderLeftWidth);
10699const borderTop = cssPixels(style.borderTopWidth);
10700const paddingLeft = cssPixels(style.paddingLeft);
10701const paddingRight = cssPixels(style.paddingRight);
10702const paddingTop = cssPixels(style.paddingTop);
10703const contentWidth = host.clientWidth ? Math.max(0, host.clientWidth - paddingLeft - paddingRight) : Math.max(0, host.getBoundingClientRect().width - borderLeft - cssPixels(style.borderRightWidth) - paddingLeft - paddingRight);
10704mirror.style.setProperty("left", `${Math.max(0, host.offsetLeft + borderLeft + paddingLeft)}px`);
10705mirror.style.setProperty("top", `${Math.max(0, host.offsetTop + borderTop + paddingTop)}px`);
10706if (contentWidth) mirror.style.setProperty("width", `${contentWidth}px`);
10707mirror.style.setProperty("line-height", style.lineHeight);
10708mirror.style.setProperty("text-align", style.textAlign);
10709mirror.style.setProperty("white-space", host instanceof HTMLTextAreaElement ? "pre-wrap" : "pre");
10710return state;
10711}
10712function observeControlTextMirrorHost(host, state) {
10713const previous = controlTextMirrorHosts.get(host);
10714previous?.listeners?.abort();
10715const listeners2 = new AbortController();
10716state.listeners = listeners2;
10717host.addEventListener("change", state.onChange, { signal: listeners2.signal });
10718host.addEventListener("input", state.onChange, { signal: listeners2.signal });
10719controlTextMirrorHosts.set(host, state);
10720}
10721function removeControlTextMirror(host) {
10722const state = controlTextMirrorHosts.get(host);
10723if (state) {
10724state.listeners?.abort();
10725restoreControlTextMirrorHost(host, state);
10726}
10727currentControlTextMirror(host)?.remove();
10728controlTextMirrorHosts.delete(host);
10729}
10730function restoreControlTextMirrorHost(host, state) {
10731if (state.placeholderHidden) {
10732if (state.placeholderHiddenAttribute === null) host.removeAttribute(READER_CONTROL_PLACEHOLDER_HIDDEN_ATTRIBUTE);
10733else host.setAttribute(READER_CONTROL_PLACEHOLDER_HIDDEN_ATTRIBUTE, state.placeholderHiddenAttribute);
10734}
10735if (state.parent && state.parentPositionAdjusted) {
10736restoreStyleProperty(state.parent, "position", "relative", state.parentPosition, state.parentPositionPriority);
10737}
10738}
10739function furiganaSettingsForTarget(settings2, parent) {
10740if (!targetForcesAllFurigana(parent)) return settings2;
10741if (settings2.showFurigana && settings2.furiganaMode === "all") return settings2;
10742return { ...settings2, showFurigana: true, furiganaMode: "all" };
10743}
10744function scanTargetSuppressesRuby(parent, suppressRuby, inPlace = true, decoration) {
10745if (targetForcesAllFurigana(parent) && parent.closest(READER_ROOT_SELECTOR$3)) return false;
10746if (decoration === "interactive-passive") return true;
10747if (inPlace) {
10748const clipRow = closestRubyFragileConstrainedRow(parent);
10749if (clipRow && !clampRowKeepsInFlowRestRuby(decoration ?? decorationStateForWord(parent) ?? void 0, clipRow)) return true;
10750}
10751if (targetForcesAllFurigana(parent)) return false;
10752return Boolean(suppressRuby);
10753}
10754function targetForcesAllFurigana(parent) {
10755return Boolean(parent.closest('[data-yomu-furigana-mode="all"]'));
10756}
10757function nonDestructiveScanHost(target) {
10758if (!isFragmentTextTarget$1(target)) return target.parent;
10759const parents = target.fragments.map((fragment) => fragment.node.parentElement).filter((parent) => Boolean(parent));
10760if (parents.length && parents.every((parent) => parent === target.parent)) return target.parent;
10761return preferredNonDestructiveTextHost(parents) ?? commonFragmentTextHost(parents) ?? target.parent;
10762}
10763function preferredNonDestructiveTextHost(elements) {
10764if (!elements.length) return null;
10765const preferred = elements[0]?.closest(NON_DESTRUCTIVE_TEXT_HOST_SELECTOR);
10766if (!preferred || !elements.every((element) => preferred.contains(element))) return null;
10767return preferred;
10768}
10769function commonFragmentTextHost(elements) {
10770if (!elements.length) return null;
10771let candidate = elements[0];
10772while (candidate) {
10773const host = candidate;
10774if (elements.every((element) => host.contains(element))) return host;
10775candidate = candidate.parentElement;
10776}
10777return null;
10778}
10779function targetHasNativeRuby(target) {
10780return isFragmentTextTarget$1(target) ? target.fragments.some((fragment) => fragment.hasNativeRuby) : Boolean(target.hasNativeRuby);
10781}
10782function sourceTextSpansMultipleLines(host) {
10783const source = hostOriginalTextWithNodeOffsets(host);
10784const style = safeComputedStyle(host);
10785if (preservesWhitespace(style.whiteSpace) && /\r|\n/u.test(source.hostText)) return true;
10786if (typeof Range !== "function" || typeof Range.prototype.getClientRects !== "function") return false;
10787const rects = sourceClientRects(host, source.nodeOffsets, 0, source.hostText.length);
10788const vertical = /^(?:vertical|sideways)/u.test(style.writingMode);
10789const intervals = rects.map((rect) => vertical ? [rect.left, rect.right] : [rect.top, rect.bottom]).filter(([start, end]) => Number.isFinite(start) && Number.isFinite(end) && end - start > 0.5);
10790return intervals.some(([start, end], index) => intervals.slice(index + 1).some(([otherStart, otherEnd]) => end < otherStart - 0.5 || otherEnd < start - 0.5));
10791}
10792function styleTextMirrorHost(host) {
10793const computed = safeComputedStyle(host);
10794const state = {
10795observer: new MutationObserver(() => void 0),
10796sourceText: "",
10797position: host.style.getPropertyValue("position"),
10798positionPriority: host.style.getPropertyPriority("position"),
10799positioned: computed.position === "static",
10800lineHeight: host.style.getPropertyValue("line-height"),
10801lineHeightPriority: host.style.getPropertyPriority("line-height"),
10802reservedLineHeight: ""
10803};
10804textMirrorHosts.set(host, state);
10805if (state.positioned) host.style.setProperty("position", "relative", "important");
10806return state;
10807}
10808function styleDocumentPortalTextMirrorHost(host) {
10809const state = {
10810observer: new MutationObserver(() => void 0),
10811sourceText: "",
10812position: host.style.getPropertyValue("position"),
10813positionPriority: host.style.getPropertyPriority("position"),
10814positioned: false,
10815lineHeight: host.style.getPropertyValue("line-height"),
10816lineHeightPriority: host.style.getPropertyPriority("line-height"),
10817reservedLineHeight: "",
10818documentPortal: true
10819};
10820textMirrorHosts.set(host, state);
10821return state;
10822}
10823function scheduleCurrentTextMirrorProjection(host) {
10824const mirror = currentTextMirror(host);
10825if (mirror?.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS)) {
10826scheduleDocumentPortalMirrorProjection(mirror, host);
10827return;
10828}
10829scheduleAdditiveMirrorProjection(host.getRootNode());
10830}
10831function styleTextMirror(mirror, host, hasRuby = false) {
10832const style = safeComputedStyle(host);
10833mirror.style.setProperty("position", "absolute");
10834mirror.style.setProperty("inset", "0 0 auto 0");
10835mirror.style.setProperty("box-sizing", "border-box");
10836mirror.style.setProperty("padding-top", style.paddingTop);
10837mirror.style.setProperty("padding-right", style.paddingRight);
10838mirror.style.setProperty("padding-bottom", style.paddingBottom);
10839mirror.style.setProperty("padding-left", style.paddingLeft);
10840if (hostCentersTextVertically(host, style)) {
10841mirror.style.setProperty("inset", "50% 0 auto 0");
10842mirror.style.setProperty("transform", "translateY(-50%)");
10843}
10844mirror.style.setProperty("height", "auto");
10845mirror.style.setProperty("overflow", "visible");
10846mirror.style.setProperty("pointer-events", "none");
10847mirror.style.setProperty("white-space", style.whiteSpace);
10848mirror.style.setProperty("font", style.font);
10849mirror.style.setProperty("font-size", style.fontSize);
10850mirror.style.setProperty("font-weight", style.fontWeight);
10851mirror.style.setProperty("line-height", hasRuby ? rubyFriendlyMirrorLineHeight(style) : style.lineHeight);
10852mirror.style.setProperty("letter-spacing", style.letterSpacing);
10853mirror.style.setProperty("text-align", style.textAlign);
10854mirror.style.setProperty("z-index", "1");
10855if (hasRuby) mirror.dataset.jpdbReaderHasRuby = "true";
10856}
10857function hostCentersTextVertically(host, style) {
10858const itemized = style.display.includes("flex") || style.display.includes("grid");
10859if (itemized) return style.alignItems === "center" || style.alignContent === "center";
10860return host.matches('button,input[type="button"],input[type="submit"],input[type="reset"]');
10861}
10862function observeTextMirrorHost(host) {
10863const state = textMirrorHosts.get(host);
10864if (!state) return;
10865state.sourceText = normalizedMirrorHostText(nativeTextMirrorHostText(host));
10866state.observer.disconnect();
10867clearTimeout(state.staleRemovalTimer);
10868state.staleRemovalTimer = void 0;
10869state.lifecycle?.abort();
10870const lifecycle = new AbortController();
10871state.lifecycle = lifecycle;
10872const hostRef = new WeakRef(host);
10873const observer = new MutationObserver((mutations) => {
10874const liveHost = hostRef.deref();
10875const liveState = liveHost ? textMirrorHosts.get(liveHost) : void 0;
10876if (!liveHost || !liveState || liveState.observer !== observer) {
10877liveTextMirrorObservers.delete(observer);
10878observer.disconnect();
10879return;
10880}
10881if (mutations.every(mutationInsideTextMirror)) return;
10882if (!currentTextMirror(liveHost)) {
10883const wipedHostText = normalizedMirrorHostText(nativeTextMirrorHostText(liveHost));
10884if (liveHost.isConnected && isTargetLanguageText(wipedHostText)) {
10885if (wipedHostText === liveState.sourceText && mutationsRewroteHostContent(mutations) && replayNonDestructiveRenderFromCache(liveHost)) return;
10886dispatchTextMirrorStale(liveHost);
10887}
10888removeTextMirror(liveHost);
10889return;
10890}
10891const hostAttributeMutations = mutations.filter(
10892(mutation) => mutation.type === "attributes" && mutation.target === liveHost
10893);
10894if (hostAttributeMutations.length) {
10895noteConstrainedRowLayoutSettled();
10896if (liveState.reservedLineHeight && hostAttributeMutations.some((mutation) => mutation.attributeName === "class")) {
10897const mirror = currentTextMirror(liveHost);
10898if (mirror) releaseTextMirrorReadingLane(liveHost, liveState, mirror);
10899}
10900reassertTextMirrorHostStyles(liveHost, liveState);
10901const liveMirror = currentTextMirror(liveHost);
10902if (liveState.documentPortal && liveMirror) {
10903invalidateDocumentAnnotationPortalClipTopology(liveMirror);
10904scheduleDocumentPortalMirrorProjection(liveMirror, liveHost);
10905} else {
10906scheduleCurrentTextMirrorProjection(liveHost);
10907}
10908}
10909if (!mutations.some((mutation) => mutation.type === "childList" || mutation.type === "characterData")) return;
10910const currentText = normalizedMirrorHostText(nativeTextMirrorHostText(liveHost));
10911if (!liveHost.isConnected || !isTargetLanguageText(currentText)) {
10912removeTextMirror(liveHost);
10913return;
10914}
10915if (currentText === liveState.sourceText && mutationsRewroteHostContent(mutations)) {
10916scheduleCurrentTextMirrorProjection(liveHost);
10917}
10918if (currentText !== liveState.sourceText) {
10919dispatchTextMirrorStale(liveHost);
10920if (replayNonDestructiveRenderFromCache(liveHost, true)) return;
10921reassertTextMirrorHostStyles(liveHost, liveState);
10922clearTimeout(liveState.staleRemovalTimer);
10923const staleSource = liveState.sourceText;
10924const staleLifecycle = liveState.lifecycle;
10925liveState.staleRemovalTimer = setTimeout(() => {
10926if (staleLifecycle?.signal.aborted) return;
10927const timerHost = hostRef.deref();
10928const timerState = timerHost ? textMirrorHosts.get(timerHost) : void 0;
10929if (!timerHost || !timerState || timerState.lifecycle !== staleLifecycle || timerState.sourceText !== staleSource) return;
10930if (normalizedMirrorHostText(nativeTextMirrorHostText(timerHost)) !== timerState.sourceText) removeTextMirror(timerHost);
10931}, STALE_MIRROR_REMOVAL_GRACE_MS);
10932}
10933});
10934state.observer = observer;
10935observer.observe(host, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["style", "class"] });
10936liveTextMirrorObservers.set(observer, hostRef);
10937lifecycle.signal.addEventListener("abort", () => liveTextMirrorObservers.delete(observer), { once: true });
10938}
10939const liveTextMirrorObservers = new Map();
10940let mirrorTokenApplyDepth = 0;
10941function withMirrorTokenApply(callback) {
10942mirrorTokenApplyDepth += 1;
10943try {
10944return callback();
10945} finally {
10946mirrorTokenApplyDepth -= 1;
10947if (mirrorTokenApplyDepth === 0) {
10948sweepAndDrainTextMirrorObservers();
10949}
10950}
10951}
10952function sweepAndDrainTextMirrorObservers() {
10953for (const [observer, hostRef] of liveTextMirrorObservers) {
10954const host = hostRef.deref();
10955if (!host || !host.isConnected) {
10956liveTextMirrorObservers.delete(observer);
10957observer.disconnect();
10958if (host) removeTextMirror(host);
10959continue;
10960}
10961observer.takeRecords();
10962healStuckHiddenTextMirror(host);
10963}
10964}
10965function healStuckHiddenTextMirror(host) {
10966const mirror = currentTextMirror(host);
10967if (!mirror || mirror.style.getPropertyValue("visibility") !== "hidden") return;
10968if (pageConcealsTextMirrorHostMemoized(host)) return;
10969syncTextMirrorVisibilityToPage(host, mirror);
10970}
10971function healTextMirrorPageVisibility() {
10972for (const [observer, hostRef] of liveTextMirrorObservers) {
10973const host = hostRef.deref();
10974if (!host || !host.isConnected) {
10975liveTextMirrorObservers.delete(observer);
10976observer.disconnect();
10977if (host) removeTextMirror(host);
10978continue;
10979}
10980healStuckHiddenTextMirror(host);
10981refreshConstrainedMirrorProjection(host);
10982}
10983}
10984function refreshConstrainedMirrorProjection(host) {
10985const mirror = currentTextMirror(host);
10986if (!mirror || mirror.dataset.yomuDetachedReadings !== "true") return;
10987const geometry = mirrorProjectionGeometrySignature(mirror);
10988if (settledMirrorProjectionGeometry.get(host) === geometry) return;
10989const clipRow = closestRubyFragileConstrainedRow(host);
10990if (clipRow && !clipRow.dataset.yomuClipConstrained) {
10991const decoration = host.closest("[data-yomu-decoration]")?.getAttribute("data-yomu-decoration");
10992clipRow.dataset.yomuClipConstrained = contentClipRowShowsRestReadings(decoration ?? void 0, clipRow) ? "content" : "true";
10993}
10994if (mirror.dataset.yomuSourceProjected !== "true") filterDetachedWordsToClip(mirror, clipRow);
10995projectAdditiveTextMirror(mirror, host);
10996settledMirrorProjectionGeometry.set(host, mirrorProjectionGeometrySignature(mirror));
10997}
10998function dispatchTextMirrorStale(host) {
10999host.dispatchEvent(new CustomEvent(NON_DESTRUCTIVE_SCAN_MIRROR_STALE_EVENT, {
11000bubbles: true
11001}));
11002}
11003const nonDestructiveRenderCache = new WeakMap();
11004let nonDestructiveRenderCacheEpoch = 0;
11005function rememberNonDestructiveRenderForReplay(host, target, planText, tokens, hostTextAtRender, settings2) {
11006nonDestructiveRenderCache.set(host, {
11007planText,
11008hostTextAtRender,
11009tokens,
11010settings: settings2,
11011decoration: target.decoration,
11012decorationProfileOverride: isFragmentTextTarget$1(target) ? target.decorationProfileOverride : void 0,
11013suppressRuby: target.suppressRuby,
11014passiveInteraction: target.passiveInteraction,
11015layoutSensitive: target.layoutSensitive,
11016insideShadowDOM: target.insideShadowDOM,
11017parserId: isFragmentTextTarget$1(target) ? target.parserId : void 0,
11018hadNativeRuby: targetHasNativeRuby(target),
11019epoch: nonDestructiveRenderCacheEpoch
11020});
11021}
11022function replayNonDestructiveRenderFromCache(host, allowCompatibleTextChange = false) {
11023const entry = nonDestructiveRenderCache.get(host);
11024if (!entry || entry.epoch !== nonDestructiveRenderCacheEpoch) return false;
11025if (entry.hadNativeRuby || !host.isConnected) return false;
11026const currentHostText = hostOriginalTextWithNodeOffsets(host).hostText;
11027const compatibleTextChange = currentHostText !== entry.hostTextAtRender && allowCompatibleTextChange && cachedTokenSurfacesRemainStable(entry, currentHostText);
11028if (currentHostText !== entry.hostTextAtRender && !compatibleTextChange) return false;
11029if (entry.decoration) {
11030const current = classifyDecoration(host);
11031if (entry.decorationProfileOverride ? current === "skip" : current !== entry.decoration) return false;
11032}
11033const target = {
11034text: compatibleTextChange ? currentHostText : entry.planText,
11035parent: host,
11036fragments: [],
11037decoration: entry.decoration,
11038decorationProfileOverride: entry.decorationProfileOverride,
11039suppressRuby: entry.suppressRuby,
11040passiveInteraction: entry.passiveInteraction,
11041layoutSensitive: entry.layoutSensitive,
11042insideShadowDOM: entry.insideShadowDOM,
11043parserId: entry.parserId,
11044nonDestructive: true
11045};
11046try {
11047withMirrorTokenApply(() => {
11048removeTextMirror(host);
11049stampTargetDecoration(target, host);
11050applyTokensToNonDestructiveScanTarget(target, entry.tokens, entry.settings);
11051});
11052} catch {
11053return false;
11054}
11055if (!currentTextMirror(host)) return false;
11056return true;
11057}
11058function cachedTokenSurfacesRemainStable(entry, currentHostText) {
11059if (!currentHostText || currentHostText === entry.hostTextAtRender) return false;
11060return entry.tokens.length > 0 && entry.tokens.every((token) => {
11061if (token.start < 0 || token.end <= token.start || token.end > currentHostText.length) return false;
11062return entry.planText.slice(token.start, token.end) === currentHostText.slice(token.start, token.end);
11063});
11064}
11065function mutationInsideTextMirror(mutation) {
11066const target = mutation.target instanceof Element ? mutation.target : mutation.target.parentElement;
11067return Boolean(target?.closest(READER_TEXT_MIRROR_SELECTOR));
11068}
11069function mutationsRewroteHostContent(mutations) {
11070return mutations.some((mutation) => {
11071if (mutationInsideTextMirror(mutation)) return false;
11072if (mutation.type === "characterData") return true;
11073return mutation.type === "childList" && Array.from(mutation.addedNodes).some((node) => !nodeIsReaderMirrorNode(node));
11074});
11075}
11076function nodeIsReaderMirrorNode(node) {
11077return node instanceof Element && Boolean(node.closest?.(READER_TEXT_MIRROR_SELECTOR));
11078}
11079function nativeTextMirrorHostText(host) {
11080let text2 = "";
11081const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
11082acceptNode(node) {
11083const parent = node.parentElement;
11084if (!parent || parent.closest(TEXT_MIRROR_NATIVE_TEXT_SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
11085return NodeFilter.FILTER_ACCEPT;
11086}
11087});
11088for (let node = walker.nextNode(); node; node = walker.nextNode()) text2 += node.textContent ?? "";
11089if (isTargetLanguageText(text2)) return text2;
11090const labelledText = Array.from(host.querySelectorAll("[aria-label]")).filter((element) => !element.closest(TEXT_MIRROR_ARIA_LABEL_SKIP_SELECTOR)).map((element) => element.getAttribute("aria-label") ?? "").join(" • ");
11091return isTargetLanguageText(labelledText) ? labelledText : text2;
11092}
11093function normalizedMirrorHostText(text2) {
11094return text2.replace(/\s+/g, " ").trim();
11095}
11096function removeTextMirror(host) {
11097const state = textMirrorHosts.get(host);
11098state?.observer.disconnect();
11099state?.lifecycle?.abort();
11100clearTimeout(state?.staleRemovalTimer);
11101if (state) state.staleRemovalTimer = void 0;
11102const owned = ownedTextMirrors(host);
11103owned.forEach((mirror) => {
11104unregisterDocumentAnnotationPortalMirror(mirror);
11105clearProjectedReadings(mirror);
11106mirror.remove();
11107});
11108const tracked = state?.mirror?.deref();
11109if (tracked && !owned.includes(tracked)) {
11110unregisterDocumentAnnotationPortalMirror(tracked);
11111clearProjectedReadings(tracked);
11112}
11113if (tracked?.isConnected) tracked.remove();
11114if (state) restoreTextMirrorHost(host, state);
11115textMirrorHosts.delete(host);
11116}
11117function syncTextMirrorVisibilityToPage(host, mirror, knownTopLayerConcealsPortal) {
11118const topLayerConcealsPortal = mirror.classList.contains(DOCUMENT_ANNOTATION_PORTAL_MIRROR_CLASS) && (knownTopLayerConcealsPortal ?? documentTopLayerConcealsPortal(host.ownerDocument));
11119if (topLayerConcealsPortal || pageConcealsTextMirrorHost(host)) {
11120mirror.style.setProperty("visibility", "hidden", "important");
11121} else mirror.style.removeProperty("visibility");
11122}
11123function documentTopLayerConcealsPortal(document2) {
11124if (document2.fullscreenElement) return true;
11125try {
11126return Array.from(document2.querySelectorAll(
11127':modal, :popover-open, [aria-modal="true"]'
11128)).some((element) => {
11129const style = safeComputedStyle(element);
11130return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && (style.opacity === "" || Number.parseFloat(style.opacity) !== 0);
11131});
11132} catch {
11133return false;
11134}
11135}
11136function pageConcealsTextMirrorHost(host) {
11137for (let element = host; element; element = composedAncestorElement(element)) {
11138const style = safeComputedStyle(element);
11139if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return true;
11140if (style.opacity !== "" && Number.parseFloat(style.opacity) === 0) return true;
11141if (style.contentVisibility === "hidden") return true;
11142}
11143return false;
11144}
11145const PAGE_CONCEAL_VERDICT_TTL_MS = 1e3;
11146const pageConcealVerdictMemo = new WeakMap();
11147function pageConcealsTextMirrorHostMemoized(host) {
11148const now = Date.now();
11149const memo = pageConcealVerdictMemo.get(host);
11150if (memo && now - memo.at < PAGE_CONCEAL_VERDICT_TTL_MS) return memo.concealed;
11151const concealed = pageConcealsTextMirrorHost(host);
11152pageConcealVerdictMemo.set(host, { at: now, concealed });
11153return concealed;
11154}
11155function reassertTextMirrorHostStyles(host, state) {
11156const mirror = currentTextMirror(host);
11157if (!mirror) {
11158removeTextMirror(host);
11159return;
11160}
11161if (state.documentPortal) {
11162styleDocumentAnnotationPortalMirror(mirror, host);
11163styleAdditiveMirrorPaint(mirror);
11164syncTextMirrorVisibilityToPage(host, mirror);
11165return;
11166}
11167syncTextMirrorVisibilityToPage(host, mirror);
11168if (state.positioned && host.style.getPropertyValue("position") !== "relative") {
11169host.style.setProperty("position", "relative", "important");
11170}
11171}
11172function restoreTextMirrorHost(host, state) {
11173if (state.positioned) restoreStyleProperty(host, "position", "relative", state.position, state.positionPriority);
11174if (state.reservedLineHeight) {
11175restoreStyleProperty(host, "line-height", state.reservedLineHeight, state.lineHeight, state.lineHeightPriority);
11176}
11177}
11178function restoreStyleProperty(host, property, injectedValue, value, priority2) {
11179const current = host.style.getPropertyValue(property);
11180if (current !== injectedValue || host.style.getPropertyPriority(property) !== "important") return;
11181if (value) host.style.setProperty(property, value, priority2);
11182else host.style.removeProperty(property);
11183}
11184function registeredTextMirrorHostFor(mirror) {
11185const owner = textMirrorOwners.get(mirror);
11186if (owner && textMirrorHosts.has(owner)) return owner;
11187let ancestor = mirror.parentElement;
11188while (ancestor) {
11189if (textMirrorHosts.has(ancestor)) return ancestor;
11190ancestor = ancestor.parentElement;
11191}
11192return null;
11193}
11194function removeNonDestructiveScanMirrors(root = document) {
11195nonDestructiveRenderCacheEpoch += 1;
11196const hosts = new Set();
11197const controlHosts = new Set();
11198const canvasHosts = new Set();
11199documentAnnotationPortalMirrorsWithin(root).forEach((mirror) => {
11200const host = registeredTextMirrorHostFor(mirror);
11201if (host) hosts.add(host);
11202else {
11203unregisterDocumentAnnotationPortalMirror(mirror);
11204clearProjectedReadings(mirror);
11205mirror.remove();
11206}
11207});
11208queryAllInAnnotationRoots(root, `${READER_TEXT_MIRROR_SELECTOR},${READER_CONTROL_TEXT_MIRROR_SELECTOR},${READER_CANVAS_TEXT_LAYER_SELECTOR}`).forEach((surface) => {
11209if (surface.matches(READER_TEXT_MIRROR_SELECTOR)) {
11210const host = registeredTextMirrorHostFor(surface) ?? surface.parentElement;
11211if (host) hosts.add(host);
11212else surface.remove();
11213} else if (surface.matches(READER_CONTROL_TEXT_MIRROR_SELECTOR)) {
11214const host = surface.previousElementSibling;
11215if (host instanceof HTMLElement) controlHosts.add(host);
11216else surface.remove();
11217} else {
11218const canvas = canvasForFallbackTextLayer(surface);
11219if (canvas) canvasHosts.add(canvas);
11220else surface.remove();
11221}
11222});
11223hosts.forEach(removeTextMirror);
11224controlHosts.forEach(removeControlTextMirror);
11225canvasHosts.forEach(removeCanvasFallbackTextLayer);
11226releaseRubyRoomGrowth(root);
11227return hosts.size + controlHosts.size + canvasHosts.size;
11228}
11229function queryAllInAnnotationRoots(root, selector) {
11230const matches = new Set();
11231const collect = (annotationRoot) => {
11232if (annotationRoot instanceof HTMLElement && annotationRoot.matches(selector)) matches.add(annotationRoot);
11233annotationRoot.querySelectorAll(selector).forEach((match) => matches.add(match));
11234};
11235collect(root);
11236forEachScannedShadowRoot((shadowRoot) => {
11237if (root === document || composedTreeContains(root, shadowRoot.host)) collect(shadowRoot);
11238}, true);
11239return [...matches];
11240}
11241function composedTreeContains(root, node) {
11242const rootNode = root;
11243let current = node;
11244while (current) {
11245if (current === rootNode || rootNode.contains(current)) return true;
11246const tree = current.getRootNode();
11247current = tree instanceof ShadowRoot ? tree.host : null;
11248}
11249return false;
11250}
11251function removeStaleControlTextMirrors(root = document) {
11252let removed = 0;
11253root.querySelectorAll(READER_CONTROL_TEXT_MIRROR_SELECTOR).forEach((mirror) => {
11254const host = mirror.previousElementSibling;
11255if (!(host instanceof HTMLElement)) {
11256mirror.remove();
11257removed += 1;
11258return;
11259}
11260if (isVisible(host)) return;
11261removeControlTextMirror(host);
11262removed += 1;
11263});
11264return removed;
11265}
11266function canvasForFallbackTextLayer(layer) {
11267const host = layer.parentElement;
11268if (!host) return null;
11269return Array.from(host.querySelectorAll("canvas")).find((canvas) => canvasFallbackTextLayers.get(canvas)?.layer === layer) ?? null;
11270}
11271function appendPlainTextBeforeToken(fragment, text2, start, end, followedByToken = false) {
11272if (end <= start) return;
11273const slice = text2.slice(start, end);
11274const digits = followedByToken ? TRAILING_DIGITS_RE.exec(slice)?.[0] : void 0;
11275if (!digits) {
11276fragment.append(document.createTextNode(slice));
11277return;
11278}
11279const prefix = slice.slice(0, slice.length - digits.length);
11280if (prefix) fragment.append(document.createTextNode(prefix));
11281const bind = document.createElement("span");
11282bind.className = NUMBER_BIND_CLASS;
11283bind.textContent = digits;
11284fragment.append(bind);
11285}
11286function markRenderedScanTarget(target) {
11287const text2 = normalizedRenderedHostText(target.text);
11288if (!text2 || !isTargetLanguageText(text2) || !target.parent.isConnected) return;
11289const previous = renderedScanHosts.get(target.parent);
11290const now = Date.now();
11291const keepBackoff = previous && previous.text === text2 && previous.lastRejectedAt !== void 0 && now - previous.lastRejectedAt < RENDERED_SCAN_HOST_REJECTION_RESET_MS;
11292renderedScanHosts.set(target.parent, {
11293text: text2,
11294markedAt: now,
11295lastRejectedAt: keepBackoff ? previous.lastRejectedAt : void 0,
11296rejectionCount: keepBackoff ? previous.rejectionCount : void 0
11297});
11298}
11299function readerRenderRejectionRescanDelay(mutation) {
11300const rejection = classifyReaderRenderRejection(mutation);
11301if (!rejection) return null;
11302if (rejection.duplicateInsert) {
11303replaceStaleReaderPaintWithAddedNodes(rejection.match.element);
11304loopingScanHosts.add(rejection.match.element);
11305} else if (rejection.repair) {
11306unwrapReaderWords(rejection.match.element);
11307}
11308return nextRenderedScanHostRescanDelay(rejection.match.host);
11309}
11310function classifyReaderRenderRejection(mutation) {
11311if (mutation.type !== "childList") return null;
11312const element = mutationTargetElement(mutation.target);
11313if (!element) return null;
11314const match = closestRenderedScanHost(element);
11315if (!match || Date.now() - match.host.markedAt > RENDERED_SCAN_HOST_REJECTION_WINDOW_MS) return null;
11316if (nodesContainReaderWord(mutation.removedNodes) && restoredHostTextMatches(match.element, match.host.text, mutation.addedNodes)) {
11317return { match, repair: false };
11318}
11319if (mutationTouchesReaderWordContent(mutation) && renderedHostContainsDamagedReaderWord(match.element)) {
11320return { match, repair: true };
11321}
11322if (elementIsFrameworkManaged(match.element) && addedNodesDuplicateHostSurface(mutation.addedNodes, match.host.text) && Boolean(match.element.querySelector(READER_WORD_SELECTOR$1))) {
11323return { match, repair: false, duplicateInsert: true };
11324}
11325return null;
11326}
11327function addedNodesDuplicateHostSurface(addedNodes, previousText) {
11328if (!previousText || nodesContainReaderWord(addedNodes)) return false;
11329if (previousText.length >= RENDERED_SCAN_HOST_MAX_TEXT) return false;
11330const addedText = normalizedRenderedHostText(Array.from(addedNodes, (node) => node.textContent ?? "").join(""));
11331return addedText.length >= previousText.length && addedText.includes(previousText);
11332}
11333function replaceStaleReaderPaintWithAddedNodes(host) {
11334host.querySelectorAll(READER_WORD_SELECTOR$1).forEach((word) => word.remove());
11335const staleTextNodes = [];
11336const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
11337for (let node = walker.nextNode(); node; node = walker.nextNode()) {
11338if (destructivePaintTextNodes.has(node)) staleTextNodes.push(node);
11339}
11340staleTextNodes.forEach((node) => node.remove());
11341}
11342function nextRenderedScanHostRescanDelay(host) {
11343const now = Date.now();
11344const previousCount = host.lastRejectedAt !== void 0 && now - host.lastRejectedAt < RENDERED_SCAN_HOST_REJECTION_RESET_MS ? host.rejectionCount ?? 0 : 0;
11345host.lastRejectedAt = now;
11346host.rejectionCount = previousCount + 1;
11347return RENDERED_SCAN_HOST_RESCAN_DELAYS_MS[Math.min(previousCount, RENDERED_SCAN_HOST_RESCAN_DELAYS_MS.length - 1)];
11348}
11349function closestRenderedScanHost(element) {
11350let current = null;
11351if (element instanceof HTMLElement) current = element;
11352else if (element.parentElement instanceof HTMLElement) current = element.parentElement;
11353while (current && current !== document.body && current !== document.documentElement) {
11354const host = renderedScanHosts.get(current);
11355if (host) return { element: current, host };
11356current = current.parentElement;
11357}
11358return null;
11359}
11360function restoredHostTextMatches(element, previousText, addedNodes) {
11361const currentText = normalizedRenderedHostSurfaceText(element);
11362if (textMatchesRenderedHost(currentText, previousText)) return true;
11363const addedText = normalizedRenderedHostText(Array.from(addedNodes, (node) => node.textContent ?? "").join(""));
11364return textMatchesRenderedHost(addedText, previousText);
11365}
11366function textMatchesRenderedHost(candidate, previousText) {
11367return Boolean(candidate && previousText && (candidate.includes(previousText) || previousText.includes(candidate)));
11368}
11369function normalizedRenderedHostText(text2) {
11370return text2.replace(/\s+/g, " ").trim().slice(0, RENDERED_SCAN_HOST_MAX_TEXT);
11371}
11372function normalizedRenderedHostSurfaceText(element) {
11373return normalizedRenderedHostText(readerWordSurfaceText(element) || element.textContent || "");
11374}
11375function nodesContainReaderWord(nodes) {
11376return Array.from(nodes).some(nodeContainsReaderWord);
11377}
11378function nodeContainsReaderWord(node) {
11379if (node instanceof Element) {
11380return node.matches(READER_WORD_SELECTOR$1) || Boolean(node.querySelector(READER_WORD_SELECTOR$1));
11381}
11382if (node instanceof DocumentFragment) return Boolean(node.querySelector(READER_WORD_SELECTOR$1));
11383return false;
11384}
11385function mutationTouchesReaderWordContent(mutation) {
11386const target = mutationTargetElement(mutation.target);
11387return Boolean(target?.closest(READER_WORD_SELECTOR$1)) || nodesContainReaderWordMarkup(mutation.removedNodes) || nodesContainReaderWordMarkup(mutation.addedNodes);
11388}
11389function nodesContainReaderWordMarkup(nodes) {
11390return Array.from(nodes).some(nodeContainsReaderWordMarkup);
11391}
11392function nodeContainsReaderWordMarkup(node) {
11393if (node.nodeType === Node.TEXT_NODE) return true;
11394if (!(node instanceof Element || node instanceof DocumentFragment)) return false;
11395const root = node;
11396return Boolean(root instanceof Element && root.matches(".jpdb-reader-ruby-base,.jpdb-reader-furi,ruby,rt,rp")) || Boolean(root.querySelector?.(".jpdb-reader-ruby-base,.jpdb-reader-furi,ruby,rt,rp"));
11397}
11398function renderedHostContainsDamagedReaderWord(host) {
11399return Array.from(host.querySelectorAll(READER_WORD_SELECTOR$1)).some(renderedWordLooksDamaged);
11400}
11401function renderedWordLooksDamaged(word) {
11402if (!word.classList.contains("jpdb-reader-has-furi")) return false;
11403const expected = normalizedRenderedHostText(word.dataset.surface ?? "");
11404const surface = normalizedRenderedHostText(readerWordDomSurfaceText(word));
11405if (expected && !textMatchesRenderedHost(surface, expected)) return true;
11406const hasFuri = Boolean(word.querySelector(".jpdb-reader-furi,rt"));
11407const hasBase = Boolean(word.querySelector(".jpdb-reader-ruby-base,rb"));
11408return hasFuri && (!hasBase || !surface);
11409}
11410function readerWordDomSurfaceText(element) {
11411let text2 = "";
11412element.childNodes.forEach((node) => {
11413if (node.nodeType === Node.TEXT_NODE) {
11414text2 += node.textContent ?? "";
11415return;
11416}
11417if (!(node instanceof Element) || node.matches("rt,rp")) return;
11418text2 += readerWordDomSurfaceText(node);
11419});
11420return text2;
11421}
11422function mutationTargetElement(target) {
11423if (target.nodeType === Node.ELEMENT_NODE) return target;
11424return target.parentElement;
11425}
11426function applyTokensToFragmentTarget(target, tokens, settings2) {
11427if (!hasFragmentTokenWork(target, tokens)) return;
11428const safeTokens = nonOverlappingTokens(tokens, target.text);
11429if (!safeTokens.length) return;
11430const sentence = target.text.replace(/\s+/g, " ").trim();
11431const renderTarget = target.decoration === "interactive-passive" && interactivePassiveControl(target.parent) ? { ...target, suppressRuby: true } : targetForcesAllFurigana(target.parent) ? { ...target, suppressRuby: false } : target;
11432{
11433const clipRow = closestRubyFragileConstrainedRow(target.parent);
11434if (clipRow) {
11435clipRow.dataset.yomuClipConstrained = contentClipRowShowsRestReadings(renderTarget.decoration, clipRow) || clampRowKeepsInFlowRestRuby(renderTarget.decoration, clipRow) ? "content" : "true";
11436}
11437}
11438applyTokensToIndexedFragmentTarget(renderTarget, safeTokens, furiganaSettingsForTarget(settings2, target.parent), sentence);
11439styleDetachedReadingElements(target.parent, target.parent);
11440stabilizeDetachedReadings(target.parent, closestRubyFragileConstrainedRow(target.parent));
11441markRenderedScanTarget(target);
11442}
11443function hasFragmentTokenWork(target, tokens) {
11444return Boolean(tokens.length && target.fragments.length);
11445}
11446function applyTokensToIndexedFragmentTarget(target, tokens, settings2, sentence) {
11447const indexedFragments = indexTextFragments(target.fragments);
11448const tokensWithSentence = tokens.map((token) => tokenWithReadableSentence(token, target.text, token.sentence ?? sentence));
11449const miningInsightKeys = miningInsightTokenKeys(tokensWithSentence);
11450const singleFragmentPlans = singleFragmentTokenPlans(target, indexedFragments, tokens, tokensWithSentence);
11451if (singleFragmentPlans.length === tokens.length) {
11452const grouped = groupSingleFragmentTokenPlans(singleFragmentPlans);
11453for (const group of grouped) replaceSingleFragmentTokenNode(target, group.fragment, group.plans, settings2, miningInsightKeys);
11454return;
11455}
11456for (let index = tokens.length - 1; index >= 0; index--) {
11457applyTokenToIndexedFragments(target, indexedFragments, tokens[index], tokensWithSentence[index] ?? tokens[index], settings2, miningInsightKeys);
11458}
11459}
11460function singleFragmentTokenPlans(target, indexedFragments, tokens, tokensWithSentence) {
11461const plans = [];
11462for (const [index, token] of tokens.entries()) {
11463const start = findFragmentBoundary(indexedFragments, token.start, "start");
11464const end = findFragmentBoundary(indexedFragments, token.end, "end");
11465const bounds = attachableFragmentRange(start, end);
11466if (!bounds || bounds.start.fragment !== bounds.end.fragment) continue;
11467plans.push({
11468fragment: bounds.start.fragment,
11469localStart: bounds.start.localOffset,
11470localEnd: bounds.end.localOffset,
11471token,
11472tokenWithSentence: tokensWithSentence[index] ?? token,
11473passiveInteraction: target.passiveInteraction === true || fragmentRangeHasPassiveInteraction(indexedFragments, token.start, token.end)
11474});
11475}
11476return plans;
11477}
11478function groupSingleFragmentTokenPlans(plans) {
11479const groups = [];
11480for (const plan of plans) {
11481let group = groups.find((candidate) => candidate.fragment === plan.fragment);
11482if (!group) {
11483group = { fragment: plan.fragment, plans: [] };
11484groups.push(group);
11485}
11486group.plans.push(plan);
11487}
11488groups.forEach((group) => group.plans.sort((a, b) => a.localStart - b.localStart));
11489return groups.sort((a, b) => b.fragment.globalStart - a.fragment.globalStart);
11490}
11491function replaceSingleFragmentTokenNode(target, fragment, plans, settings2, miningInsightKeys) {
11492if (!fragment.node.parentNode || !plans.length) return;
11493const replacement = document.createDocumentFragment();
11494const text2 = fragment.node.data;
11495let offset = fragment.start;
11496for (const plan of plans) {
11497appendPlainTextBeforeToken(replacement, text2, offset, plan.localStart, true);
11498replacement.append(renderSingleFragmentToken(target, fragment, plan, settings2, miningInsightKeys));
11499offset = plan.localEnd;
11500}
11501appendPlainTextBeforeToken(replacement, text2, offset, fragment.end);
11502replaceTextNodeRange(fragment.node, fragment.start, fragment.end, wrapDirectFlexGridTextRun(replacement, fragment.node.parentElement));
11503}
11504function wrapDirectFlexGridTextRun(replacement, parent) {
11505const display = parent ? safeComputedStyle(parent).display : "";
11506if (!display.includes("flex") && !display.includes("grid")) return replacement;
11507const fragment = document.createDocumentFragment();
11508const run = document.createElement("span");
11509run.append(replacement);
11510fragment.append(run);
11511return fragment;
11512}
11513function renderSingleFragmentToken(target, fragment, plan, settings2, miningInsightKeys) {
11514const allowRuby = scanFragmentAllowsRuby(fragment.hasNativeRuby);
11515return renderToken(fragment.node.data.slice(plan.localStart, plan.localEnd), plan.tokenWithSentence, settings2, {
11516allowRuby,
11517detachedReadings: targetUsesDetachedReadings(target),
11518kanjiNavigation: kanjiNavigationForElement(target.parent),
11519scanWord: true,
11520proseWrap: target.proseWrap === true,
11521passiveInteraction: plan.passiveInteraction,
11522preserveTokenRubies: true,
11523miningInsightKeys
11524});
11525}
11526function applyTokenToIndexedFragments(target, indexedFragments, token, tokenWithSentence, settings2, miningInsightKeys) {
11527const start = findFragmentBoundary(indexedFragments, token.start, "start");
11528const end = findFragmentBoundary(indexedFragments, token.end, "end");
11529const bounds = attachableFragmentRange(start, end);
11530if (!bounds) return;
11531const isSingleFragment = bounds.start.fragment === bounds.end.fragment;
11532const passiveInteraction = target.passiveInteraction === true || fragmentRangeHasPassiveInteraction(indexedFragments, token.start, token.end);
11533if (isSingleFragment) {
11534insertSingleFragmentToken(
11535target,
11536bounds.start.fragment,
11537bounds.start.localOffset,
11538bounds.end.localOffset,
11539token,
11540tokenWithSentence,
11541settings2,
11542miningInsightKeys,
11543passiveInteraction
11544);
11545return;
11546}
11547if (fragmentRangeHasNativeRuby(indexedFragments, token.start, token.end)) {
11548const nativeRubyRange = nativeRubyPreservingTokenRange(indexedFragments, bounds, token.start, token.end);
11549if (nativeRubyRange) {
11550insertMultiFragmentToken(nativeRubyRange, target.text.slice(token.start, token.end), tokenWithSentence, settings2, {
11551scanWord: true,
11552proseWrap: target.proseWrap === true,
11553passiveInteraction,
11554allowRuby: false,
11555preserveTokenRubies: true,
11556miningInsightKeys
11557});
11558nativeRubyRange.detach();
11559return;
11560}
11561insertSplitFragmentTokenPieces(
11562target,
11563splitFragmentTokenPieces(indexedFragments, token.start, token.end),
11564token,
11565tokenWithSentence,
11566settings2,
11567passiveInteraction,
11568miningInsightKeys
11569);
11570return;
11571}
11572const pieces = splitFragmentTokenPieces(indexedFragments, token.start, token.end);
11573if (shouldSplitFragmentTokenPieces(pieces)) {
11574insertSplitFragmentTokenPieces(target, pieces, token, tokenWithSentence, settings2, passiveInteraction, miningInsightKeys);
11575return;
11576}
11577const range = document.createRange();
11578range.setStart(bounds.start.fragment.node, bounds.start.localOffset);
11579range.setEnd(bounds.end.fragment.node, bounds.end.localOffset);
11580insertMultiFragmentToken(range, target.text.slice(token.start, token.end), tokenWithSentence, settings2, {
11581scanWord: true,
11582proseWrap: target.proseWrap === true,
11583passiveInteraction,
11584allowRuby: true,
11585detachedReadings: targetUsesDetachedReadings(target),
11586preserveTokenRubies: true,
11587miningInsightKeys
11588});
11589range.detach();
11590}
11591function insertSplitFragmentTokenPieces(target, pieces, token, tokenWithSentence, settings2, passiveInteraction, miningInsightKeys) {
11592for (const piece of [...pieces].reverse()) {
11593const surface = piece.fragment.node.data.slice(piece.start, piece.end);
11594if (!surface) continue;
11595const pieceToken = splitFragmentPieceToken(piece, token, tokenWithSentence);
11596const rendered = renderToken(surface, pieceToken, settings2, {
11597allowRuby: scanFragmentAllowsRuby(piece.fragment.hasNativeRuby),
11598detachedReadings: targetUsesDetachedReadings(target),
11599kanjiNavigation: kanjiNavigationForElement(target.parent),
11600scanWord: true,
11601proseWrap: target.proseWrap === true,
11602passiveInteraction,
11603preserveTokenRubies: true,
11604miningInsightKeys
11605});
11606replaceTextNodeRange(piece.fragment.node, piece.start, piece.end, rendered);
11607}
11608}
11609function splitFragmentTokenPieces(indexedFragments, tokenStart, tokenEnd) {
11610return indexedFragments.map((fragment) => splitFragmentTokenPiece(fragment, tokenStart, tokenEnd)).filter((piece) => piece !== null);
11611}
11612function shouldSplitFragmentTokenPieces(pieces) {
11613for (let index = 1; index < pieces.length; index++) {
11614if (!fragmentsShareInlineFlow(pieces[index - 1].fragment, pieces[index].fragment)) return true;
11615}
11616return false;
11617}
11618function fragmentsShareInlineFlow(previous, next) {
11619const common = commonElementAncestor(previous.node, next.node);
11620if (!common) return false;
11621const previousBoundary = childUnderAncestor(previous.node, common);
11622const nextBoundary = childUnderAncestor(next.node, common);
11623if (!previousBoundary || !nextBoundary || previousBoundary === nextBoundary) return true;
11624if (flowBoundaryNodeBreaksInline(previousBoundary) || flowBoundaryNodeBreaksInline(nextBoundary)) return false;
11625return !hasInlineFlowBreakBetween(common, previousBoundary, nextBoundary);
11626}
11627function commonElementAncestor(first, second) {
11628const firstAncestors = new Set();
11629for (let current = parentElementOf(first); current; current = current.parentElement) firstAncestors.add(current);
11630for (let current = parentElementOf(second); current; current = current.parentElement) {
11631if (firstAncestors.has(current)) return current;
11632}
11633return null;
11634}
11635function parentElementOf(node) {
11636return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
11637}
11638function childUnderAncestor(node, ancestor) {
11639let current = node;
11640while (current && current.parentNode !== ancestor) current = current.parentNode;
11641return current;
11642}
11643function flowBoundaryNodeBreaksInline(node) {
11644if (!(node instanceof HTMLElement)) return false;
11645if (node.tagName === "BR") return true;
11646return BLOCK_FLOW_TAG_NAMES.has(node.tagName);
11647}
11648function hasInlineFlowBreakBetween(parent, first, second) {
11649let seenFirst = false;
11650for (let current = parent.firstChild; current; current = current.nextSibling) {
11651if (current === first) {
11652seenFirst = true;
11653continue;
11654}
11655if (current === second) return false;
11656if (seenFirst && flowBoundaryNodeBreaksInline(current)) return true;
11657}
11658return false;
11659}
11660function splitFragmentPieceToken(piece, token, tokenWithSentence) {
11661const globalStart = piece.fragment.globalStart + piece.start - piece.fragment.start;
11662const globalEnd = piece.fragment.globalStart + piece.end - piece.fragment.start;
11663const rubies = tokenWithSentence.rubies.filter((ruby) => ruby.start >= globalStart && ruby.end <= globalEnd);
11664return {
11665...tokenWithSentence,
11666start: globalStart,
11667end: globalEnd,
11668length: globalEnd - globalStart,
11669rubies,
11670sentence: tokenWithSentence.sentence ?? token.sentence
11671};
11672}
11673function splitFragmentTokenPiece(fragment, tokenStart, tokenEnd) {
11674const start = Math.max(tokenStart, fragment.globalStart);
11675const end = Math.min(tokenEnd, fragment.globalEnd);
11676if (end <= start) return null;
11677return {
11678fragment,
11679start: fragment.start + start - fragment.globalStart,
11680end: fragment.start + end - fragment.globalStart
11681};
11682}
11683function fragmentRangeHasPassiveInteraction(fragments, start, end) {
11684return fragments.some((fragment) => fragment.passiveInteraction === true && fragment.globalStart < end && fragment.globalEnd > start);
11685}
11686function fragmentRangeHasNativeRuby(fragments, start, end) {
11687return fragments.some((fragment) => fragment.hasNativeRuby && fragment.globalStart < end && fragment.globalEnd > start);
11688}
11689function nativeRubyPreservingTokenRange(fragments, bounds, tokenStart, tokenEnd) {
11690const rubies = fullyCoveredNativeRubies(fragments, tokenStart, tokenEnd);
11691if (!rubies.size) return null;
11692const range = document.createRange();
11693setNativeRubyPreservingRangeStart(range, bounds.start, rubies);
11694setNativeRubyPreservingRangeEnd(range, bounds.end, rubies);
11695return range;
11696}
11697function fullyCoveredNativeRubies(fragments, start, end) {
11698const rubies = new Set();
11699for (const fragment of fragments) {
11700if (!fragment.hasNativeRuby || fragment.globalStart >= end || fragment.globalEnd <= start) continue;
11701const ruby = closestFragmentRuby(fragment);
11702if (!ruby) return new Set();
11703const span = nativeRubyBaseSpan(fragments, ruby);
11704if (!span || start > span.start || end < span.end) return new Set();
11705rubies.add(ruby);
11706}
11707return rubies;
11708}
11709function nativeRubyBaseSpan(fragments, ruby) {
11710const rubyFragments = fragments.filter((fragment) => closestFragmentRuby(fragment) === ruby);
11711if (!rubyFragments.length) return null;
11712return {
11713start: Math.min(...rubyFragments.map((fragment) => fragment.globalStart)),
11714end: Math.max(...rubyFragments.map((fragment) => fragment.globalEnd))
11715};
11716}
11717function setNativeRubyPreservingRangeStart(range, boundary, rubies) {
11718const ruby = closestFragmentRuby(boundary.fragment);
11719if (ruby && rubies.has(ruby)) {
11720range.setStartBefore(ruby);
11721return;
11722}
11723range.setStart(boundary.fragment.node, boundary.localOffset);
11724}
11725function setNativeRubyPreservingRangeEnd(range, boundary, rubies
11725) {
11726const ruby = closestFragmentRuby(boundary.fragment);
11727if (ruby && rubies.has(ruby)) {
11728range.setEndAfter(ruby);
11729return;
11730}
11731range.setEnd(boundary.fragment.node, boundary.localOffset);
11732}
11733function closestFragmentRuby(fragment) {
11734return fragment.node.parentElement?.closest("ruby") ?? null;
11735}
11736function attachableFragmentRange(start, end) {
11737const attachedStart = attachedFragmentBoundary(start);
11738const attachedEnd = attachedFragmentBoundary(end);
11739if (!attachedStart || !attachedEnd) return null;
11740return { start: attachedStart, end: attachedEnd };
11741}
11742function attachedFragmentBoundary(boundary) {
11743if (!boundary) return null;
11744if (!boundary.fragment.node.parentElement) return null;
11745return boundary;
11746}
11747function insertSingleFragmentToken(target, fragment, start, end, token, tokenWithSentence, settings2, miningInsightKeys, passiveInteraction) {
11748const allowRuby = scanFragmentAllowsRuby(fragment.hasNativeRuby);
11749const surface = fragment.node.data.slice(start, end);
11750const rendered = renderToken(surface || target.text.slice(token.start, token.end), tokenWithSentence, settings2, {
11751allowRuby,
11752detachedReadings: targetUsesDetachedReadings(target),
11753kanjiNavigation: kanjiNavigationForElement(target.parent),
11754scanWord: true,
11755proseWrap: target.proseWrap === true,
11756passiveInteraction,
11757preserveTokenRubies: true,
11758miningInsightKeys
11759});
11760replaceTextNodeRange(fragment.node, start, end, rendered);
11761}
11762function scanFragmentAllowsRuby(hasNativeRuby) {
11763return !hasNativeRuby;
11764}
11765function targetUsesDetachedReadings(target) {
11766if (isInsideOwnedReaderRoot(target.parent) && target.parent.closest("[data-provider-example-sentence]")) return Boolean(target.suppressRuby);
11767if (target.suppressRuby) return true;
11768const clipRow = closestRubyFragileConstrainedRow(target.parent);
11769if (!clipRow) return false;
11770return !clampRowKeepsInFlowRestRuby(target.decoration ?? decorationStateForWord(target.parent) ?? void 0, clipRow);
11771}
11772function clampRowKeepsInFlowRestRuby(decoration, clipRow) {
11773return !clampRowGrowthFailed(clipRow) && clampRowAllowsInFlowRestRuby(decoration, clipRow);
11774}
11775function isInsideOwnedReaderRoot(element) {
11776const readerRoot = element.closest(READER_ROOT_SELECTOR$3);
11777return Boolean(readerRoot && readerRoot !== document.body && readerRoot !== document.documentElement);
11778}
11779function replaceTextNodeRange(node, start, end, replacement) {
11780if (!node.parentNode || end <= start || start < 0 || end > node.data.length) return;
11781registerDestructivePaintTextNodes(replacement);
11782const after = node.splitText(end);
11783const selected = node.splitText(start);
11784selected.replaceWith(replacement);
11785if (!node.data) node.remove();
11786if (!after.data) after.remove();
11787}
11788function insertMultiFragmentToken(range, surface, token, settings2, options = {}) {
11789if (shouldRenderRuby(surface, token, settings2, options.allowRuby, options.preserveTokenRubies)) {
11790range.deleteContents();
11791range.insertNode(renderToken(surface, token, settings2, options));
11792return;
11793}
11794const shell = renderTokenShell(token, options);
11795shell.append(range.extractContents());
11796range.insertNode(shell);
11797}
11798function tokenWithReadableSentence(token, text2, fallback) {
11799const sentence = sentenceAroundRange(text2, token.start, token.end, fallback) || fallback || token.sentence;
11800return sentence === token.sentence ? token : { ...token, sentence };
11801}
11802function indexTextFragments(fragments) {
11803let globalOffset = 0;
11804return fragments.map((fragment) => {
11805const length = fragment.end - fragment.start;
11806const indexed = {
11807...fragment,
11808globalStart: globalOffset,
11809globalEnd: globalOffset + length
11810};
11811globalOffset += length;
11812return indexed;
11813});
11814}
11815function findFragmentBoundary(fragments, offset, side) {
11816for (const fragment of fragments) {
11817if (fragmentContainsBoundary(fragment, offset, side)) return fragmentBoundary(fragment, offset);
11818}
11819return edgeFragmentBoundary(fragments, offset, side);
11820}
11821function fragmentContainsBoundary(fragment, offset, side) {
11822return side === "start" ? offset >= fragment.globalStart && offset < fragment.globalEnd : offset > fragment.globalStart && offset <= fragment.globalEnd;
11823}
11824function fragmentBoundary(fragment, offset) {
11825return {
11826fragment,
11827localOffset: fragment.start + offset - fragment.globalStart
11828};
11829}
11830function edgeFragmentBoundary(fragments, offset, side) {
11831return side === "start" ? trailingEdgeFragmentBoundary(fragments, offset) : leadingEdgeFragmentBoundary(fragments, offset);
11832}
11833function trailingEdgeFragmentBoundary(fragments, offset) {
11834const fragment = fragments[fragments.length - 1];
11835return fragment && offset === fragment.globalEnd ? { fragment, localOffset: fragment.end } : null;
11836}
11837function leadingEdgeFragmentBoundary(fragments, offset) {
11838const fragment = fragments[0];
11839return fragment && offset === fragment.globalStart ? { fragment, localOffset: fragment.start } : null;
11840}
11841function renderToken(surface, token, settings2, options = {}) {
11842const span = createRenderedWordSpan(token, { ...options, showPitchAccent: settings2.showPitchAccent });
11843span.dataset.surface = surface;
11844prepareRenderedTokenShell(span, options);
11845renderTokenContent(span, surface, token, settings2, options);
11846return span;
11847}
11848function prepareRenderedTokenShell(span, options) {
11849if (options.detachedReadings) span.classList.add("jpdb-reader-detached-reading-word");
11850if ([!options.kanjiNavigation?.enabled, options.passiveInteraction !== true].every(Boolean)) span.tabIndex = -1;
11851}
11852function renderTokenContent(span, surface, token, settings2, options) {
11853const ruby = () => {
11854span.classList.add("jpdb-reader-has-furi");
11855setInnerHtml(span, renderRuby(surface, token, options.kanjiNavigation, options.preserveTokenRubies));
11856};
11857const handlers = {
11858"detached-ruby": () => {
11859span.classList.add("jpdb-reader-has-furi");
11860setInnerHtml(span, renderDetachedReadings(surface, token, options.kanjiNavigation, options.preserveTokenRubies));
11861},
11862ruby,
11863"kanji-navigation": () => setInnerHtml(span, renderKanjiNavigationText(surface, options.kanjiNavigation)),
11864text: () => {
11865span.textContent = surface;
11866}
11867};
11868handlers[renderedTokenContentMode(surface, token, settings2, options)]();
11869}
11870function renderedTokenContentMode(surface, token, settings2, options) {
11871const allowRuby = renderedTokenAllowsRuby(surface, token, options);
11872const hasRuby = shouldRenderRuby(surface, token, settings2, allowRuby, options.preserveTokenRubies);
11873const rubyMode = options.detachedReadings ? "detached-ruby" : "ruby";
11874const modes = [
11875[hasRuby, rubyMode],
11876[Boolean(options.kanjiNavigation?.enabled), "kanji-navigation"],
11877[true, "text"]
11878];
11879return modes.find(([matches]) => matches)[1];
11880}
11881function renderedTokenAllowsRuby(surface, token, options) {
11882if (options.allowRuby === false) return false;
11883return !shouldSuppressLongProseRuby(surface, token, options);
11884}
11885function shouldSuppressLongProseRuby(surface, token, options) {
11886if (!options.scanWord || !options.proseWrap || options.passiveInteraction) return false;
11887if (surface.length <= 16) return false;
11888const rubyLength = effectiveTokenRubies(surface, token, options.preserveTokenRubies).reduce((total, ruby) => total + ruby.text.length, 0);
11889return rubyLength > 20 || /[A-Za-z0-9]/.test(surface);
11890}
11891function renderTokenShell(token, options = {}) {
11892const span = createRenderedWordSpan(token, options);
11893if (options.passiveInteraction !== true) span.tabIndex = -1;
11894return span;
11895}
11896function replaceRenderedWordFurigana(word, surface, token) {
11897const mirror = word.closest(READER_TEXT_MIRROR_SELECTOR);
11898const detached = Boolean(mirror) || word.classList.contains("jpdb-reader-detached-reading-word");
11899const html = detached ? renderDetachedReadings(surface, token) : renderRuby(surface, token);
11900if (detached ? !html.includes("jpdb-reader-detached-furi") : !html.includes("<rt")) return false;
11901setInnerHtml(word, html);
11902word.classList.add("jpdb-reader-has-furi");
11903if (!detached) return true;
11904word.classList.add("jpdb-reader-detached-reading-word");
11905const renderSurface = mirror ?? word.parentElement ?? word;
11906const host = mirror ? registeredTextMirrorHostFor(mirror) ?? mirror.parentElement ?? word : word.parentElement ?? word;
11907const clipRow = closestRubyFragileConstrainedRow(host);
11908if (mirror) {
11909mirror.dataset.yomuDetachedReadings = "true";
11910styleConstrainedTextMirror(mirror, clipRow);
11911const sourceStart = Number.parseInt(word.dataset.yomuSourceStart ?? "", 10);
11912if (Number.isFinite(sourceStart)) stampProjectedRubySourceRanges(word, surface, token, sourceStart);
11913}
11914styleDetachedReadingElements(word, host);
11915if (mirror) {
11916scheduleCurrentTextMirrorProjection(host);
11917} else {
11918stabilizeDetachedReadings(renderSurface, clipRow);
11919}
11920return true;
11921}
11922function clearRenderedWordFurigana(word, surface) {
11923word.textContent = surface;
11924word.classList.remove("jpdb-reader-has-furi");
11925clearProjectedReadings(word);
11926const mirror = word.closest(READER_TEXT_MIRROR_SELECTOR);
11927if (!mirror) return;
11928const host = registeredTextMirrorHostFor(mirror) ?? mirror.parentElement ?? word;
11929const clipRow = closestRubyFragileConstrainedRow(host);
11930if (mirror.querySelector(".jpdb-reader-detached-furi")) {
11931stabilizeDetachedReadings(mirror, clipRow, true);
11932return;
11933}
11934delete mirror.dataset.yomuDetachedReadings;
11935clearProjectedReadings(mirror);
11936styleConstrainedTextMirror(mirror, clipRow);
11937}
11938function isVisible(element) {
11939const rect = element.getBoundingClientRect();
11940if (!isVisibleRect(rect)) return false;
11941const style = safeComputedStyle(element);
11942return isVisibleStyle(style);
11943}
11944function isVisibleRect(rect) {
11945return rect.width > 0 && rect.height > 0 && rect.bottom >= 0 && rect.top <= window.innerHeight && rect.right >= 0 && rect.left <= window.innerWidth;
11946}
11947function isVisibleStyle(style) {
11948return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity || "1") > 0;
11949}
11950function isParagraphBoundary(element) {
11951const display = safeComputedStyle(element).display;
11952if (element.tagName === "BR") return true;
11953if (isInlineDisplay(display)) return false;
11954if (BLOCK_TAGS.has(element.tagName)) return true;
11955return isBlockLikeDisplay(display);
11956}
11957function isInlineDisplay(display) {
11958return INLINE_DISPLAY_VALUES.has(display);
11959}
11960function isBlockLikeDisplay(display) {
11961return BLOCK_LIKE_DISPLAY_VALUES.has(display);
11962}
11963const INLINE_DISPLAY_VALUES = new Set(["inline", "contents", "inline-block", "inline-flex", "inline-grid"]);
11964const BLOCK_LIKE_DISPLAY_VALUES = new Set(["block", "flow-root", "grid", "list-item", "table", "table-row", "table-cell"]);
11965function isFragileUiText(element, text2) {
11966if (isReadablePrimaryDisplayHeadingText(element, text2)) return false;
11967return isFragileUiContext(element, text2);
11968}
11969function isGeometryFragileText(element, text2) {
11970if (isReadablePrimaryDisplayHeadingText(element, text2)) return false;
11971const metrics = fragileTextMetrics(element, text2);
11972if (fragileByTypography(element, metrics.style, metrics.compactLength, metrics.fontSize, metrics.lineHeight, metrics.prose)) return true;
11973if (fragileByCompactLayout(text2, metrics.style, metrics.rect)) return true;
11974if (isInsideMediaTextLink(element, text2)) return true;
11975return fragileByInlineControl(text2, metrics.style, metrics.rect);
11976}
11977function isShortCenteredDisplayHeading(element, text2) {
11978const heading = closestDisplayHeading(element);
11979if (!heading) return false;
11980const style = safeComputedStyle(heading);
11981const headingText = heading.textContent?.trim() || text2;
11982if (isReadablePrimaryDisplayHeadingText(element, text2)) return false;
11983return style.textAlign === "center" && compactLength(headingText) <= 40;
11984}
11985function closestDisplayHeading(element) {
11986return DISPLAY_HEADING_RE.test(element.tagName) ? element : element.closest(DISPLAY_HEADING_SELECTOR);
11987}
11988function isReadablePrimaryDisplayHeadingText(element, text2) {
11989const heading = closestDisplayHeading(element);
11990if (!heading || heading.tagName !== "H1") return false;
11991if (compactLength(text2) < 4) return false;
11992return isReadablePrimaryDisplayHeadingElement(heading);
11993}
11994function isReadablePrimaryDisplayHeadingElement(element) {
11995const heading = closestDisplayHeading(element);
11996if (!heading || heading.tagName !== "H1") return false;
11997if (heading.closest('header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]')) return false;
11998return Boolean(heading.closest('main, [role="main"]'));
11999}
12000function isFragileUiContext(element, text2) {
12001if (UI_CLASS_RE.test(String(element.className)) && !isReadableProseUiClassText(element, text2)) return true;
12002if (text2.length <= 4 && ancestorClassLooksLikeUi(element)) return true;
12003return isInsideControlLikeLink(element, text2);
12004}
12005function isReadableProseUiClassText(element, text2) {
12006if (compactLength(text2) < 8) return false;
12007return isLikelyProseElement(element) && Boolean(element.closest('article, main, [role="main"]'));
12008}
12009function fragileTextMetrics(element, text2) {
12010const style = safeComputedStyle(element);
12011const rect = element.getBoundingClientRect();
12012const compactLength2 = Array.from(text2.replace(/\s+/g, "")).length;
12013const fontSize = cssPixels(style.fontSize);
12014const lineHeight = cssPixels(style.lineHeight) || fontSize * 1.25;
12015return { style, rect, compactLength: compactLength2, fontSize, lineHeight, prose: isLikelyProseElement(element) };
12016}
12017function fragileByCompactLayout(text2, style, rect) {
12018if (!hasCompactLayoutShape(text2, rect)) return false;
12019return hasCompactLayoutAlignment(style);
12020}
12021function hasCompactLayoutShape(text2, rect) {
12022return rect.width > 0 && text2.length <= 12 && rect.width < 180;
12023}
12024function hasCompactLayoutAlignment(style) {
12025return style.textAlign === "center" || style.whiteSpace !== "normal";
12026}
12027function fragileByInlineControl(text2, style, rect) {
12028return text2.length <= 6 && hasUiBox(style) && hasInlineControlShape(style.display) && rect.width < 180;
12029}
12030function fragileByTypography(element, style, compactLength2, fontSize, lineHeight, prose) {
12031const centered = style.textAlign === "center";
12032const heading = DISPLAY_HEADING_RE.test(element.tagName);
12033if (!heading) return fragileCenteredNonProseTypography(style, centered, compactLength2, fontSize, prose);
12034if (isReadableArticleHeading(element, compactLength2)) return false;
12035if (fragileHeadingTypography(centered, compactLength2, fontSize, lineHeight)) return true;
12036return fragileCenteredNonProseTypography(style, centered, compactLength2, fontSize, prose);
12037}
12038function fragileHeadingTypography(centered, compactLength2, fontSize, lineHeight) {
12039return compactLength2 <= 40 && (centered || fontSize >= 18 || lineHeight <= fontSize * 1.35);
12040}
12041function fragileCenteredNonProseTypography(style, centered, compactLength2, fontSize, prose) {
12042if (!isCompactCenteredNonProse(prose, centered, compactLength2)) return false;
12043return hasProminentCenteredTypography(style, fontSize);
12044}
12045function isCompactCenteredNonProse(prose, centered, compactLength2) {
12046return !prose && centered && compactLength2 <= 30;
12047}
12048function hasProminentCenteredTypography(style, fontSize) {
12049return fontSize >= 17 || Number(style.fontWeight) >= 600;
12050}
12051function isReadableArticleHeading(element, compactLength2) {
12052return compactLength2 >= 4 && Boolean(element.closest('article, main, [role="main"]'));
12053}
12054function ancestorClassLooksLikeUi(element) {
12055let current = element;
12056while (current) {
12057if (current === document.body) break;
12058if (UI_CLASS_RE.test(String(current.className))) return true;
12059current = current.parentElement;
12060}
12061return false;
12062}
12063function isInsideControlLikeLink(element, text2) {
12064const link = element.closest("a[href]");
12065if (!link) return false;
12066if (isLikelyProseLink(link, element)) return false;
12067const iconOnlyMediaLink = linkHasControlMedia(link) && compactLength(text2) <= 2;
12068return [isExplicitControlLink(link), iconOnlyMediaLink, linkHasControlShape(link, text2)].some(Boolean);
12069}
12070function isInsideMediaTextLink(element, text2) {
12071const link = element.closest("a[href]");
12072if (!link || isLikelyProseLink(link, element)) return false;
12073return linkHasControlMedia(link) && compactLength(text2) > 2;
12074}
12075const RUBY_ROOM_HARD_SKIP_SELECTOR = "[data-yomu-youtube-filtered],[data-yomu-youtube-pending],[data-yomu-youtube-aria-hidden],.jpdb-youtube-filter-collapsed,.jpdb-youtube-pending";
12076const RUBY_ROOM_YOUTUBE_TEXT_BOX_SELECTOR = [
12077"ytd-comment-view-model #content-text",
12078"ytm-comment-renderer #content-text",
12079"ytd-watch-info-text",
12080"ytd-watch-metadata :is(h1,#title,#owner,#info,#info-strings,#info-container,#info-text,#metadata,#metadata-line,.ytContentMetadataViewModelMetadataRow,yt-video-metadata-carousel-view-model)",
12081".ytContentMetadataViewModelMetadataRow",
12082"ytd-transcript-segment-renderer :is(.segment-text,yt-formatted-string)",
12083"ytm-transcript-segment-renderer",
12084"ytm-slim-video-metadata-section-renderer :is(h1,#title,.slim-video-metadata-info)",
12085"ytm-expandable-video-description-body-renderer p",
12086"ytm-structured-description-content-renderer",
12087"ytd-rich-section-renderer :is(#title,h2)",
12088"ytd-rich-shelf-renderer :is(#title,h2)",
12089"ytd-rich-item-renderer :is(#video-title-link,#video-title,#metadata-line,ytd-channel-name)",
12090"ytd-video-renderer :is(#video-title,#metadata-line)",
12091":is(ytd-compact-video-renderer,ytd-watch-next-secondary-results-renderer) #video-title",
12092"yt-lockup-view-model :is(.ytLockupMetadataViewModelHeadingReset,.ytLockupMetadataViewModelTitle,.ytAttributedStringHost)",
12093"ytm-video-with-context-renderer .media-item-headline",
12094":is(ytm-shorts-lockup-view-model,ytm-shorts-lockup-view-model-v2) h3",
12095"grid-shelf-view-model h2",
12096"ytd-shelf-renderer :is(#title,h2)",
12097"ytd-grid-video-renderer :is(#video-title,#metadata-line)",
12098"yt-description-preview-view-model",
12099"yt-tab-shape",
12100"ytd-playlist-panel-video-renderer #video-title",
12101"ytd-playlist-video-renderer #video-title",
12102"ytd-playlist-header-renderer :is(#title,.metadata-wrapper)",
12103"ytd-video-renderer .metadata-snippet-text",
12104":is(ytd-channel-renderer,ytd-grid-channel-renderer) :is(#info,#description)",
12105'yt-live-chat-viewer-engagement-message-renderer :is(#content,#message,yt-formatted-string,a,button,[role="button"])',
12106'yt-live-chat-restricted-participation-renderer :is(#message,#subtext,yt-formatted-string,a,button,[role="button"])',
12107'yt-live-chat-banner-renderer :is(#message,#header,yt-formatted-string,a,button,[role="button"])',
12108'yt-live-chat-ticker-renderer :is(#text,#content,yt-formatted-string,a,button,[role="button"])'
12109].join(",");
12110const RUBY_ROOM_MAX_PX = 400;
12111const RUBY_ROOM_SHORT_ROW_MAX_PX = 96;
12112const RUBY_ROOM_SHORT_ROW_OVERFLOW_MAX_PX = 120;
12113const RUBY_ROOM_WRAPPED_MIRROR_MIN_DELTA_PX = 32;
12114const RUBY_ROOM_WRAPPED_MIRROR_MIN_HEIGHT_PX = 80;
12115const RUBY_ROOM_WRAPPED_MIRROR_SETTLE_BUFFER_PX = 8;
12116const RUBY_ROOM_SWEEP_MAX_PASSES = 3;
12117const CLAMP_GROWTH_ATTRIBUTE = "data-yomu-clamp-growth";
12118const CONTENT_CLIP_ROW_SELECTOR = '[data-yomu-clip-constrained="content"]';
12119const FAILED_CLAMP_ROW_SELECTOR = `[${CLAMP_GROWTH_ATTRIBUTE}="failed"]`;
12120const CLAMP_ROW_BASE_ESCAPE_PX = 2;
12121const CLAMP_ROW_RT_OVERLAP_SLOP_PX = 2;
12122const CLAMP_ROW_WIDTH_SLOP_PX = 4;
12123function clampRowGrowthFailed(clipRow) {
12124return clipRow.getAttribute(CLAMP_GROWTH_ATTRIBUTE) === "failed";
12125}
12126function measuredClampRowVerdict(row) {
12127const word = row.querySelector(".jpdb-reader-word.jpdb-reader-scan-word");
12128const rt = word?.querySelector("rt.jpdb-reader-furi");
12129if (!word || !rt) return "unmeasurable";
12130const rowRect = row.getBoundingClientRect();
12131if (!rowRect.height) return "unmeasurable";
12132const base = word.querySelector(".jpdb-reader-ruby-base") ?? word;
12133const baseRect = base.getBoundingClientRect();
12134if (!baseRect.height) return "unmeasurable";
12135const rtRect = rt.getBoundingClientRect();
12136const baseEscaped = baseRect.top >= rowRect.bottom - CLAMP_ROW_BASE_ESCAPE_PX || baseRect.bottom <= rowRect.top + CLAMP_ROW_BASE_ESCAPE_PX;
12137const rtOverlapsBase = rtRect.height > 0 && rtRect.bottom - baseRect.top > CLAMP_ROW_RT_OVERLAP_SLOP_PX;
12138const widthOverflows = row.clientWidth > 0 && row.scrollWidth > row.clientWidth + CLAMP_ROW_WIDTH_SLOP_PX;
12139if (baseEscaped || rtOverlapsBase || widthOverflows) return "broken";
12140if (rtRect.height > 0 && rtRect.bottom <= baseRect.top + CLAMP_ROW_RT_OVERLAP_SLOP_PX) return "healthy";
12141return "unmeasurable";
12142}
12143function healUngrowableInFlowClampRows(root = document) {
12144const rows = new Set();
12145if (root instanceof HTMLElement) {
12146const selfOrAncestor = root.closest(`${CONTENT_CLIP_ROW_SELECTOR},${FAILED_CLAMP_ROW_SELECTOR}`);
12147if (selfOrAncestor) rows.add(selfOrAncestor);
12148}
12149for (const row of root.querySelectorAll(CONTENT_CLIP_ROW_SELECTOR)) rows.add(row);
12150for (const row of root.querySelectorAll(FAILED_CLAMP_ROW_SELECTOR)) rows.add(row);
12151if (!rows.size) return 0;
12152const demote = [];
12153const promote = [];
12154for (const row of rows) {
12155if (row.classList.contains("jpdb-reader-text-mirror")) continue;
12156const verdict = measuredClampRowVerdict(row);
12157if (verdict === "broken") {
12158if (!clampRowGrowthFailed(row)) demote.push(row);
12159} else if (verdict === "healthy" && clampRowGrowthFailed(row)) {
12160promote.push(row);
12161}
12162}
12163for (const row of demote) {
12164row.setAttribute(CLAMP_GROWTH_ATTRIBUTE, "failed");
12165row.dataset.yomuClipConstrained = "true";
12166}
12167for (const row of promote) {
12168row.setAttribute(CLAMP_GROWTH_ATTRIBUTE, "ok");
12169row.dataset.yomuClipConstrained = "content";
12170}
12171return demote.length;
12172}
12173const WRAPPED_SCAN_WORD_ATTRIBUTE = "data-yomu-wrapped";
12174function refreshWrappedScanWordUnderlines(root = document) {
12175const words = root.querySelectorAll(".jpdb-reader-word.jpdb-reader-scan-word");
12176if (!words.length) return;
12177const wrapped = [];
12178const unwrapped = [];
12179for (const word of words) {
12180(scanWordSpansMultipleLines(word) ? wrapped : unwrapped).push(word);
12181}
12182for (const word of wrapped) word.setAttribute(WRAPPED_SCAN_WORD_ATTRIBUTE, "true");
12183for (const word of unwrapped) {
12184if (word.hasAttribute(WRAPPED_SCAN_WORD_ATTRIBUTE)) word.removeAttribute(WRAPPED_SCAN_WORD_ATTRIBUTE);
12185}
12186}
12187function scanWordSpansMultipleLines(word) {
12188const rects = Array.from(word.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0);
12189if (rects.length < 2) return false;
12190let minTop = Number.POSITIVE_INFINITY;
12191let maxTop = Number.NEGATIVE_INFINITY;
12192let maxHeight = 0;
12193for (const rect of rects) {
12194minTop = Math.min(minTop, rect.top);
12195maxTop = Math.max(maxTop, rect.top);
12196maxHeight = Math.max(maxHeight, rect.height);
12197}
12198return maxTop - minTop > maxHeight / 2;
12199}
12200function makeRoomForRubyInCroppedRows(root = document) {
12201const adjustedBoxes = new Set();
12202for (let pass = 0; pass < RUBY_ROOM_SWEEP_MAX_PASSES; pass += 1) {
12203if (!makeRoomForRubyInCroppedRowsOnce(root, adjustedBoxes)) break;
12204}
12205return adjustedBoxes.size;
12206}
12207function makeRoomForRubyInCroppedRowsOnce(root, adjustedBoxes) {
12208const decisions = new Map();
12209const words = root.querySelectorAll(".jpdb-reader-word");
12210for (const word of words) collectRubyRoomDecisions(word, adjustedBoxes, decisions);
12211return applyRubyRoomDecisions(decisions, adjustedBoxes);
12212}
12213function collectRubyRoomDecisions(word, adjustedBoxes, decisions) {
12214const candidate = rubyRoomWordCandidate(word);
12215if (!candidate) return;
12216for (const box of candidate.cropBoxes) {
12217const crop = rubyRoomCropPlan(box, candidate.mirror);
12218if (!crop) continue;
12219collectRubyRoomBoxDecisions(box, crop.curated, candidate.mirror, adjustedBoxes, decisions);
12220}
12221}
12222function rubyRoomWordCandidate(word) {
12223if (!word.querySelector("rt")) return null;
12224if (["interactive-passive", "skip"].includes(decorationStateForWord(word) ?? "")) return null;
12225if (word.closest("[data-yomu-clip-constrained]")) return null;
12226const mirror = word.closest(".jpdb-reader-text-mirror");
12227return { mirror, cropBoxes: rubyRoomEligibleCropBoxes(word, mirror) };
12228}
12229function rubyRoomCropPlan(box, mirror) {
12230const excluded = [
12231box.closest(RUBY_ROOM_HARD_SKIP_SELECTOR),
12232box.closest('[aria-hidden="true"],[hidden]')
12233].some(Boolean);
12234if (excluded) return null;
12235const curated = [isGoogleSearchRubyRoomTextBox(box), isYouTubeRubyRoomTextBox(box)].some(Boolean);
12236const cropsRuby = curated ? boxActuallyCrops(box, mirror) : genericRubyNeedsRoom(box, mirror);
12237return cropsRuby ? { curated } : null;
12238}
12239function collectRubyRoomBoxDecisions(box, curated, mirror, adjustedBoxes, decisions) {
12240for (const roomBox of rubyRoomBoxesForCroppedBox(box, curated, mirror)) {
12241const decision = rubyRoomDecisionForBox(roomBox, curated, mirror, adjustedBoxes);
12242if (!decision) continue;
12243recordBestRubyRoomDecision(decisions, roomBox, decision);
12244}
12245}
12246function rubyRoomDecisionForBox(roomBox, curated, mirror, adjustedBoxes) {
12247if (isClipConstrainedRow(roomBox)) return null;
12248if (participatesInTrackSizing(roomBox)) return null;
12249const measuredHeight = curated ? rubyRoomHeight(roomBox, mirror) : genericRubyRoomHeight(roomBox, mirror);
12250if (measuredHeight > RUBY_ROOM_MAX_PX) return null;
12251const topDeficit = adjustedBoxes.has(roomBox) ? 0 : rubyTopClearanceDeficit(roomBox, mirror);
12252const roomHeight = measuredHeight + topDeficit;
12253const previousHeightIsEnough = previousRubyRoomHeight(roomBox) >= roomHeight && topDeficit === 0;
12254return previousHeightIsEnough ? null : { roomHeight, topDeficit };
12255}
12256const TRACK_SIZED_DISPLAY_VALUES = new Set([
12257"grid",
12258"inline-grid",
12259"table",
12260"inline-table",
12261"table-row",
12262"table-row-group",
12263"table-header-group",
12264"table-footer-group",
12265"table-cell"
12266]);
12267function participatesInTrackSizing(box) {
12268if (TRACK_SIZED_DISPLAY_VALUES.has(safeComputedStyle(box).display)) return true;
12269const parent = box.parentElement;
12270return Boolean(parent && TRACK_SIZED_DISPLAY_VALUES.has(safeComputedStyle(parent).display));
12271}
12272function recordBestRubyRoomDecision(decisions, roomBox, decision) {
12273if ((decisions.get(roomBox)?.roomHeight ?? 0) >= decision.roomHeight) return;
12274decisions.set(roomBox, decision);
12275}
12276function applyRubyRoomDecisions(decisions, adjustedBoxes) {
12277let adjusted = 0;
12278for (const [box, { roomHeight, topDeficit }] of decisions) {
12279recordRubyRoomGrowth(box);
12280box.dataset.yomuRubyRoom = "true";
12281box.dataset.yomuRubyRoomHeight = String(roomHeight);
12282makeRoomForRubyInBox(box, safeComputedStyle(box), roomHeight, topDeficit);
12283recordRubyRoomGrowthWrite(box);
12284adjustedBoxes.add(box);
12285adjusted += 1;
12286}
12287return adjusted;
12288}
12289function rubyRoomEligibleCropBoxes(word, mirror) {
12290const cropBoxes = cropCapableBoxes(word.parentElement, mirror);
12291return cropBoxes.some(isClipConstrainedRow) ? [] : cropBoxes;
12292}
12293const rubyRoomGrowthRecords = new WeakMap();
12294function rubyRoomStyleSnapshot(box) {
12295return {
12296minHeight: box.style.getPropertyValue("min-height"),
12297minHeightPriority: box.style.getPropertyPriority("min-height"),
12298height: box.style.getPropertyValue("height"),
12299heightPriority: box.style.getPropertyPriority("height"),
12300maxHeight: box.style.getPropertyValue("max-height"),
12301maxHeightPriority: box.style.getPropertyPriority("max-height"),
12302paddingTop: box.style.getPropertyValue("padding-top"),
12303paddingTopPriority: box.style.getPropertyPriority("padding-top")
12304};
12305}
12306function recordRubyRoomGrowth(box) {
12307if (rubyRoomGrowthRecords.has(box)) return;
12308rubyRoomGrowthRecords.set(box, { before: rubyRoomStyleSnapshot(box) });
12309}
12310function recordRubyRoomGrowthWrite(box) {
12311const record2 = rubyRoomGrowthRecords.get(box);
12312if (record2) record2.written = rubyRoomStyleSnapshot(box);
12313}
12314function releaseRubyRoomGrowth(root = document) {
12315const boxes = queryAllInAnnotationRoots(root, "[data-yomu-ruby-room]");
12316for (const box of boxes) {
12317const record2 = rubyRoomGrowthRecords.get(box);
12318restoreRubyRoomProperty(box, "min-height", record2, (r) => [r.minHeight, r.minHeightPriority]);
12319restoreRubyRoomProperty(box, "height", record2, (r) => [r.height, r.heightPriority]);
12320restoreRubyRoomProperty(box, "max-height", record2, (r) => [r.maxHeight, r.maxHeightPriority]);
12321restoreRubyRoomProperty(box, "padding-top", record2, (r) => [r.paddingTop, r.paddingTopPriority]);
12322delete box.dataset.yomuRubyRoom;
12323delete box.dataset.yomuRubyRoomHeight;
12324delete box.dataset.yomuRubyRoomPadTop;
12325rubyRoomGrowthRecords.delete(box);
12326}
12327return boxes.length;
12328}
12329function restoreRubyRoomProperty(box, property, record2, pick) {
12330if (record2?.written) {
12331const [writtenValue, writtenPriority] = pick(record2.written);
12332const currentValue = box.style.getPropertyValue(property);
12333const currentPriority = box.style.getPropertyPriority(property);
12334if (currentValue !== writtenValue || currentPriority !== writtenPriority) return;
12335}
12336const [value, priority2] = record2 ? pick(record2.before) : ["", ""];
12337if (value) box.style.setProperty(property, value, priority2);
12338else box.style.removeProperty(property);
12339}
12340const RUBY_ROOM_TOP_CLEARANCE_PX = 1;
12341const RUBY_ROOM_TOP_PAD_MAX_PX = 24;
12342function rubyTopClearanceDeficit(box, mirror) {
12343const raw = rubyTopOverflowRaw(box, mirror);
12344if (raw <= 0 && !rubyTouchesBoxTop(box, mirror)) return 0;
12345if (raw <= -RUBY_ROOM_TOP_CLEARANCE_PX) return 0;
12346const applied = previousRubyRoomTopPad(box);
12347const deficit = Math.ceil(raw + RUBY_ROOM_TOP_CLEARANCE_PX);
12348return applied + deficit > RUBY_ROOM_TOP_PAD_MAX_PX ? 0 : deficit;
12349}
12350function previousRubyRoomTopPad(box) {
12351const value = Number(box.dataset.yomuRubyRoomPadTop ?? "");
12352return Number.isFinite(value) ? value : 0;
12353}
12354function rubyCropsBox(box, mirror) {
12355return rubyBottomOverflow(box, mirror) > 1 || rubyTouchesBoxTop(box, mirror) || rubyMirrorBlockOverflow(box, mirror) > 1;
12356}
12357function rubyTouchesBoxTop(box, mirror) {
12358const style = safeComputedStyle(box);
12359const lineHeight = cssPixels(style.lineHeight) || (cssPixels(style.fontSize) || 16) * 1.4;
12360if (!box.clientHeight || box.clientHeight > lineHeight * 1.8) return false;
12361return rubyTopOverflowRaw(box, mirror) > -RUBY_ROOM_TOP_CLEARANCE_PX;
12362}
12363function genericRubyNeedsRoom(box, mirror) {
12364return rubyCropsBox(box, mirror) || rubyLayoutOverflowsShortRow(box, mirror) || rubyOverflowsCompactClippedRow(box, mirror);
12365}
12366function rubyOverflowsCompactClippedRow(box, mirror) {
12367const clientHeight = box.clientHeight;
12368if (clientHeight <= 0 || clientHeight > RUBY_ROOM_SHORT_ROW_MAX_PX) return false;
12369if (isReadableProseContext(box)) return false;
12370if (isVisuallyHiddenBox(box)) return false;
12371if (!boxHasRubySignal(box, mirror)) return false;
12372const overflow = compactClippedRubyOverflow(box, mirror) - clientHeight;
12373return overflow > 2 && overflow <= RUBY_ROOM_SHORT_ROW_OVERFLOW_MAX_PX;
12374}
12375function ownedRubyMirrorFor(box, mirror) {
12376return mirror && box.contains(mirror) ? mirror : null;
12377}
12378function ownedRubyMirrorWithRuby(box, mirror) {
12379const owned = ownedRubyMirrorFor(box, mirror);
12380return owned?.dataset.jpdbReaderHasRuby === "true" ? owned : null;
12381}
12382function boxHasRubySignal(box, mirror) {
12383if (ownedRubyMirrorWithRuby(box, mirror)) return true;
12384const word = box.querySelector(".jpdb-reader-word rt");
12385return Boolean(word && !word.closest(".jpdb-reader-text-mirror"));
12386}
12387function isVisuallyHiddenBox(box) {
12388const visibility = safeComputedStyle(box).visibility;
12389return visibility === "hidden" || visibility === "collapse";
12390}
12391function compactClippedRubyOverflow(box, mirror) {
12392const owned = ownedRubyMirrorWithRuby(box, mirror);
12393return Math.max(box.scrollHeight, owned ? owned.scrollHeight : 0);
12394}
12395function genericRubyRoomHeight(box, mirror) {
12396const owned = ownedRubyMirrorWithRuby(box, mirror);
12397const shortRowHeight = rubyLayoutOverflowsShortRow(box, mirror) ? box.scrollHeight : 0;
12398const compactRowHeight = rubyOverflowsCompactClippedRow(box, mirror) ? c
12398ompactClippedRubyOverflow(box, mirror) : 0;
12399return Math.ceil(Math.max(
12400box.clientHeight + rubyBottomOverflow(box, mirror) + rubyTopOverflow(box, mirror),
12401owned ? owned.scrollHeight : 0,
12402shortRowHeight,
12403compactRowHeight
12404));
12405}
12406const RUBY_ROOM_GOOGLE_CONTROL_SELECTOR = ":is(a,button,[role=button])";
12407const RUBY_ROOM_GOOGLE_TEXT_BOX_SELECTOR = `:is(#botstuff,#bres,.MjjYud,[data-attrid]) ${RUBY_ROOM_GOOGLE_CONTROL_SELECTOR}`;
12408function isGoogleSearchRubyRoomTextBox(box) {
12409return /(^|\.)google\./i.test(location.hostname) && location.pathname === "/search" && (safeElementMatches$1(box, RUBY_ROOM_GOOGLE_TEXT_BOX_SELECTOR) || !!box.closest(RUBY_ROOM_GOOGLE_TEXT_BOX_SELECTOR));
12410}
12411function rubyRoomBoxesForCroppedBox(box, curated, mirror) {
12412if (!curated) return [box];
12413const boxes = [box];
12414const googleControl = googleSearchRubyRoomControl(box, mirror);
12415if (googleControl && googleControl !== box) boxes.push(googleControl);
12416return boxes;
12417}
12418function googleSearchRubyRoomControl(box, mirror) {
12419if (!/(^|\.)google\./i.test(location.hostname) || location.pathname !== "/search") return null;
12420const control = box.closest(RUBY_ROOM_GOOGLE_CONTROL_SELECTOR);
12421if (!control || !safeElementMatches$1(control, RUBY_ROOM_GOOGLE_TEXT_BOX_SELECTOR)) return null;
12422if (!ownedRubyMirrorWithRuby(control, mirror)) return null;
12423return control;
12424}
12425function isYouTubeRubyRoomTextBox(box) {
12426if (safeElementMatches$1(box, "yt-attributed-string,yt-formatted-string,.ytAttributedStringHost,.yt-core-attributed-string")) {
12427return safeElementMatches$1(box, "ytd-comment-view-model #content-text,ytm-comment-renderer #content-text");
12428}
12429return safeElementMatches$1(box, RUBY_ROOM_YOUTUBE_TEXT_BOX_SELECTOR) || !!box.closest(RUBY_ROOM_YOUTUBE_TEXT_BOX_SELECTOR);
12430}
12431function makeRoomForRubyInBox(box, style, roomHeight, topDeficit = 0) {
12432const contentHeight = `${roomHeight}px`;
12433if (topDeficit > 0) {
12434const applied = previousRubyRoomTopPad(box) + topDeficit;
12435box.dataset.yomuRubyRoomPadTop = String(applied);
12436box.style.setProperty("padding-top", `${(cssPixels(style.paddingTop) || 0) + topDeficit}px`, "important");
12437}
12438box.style.setProperty("min-height", contentHeight, "important");
12439if (hasLineClamp(style)) {
12440box.style.setProperty("max-height", "none", "important");
12441if (hasDefiniteCssSize(style.height)) box.style.setProperty("height", "auto", "important");
12442return;
12443}
12444if (hasDefiniteCssSize(style.height)) {
12445box.style.setProperty("height", contentHeight, "important");
12446}
12447if (hasDefiniteCssSize(style.maxHeight) || !hasDefiniteCssSize(style.height)) {
12448box.style.setProperty("max-height", contentHeight, "important");
12449}
12450}
12451function cropCapableBoxes(element, mirror) {
12452const boxes = [];
12453let fallback;
12454let current = element;
12455while (current && current !== document.body && current !== document.documentElement) {
12456if (current.dataset.jpdbReaderRoot) break;
12457if (boxStyleIsClipCapable(current) || rubyLayoutOverflowsShortRow(current, mirror)) {
12458boxes.push(current);
12459} else if (!fallback && isYouTubeRubyRoomTextBox(current) && ownedRubyMirrorFor(current, mirror)) {
12460fallback = current;
12461}
12462current = current.parentElement;
12463}
12464return boxes.length || !fallback ? boxes : [fallback];
12465}
12466const shortRowVerdicts = new WeakMap();
12467function rubyLayoutOverflowsShortRow(box, mirror) {
12468const now = Date.now();
12469const memo = shortRowVerdicts.get(box);
12470if (memo && now - memo.at < CONSTRAINED_ROW_VERDICT_TTL_MS) return memo.value;
12471const value = rubyLayoutOverflowsShortRowUncached(box, mirror);
12472shortRowVerdicts.set(box, { at: now, value });
12473return value;
12474}
12475function rubyLayoutOverflowsShortRowUncached(box, mirror) {
12476if (safeElementMatches$1(box, ".jpdb-reader-word,ruby,rt,.jpdb-reader-furi,.jpdb-reader-ruby-base")) return false;
12477const clientHeight = box.clientHeight;
12478if (clientHeight <= 0 || clientHeight > RUBY_ROOM_SHORT_ROW_MAX_PX) return false;
12479if (!boxHasRubySignal(box, mirror)) return false;
12480const overflow = box.scrollHeight - clientHeight;
12481if (overflow <= 2 || overflow > RUBY_ROOM_SHORT_ROW_OVERFLOW_MAX_PX) return false;
12482const style = safeComputedStyle(box);
12483if (hasClippedTextConstraint(style) || hasLineClamp(style) || isEllipsisTextRow(style)) return false;
12484return isShortRubyRowDisplay(style);
12485}
12486function isShortRubyRowDisplay(style) {
12487return style.display.includes("flex") || style.display.includes("grid") || style.display === "block" || style.display === "flow-root" || style.display === "list-item" || style.display === "table" || style.display === "table-row" || style.display === "table-cell" || style.display === "inline-block";
12488}
12489function boxActuallyCrops(box, mirror) {
12490return box.scrollHeight > box.clientHeight + 1 || rubyBottomOverflow(box, mirror) > 1 || rubyTouchesBoxTop(box, mirror) || rubyMirrorBlockOverflow(box, mirror) > 1;
12491}
12492function rubyRoomHeight(box, mirror) {
12493const owned = ownedRubyMirrorFor(box, mirror);
12494const mirrorHeight = owned ? owned.scrollHeight : 0;
12495const measuredHeight = Math.max(
12496box.scrollHeight,
12497box.clientHeight + rubyBottomOverflow(box, mirror) + rubyTopOverflow(box, mirror),
12498mirrorHeight
12499);
12500const wrappedMirror = mirrorHeight >= RUBY_ROOM_WRAPPED_MIRROR_MIN_HEIGHT_PX && mirrorHeight > box.clientHeight + RUBY_ROOM_WRAPPED_MIRROR_MIN_DELTA_PX;
12501return Math.ceil(measuredHeight + (wrappedMirror ? RUBY_ROOM_WRAPPED_MIRROR_SETTLE_BUFFER_PX : 0));
12502}
12503function rubyMirrorBlockOverflow(box, mirror) {
12504const owned = ownedRubyMirrorWithRuby(box, mirror);
12505if (!owned) return 0;
12506return Math.max(0, owned.scrollHeight - box.clientHeight);
12507}
12508function rubyTopOverflow(box, mirror) {
12509return Math.max(0, rubyTopOverflowRaw(box, mirror));
12510}
12511function rubyTopOverflowRaw(box, mirror) {
12512const boxRect = box.getBoundingClientRect();
12513let overflow = Number.NEGATIVE_INFINITY;
12514for (const ruby of box.querySelectorAll("ruby")) {
12515if (!rubyBelongsToBoxMeasurement(ruby, mirror)) continue;
12516const base = ruby.querySelector(".jpdb-reader-ruby-base") ?? ruby;
12517if (!baseVisibleInBox(base.getBoundingClientRect(), boxRect)) continue;
12518const rt = ruby.querySelector("rt");
12519if (!rt) continue;
12520overflow = Math.max(overflow, boxRect.top - rt.getBoundingClientRect().top);
12521}
12522return overflow;
12523}
12524function rubyBelongsToBoxMeasurement(ruby, mirror) {
12525const owner = ruby.closest(".jpdb-reader-text-mirror");
12526return !owner || owner === mirror;
12527}
12528function rubyBottomOverflow(box, mirror) {
12529const boxRect = box.getBoundingClientRect();
12530let overflow = 0;
12531for (const ruby of box.querySelectorAll("ruby")) {
12532if (!rubyBelongsToBoxMeasurement(ruby, mirror)) continue;
12533const base = ruby.querySelector(".jpdb-reader-ruby-base") ?? ruby;
12534const baseRect = base.getBoundingClientRect();
12535if (!baseVisibleInBox(baseRect, boxRect)) continue;
12536overflow = Math.max(overflow, baseRect.bottom - boxRect.bottom);
12537}
12538return Math.max(0, overflow);
12539}
12540function previousRubyRoomHeight(box) {
12541const value = Number(box.dataset.yomuRubyRoomHeight ?? "");
12542return Number.isFinite(value) ? value : 0;
12543}
12544function baseVisibleInBox(baseRect, boxRect) {
12545return baseRect.bottom > boxRect.top + 1 && baseRect.top < boxRect.bottom - 1;
12546}
12547class DisabledAudioPlayer {
12548clearCaches() {
12549}
12550play() {
12551return Promise.resolve(false);
12552}
12553primeUserGestureIfUnprimed() {
12554return false;
12555}
12556primeUserGesture() {
12557return false;
12558}
12559preload() {
12560return false;
12561}
12562stop() {
12563}
12564destroy() {
12565}
12566playJapaneseText() {
12567return Promise.resolve();
12568}
12569playJpdbAudio() {
12570return Promise.resolve(false);
12571}
12572playMediaUrl() {
12573return Promise.resolve(false);
12574}
12575playMediaCandidates() {
12576return Promise.resolve(false);
12577}
12578}
12579const CompanionBackedAudioPlayer = class {
12580constructor(getSettings) {
12581const Player = yomuAudioCompanion()?.AudioPlayer;
12582return Player ? new Player(getSettings) : new DisabledAudioPlayer();
12583}
12584};
12585function userscriptRequestCandidates() {
12586const candidates = [];
12587const add = (request, thisArg) => {
12588candidates.push({ request, thisArg });
12589};
12590const direct = directUserscriptGlobals();
12591add(direct.GM_xmlhttpRequest, globalThis);
12592add(direct.GM?.xmlHttpRequest, direct.GM);
12593add(direct.GM?.xmlhttpRequest, direct.GM);
12594for (const source of userscriptRequestSources()) {
12595add(readSourceProperty(source, "GM_xmlhttpRequest"), source);
12596const gm = readSourceProperty(source, "GM");
12597add(readSourceProperty(gm, "xmlHttpRequest"), gm);
12598add(readSourceProperty(gm, "xmlhttpRequest"), gm);
12599}
12600return candidates;
12601}
12602function asUserscriptRequest(value) {
12603return typeof value === "function" ? value : void 0;
12604}
12605function isPromiseLike$2(value) {
12606return Boolean(value) && typeof value.then === "function";
12607}
12608function directUserscriptGlobals() {
12609return {
12610GM_xmlhttpRequest: typeof GM_xmlhttpRequest === "function" ? GM_xmlhttpRequest : void 0,
12611GM: typeof GM === "object" && GM ? GM : void 0
12612};
12613}
12614function userscriptRequestSources() {
12615const sources = [];
12616const seen = new Set();
12617const add = (value) => {
12618if (!isRequestSource(value) || seen.has(value)) return;
12619seen.add(value);
12620sources.push(value);
12621};
12622for (const mounted of mountedMonkeyWindows()) add(mounted);
12623add(globalThis);
12624if (typeof window !== "undefined") add(window);
12625return sources;
12626}
12627function mountedMonkeyWindows() {
12628if (typeof document === "undefined") return [];
12629return Object.getOwnPropertyNames(document).filter((key) => key.startsWith("__monkeyWindow-")).map((key) => readSourceProperty(document, key)).filter(isRequestSource);
12630}
12631function isRequestSource(value) {
12632return Boolean(value) && (typeof value === "object" || typeof value === "function");
12633}
12634function readSourceProperty(source, key) {
12635if (!isRequestSource(source)) return void 0;
12636try {
12637return source[key];
12638} catch {
12639return void 0;
12640}
12641}
12642const BRIDGE_REQUEST_EVENT = "yomu-userscript-http-request";
12643const BRIDGE_RESPONSE_EVENT = "yomu-userscript-http-response";
12644const BRIDGE_PROBE_EVENT = "yomu-userscript-http-probe";
12645const BRIDGE_PROBE_RESPONSE_EVENT = "yomu-userscript-http-probe-response";
12646const BRIDGE_MARKER = "yomuUserscriptHttpBridge";
12647const BRIDGE_KEYS = { ready: BRIDGE_MARKER, owner: "yomuHttpBridgeOwner", kind: "yomuHttpBridgeKind" };
12648const BRIDGE_TIMEOUT_MS = 3e4;
12649const BRIDGE_READY_TIMEOUT_MS = 1e4;
12650const USERSCRIPT_EVENT_BRIDGE_PROBE_TIMEOUT_MS = 120;
12651let bridgeListenerCleanup;
12652let bridgeOwnerId;
12653let clientOwner;
12654let eventBridgeProbeInFlight;
12655function getUserscriptHttpRequest() {
12656for (const candidate of userscriptRequestCandidates()) {
12657const request = asUserscriptRequest(candidate.request);
12658if (request) {
12659return request.bind(candidate.thisArg);
12660}
12661}
12662return userscriptHttpEventBridge();
12663}
12664function installUserscriptHttpBridge() {
12665if (typeof window === "undefined" || typeof document === "undefined") return;
12666if (!shouldInstallUserscriptHttpBridge()) return;
12667const bridgeCandidate = userscriptRequestCandidates().map((candidate) => ({ candidate, request: asUserscriptRequest(candidate.request) })).find((item) => item.request);
12668if (!bridgeCandidate?.request) return;
12669const markerDataset = bridgeMarkerDataset();
12670if (!markerDataset) return;
12671const kind = detectInstalledReaderRuntime() === "extension" ? "extension" : "userscript";
12672if (!mayClaimBridge(markerDataset, BRIDGE_KEYS, kind, bridgeOwnerId)) return;
12673if (hasInstalledUserscriptHttpBridge(markerDataset)) {
12674dispatchUserscriptBridgeReady();
12675return;
12676}
12677bridgeListenerCleanup?.();
12678bridgeListenerCleanup = void 0;
12679const request = bridgeCandidate.request.bind(bridgeCandidate.candidate.thisArg);
12680const handledRequestIds = new Set();
12681const ownerId = createBridgeOwnerId("yomu-http");
12682bridgeOwnerId = ownerId;
12683markerDataset[BRIDGE_KEYS.owner] = ownerId;
12684markerDataset[BRIDGE_KEYS.kind] = kind;
12685markerDataset[BRIDGE_MARKER] = "true";
12686const addressedHere = (event) => bridgeRequestAddressedTo(bridgeMarkerDataset(), BRIDGE_KEYS, ownerId, bridgeEventOwnerId(event));
12687const requestCleanup = addBridgeEventListener(BRIDGE_REQUEST_EVENT, (event) => {
12688const detail = bridgeRequestDetail(event);
12689if (!detail || !addressedHere(event)) return;
12690if (handledRequestIds.has(detail.id)) return;
12691rememberBridgeRequestId(handledRequestIds, detail.id);
12692const send = (kind2, response, message) => {
12693dispatchBridgeEvent(BRIDGE_RESPONSE_EVENT, bridgeResponseDetail(detail.id, kind2, response, message));
12694};
12695const options = {
12696...bridgeRequestOptions(detail.options),
12697onload: (response) => send("load", response),
12698onerror: (error) => send("error", void 0, error instanceof Error ? error.message : String(error || "Request failed.")),
12699ontimeout: () => send("timeout", void 0, "Request timed out.")
12700};
12701try {
12702const result = request(options);
12703if (isPromiseLike$2(result)) {
12704result.then((response) => send("load", response), (error) => send("error", void 0, error instanceof Error ? error.message : String(error || "Request failed.")));
12705}
12706} catch (error) {
12707send("error", void 0, error instanceof Error ? error.message : String(error || "Request failed."));
12708}
12709});
12710const probeCleanup = addBridgeEventListener(BRIDGE_PROBE_EVENT, (event) => {
12711const id = bridgeEventId(event);
12712if (id && addressedHere(event)) dispatchBridgeEvent(BRIDGE_PROBE_RESPONSE_EVENT, { id });
12713});
12714bridgeListenerCleanup = () => {
12715requestCleanup();
12716probeCleanup();
12717};
12718dispatchUserscriptBridgeReady();
12719}
12720function installUserscriptHttpBridgeWhenReady() {
12721installUserscriptHttpBridge();
12722if (typeof window === "undefined" || typeof document === "undefined") return;
12723if (!shouldInstallUserscriptHttpBridge()) return;
12724if (hasInstalledUserscriptHttpBridge()) return;
12725scheduleUserscriptHttpBridgeRetry();
12726}
12727function shouldInstallUserscriptHttpBridge() {
12728try {
12729return typeof location !== "undefined" && isYomuPrivilegedHostedAppUrl(location.href);
12730} catch {
12731return false;
12732}
12733}
12734function scheduleUserscriptHttpBridgeRetry() {
12735const retry = () => {
12736if (hasInstalledUserscriptHttpBridge()) return;
12737installUserscriptHttpBridge();
12738};
12739if (typeof queueMicrotask === "function") {
12740queueMicrotask(retry);
12741} else {
12742void Promise.resolve().then(retry);
12743}
12744window.setTimeout(retry, 0);
12745window.setTimeout(retry, 250);
12746if (document.readyState === "loading") {
12747document.addEventListener("DOMContentLoaded", retry, { once: true });
12748}
12749}
12750function hasInstalledUserscriptHttpBridge(markerDataset = bridgeMarkerDataset()) {
12751return Boolean(markerDataset?.[BRIDGE_MARKER] === "true" && bridgeListenerCleanup);
12752}
12753function dispatchUserscriptBridgeReady() {
12754dispatchBridgeEvent(USERSCRIPT_HTTP_BRIDGE_READY_EVENT);
12755}
12756const EVENT_BRIDGE_TAG = Symbol.for("yomu.userscriptEventBridge");
12757function isUserscriptEventBridgeRequest(request) {
12758return typeof request === "function" && request[EVENT_BRIDGE_TAG] === true;
12759}
12760function probeUserscriptEventBridge(request) {
12761if (!isUserscriptEventBridgeRequest(request)) return Promise.resolve(true);
12762if (typeof window === "undefined" || typeof document === "undefined") return Promise.resolve(false);
12763if (eventBridgeProbeInFlight) return eventBridgeProbeInFlight;
12764const current = currentHttpBridgeOwner();
12765const probe = current !== void 0 ? current ? probeHttpBridgeOwner(current) : Promise.resolve(false) : httpBridgeOwner().then((owner) => owner ? probeHttpBridgeOwner(owner) : false);
12766eventBridgeProbeInFlight = probe;
12767void probe.then(() => {
12768if (eventBridgeProbeInFlight === probe) eventBridgeProbeInFlight = void 0;
12769});
12770return probe;
12771}
12772function probeHttpBridgeOwner(owner) {
12773return new Promise((resolve) => {
12774const id = `yomu-probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
12775let settled = false;
12776let responseCleanup = noop;
12777const finish = (alive) => {
12778if (settled) return;
12779settled = true;
12780window.clearTimeout(timeout);
12781responseCleanup();
12782if (!alive) {
12783const markerDataset = bridgeMarkerDataset();
12784if (markerDataset?.[BRIDGE_MARKER] === "true") delete markerDataset[BRIDGE_MARKER];
12785}
12786resolve(alive);
12787};
12788const timeout = window.setTimeout(() => finish(false), USERSCRIPT_EVENT_BRIDGE_PROBE_TIMEOUT_MS);
12789responseCleanup = addBridgeEventListener(BRIDGE_PROBE_RESPONSE_EVENT, (event) => {
12790if (bridgeEventId(event) === id) finish(true);
12791});
12792dispatchBridgeEvent(BRIDGE_PROBE_EVENT, { id, ownerId: owner.ownerId });
12793});
12794}
12795function httpBridgeOwner() {
12796const current = currentHttpBridgeOwner();
12797if (current !== void 0) return Promise.resolve(current);
12798return new Promise((resolve) => {
12799const cleanups = [];
12800const settle = (owner) => {
12801for (const cleanup of cleanups.splice(0)) cleanup();
12802resolve(owner);
12803};
12804const recheck = () => {
12805const owner = currentHttpBridgeOwner();
12806if (owner !== void 0) settle(owner);
12807};
12808const timeout = window.setTimeout(() => settle(null), BRIDGE_READY_TIMEOUT_MS);
12809cleanups.push(() => window.clearTimeout(timeout));
12810cleanups.push(addBridgeEventListener(USERSCRIPT_HTTP_BRIDGE_READY_EVENT, recheck));
12811cleanups.push(addBridgeEventListener(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, () => {
12812const later = window.setTimeout(recheck, 0);
12813cleanups.push(() => window.clearTimeout(later));
12814}));
12815});
12816}
12817function currentHttpBridgeOwner() {
12818const dataset = bridgeMarkerDataset();
12819if (clientOwner) {
12820return dataset?.[BRIDGE_MARKER] === "true" && dataset[BRIDGE_KEYS.owner] === clientOwner.ownerId ? clientOwner : null;
12821}
12822const expected = expectedHttpBridgeKind();
12823const owner = readyBridgeOwner(dataset, BRIDGE_KEYS, expected);
12824if (owner?.ownerId) clientOwner = owner;
12825if (owner) return owner;
12826return expected && !installedStorageResponderReady() ? void 0 : null;
12827}
12828function expectedHttpBridgeKind() {
12829return expectedBridgeKind(shouldInstallUserscriptHttpBridge);
12830}
12831function userscriptHttpEventBridge() {
12832if (typeof window === "undefined" || typeof document === "undefined") return void 0;
12833if (currentHttpBridgeOwner() === null) return void 0;
12834return tagEventBridgeRequest((options) => new Promise((resolve, reject) => {
12835const id = `yomu-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
12836const timeout = window.setTimeout(() => {
12837cleanup();
12838options.ontimeout?.();
12839reject(new Error("Request timed out."));
12840}, options.timeout ?? BRIDGE_TIMEOUT_MS);
12841let cleanupBridgeResponseListener = noop;
12842const cleanup = () => {
12843window.clearTimeout(timeout);
12844cleanupBridgeResponseListener();
12845};
12846const onResponse = (event) => {
12847handleBridgeResponseEvent(event, id, options, cleanup, resolve, reject);
12848};
12849void httpBridgeOwner().then((owner) => {
12850if (!owner) {
12851cleanup();
12852const error = new Error("Installed Yomu request bridge is unavailable; reload to reconnect.");
12853options.onerror?.(error);
12854reject(error);
12855return;
12856}
12857cleanupBridgeResponseListener = addBridgeEventListener(BRIDGE_RESPONSE_EVENT, onResponse);
12858const { onload: _onload, onerror: _onerror, ontimeout: _ontimeout, ...requestOptions } = options;
12859dispatchBridgeEvent(BRIDGE_REQUEST_EVENT, { id, ownerId: owner.ownerId, options: requestOptions });
12860});
12861}));
12862}
12863function tagEventBridgeRequest(request) {
12864request[EVENT_BRIDGE_TAG] = true;
12865return request;
12866}
12867function handleBridgeResponseEvent(event, id, options, cleanup, resolve, reject) {
12868const detail = bridgeResponseEventDetail(event);
12869if (!detail || detail.id !== id) return;
12870cleanup();
12871if (detail.kind === "load" && detail.response) {
12872options.onload?.(detail.response);
12873resolve(detail.response);
12874return;
12875}
12876rejectBridgeResponse(detail, options, reject);
12877}
12878function rejectBridgeResponse(detail, options, reject) {
12879const message = detail.message || "Request failed.";
12880if (detail.kind === "timeout") options.ontimeout?.();
12881else options.onerror?.(new Error(message));
12882reject(new Error(message));
12883}
12884function addBridgeEventListener(type, listener) {
12885const cleanups = [];
12886if (addWindowEventListener(type, listener)) {
12887cleanups.push(() => removeWindowEventListener(type, listener));
12888}
12889const documentTarget = bridgeDocumentTarget();
12890if (documentTarget && callAddEventListener(documentTarget, type, listener)) {
12891cleanups.push(() => callRemoveEventListener(documentTarget, type, listener));
12892}
12893return () => {
12894for (const cleanup of cleanups) cleanup();
12895};
12896}
12897function dispatchBridgeEvent(type, detail) {
12898const eventDetail = bridgeEventDetail(detail);
12899let dispatched = dispatchWindowEvent(createWindowCustomEvent(type, eventDetail));
12900const documentTarget = bridgeDocumentTarget();
12901if (documentTarget) {
12902dispatched = callDispatchEvent(documentTarget, createWindowCustomEvent(type, eventDetail)) || dispatched;
12903}
12904return dispatched;
12905}
12906function bridgeDocumentTarget() {
12907if (typeof document === "undefined") return void 0;
12908return document.documentElement instanceof HTMLElement ? document.documentElement : void 0;
12909}
12910function bridgeMarkerDataset() {
12911if (typeof document === "undefined") return void 0;
12912const root = document.documentElement;
12913return root?.dataset;
12914}
12915function callAddEventListener(target, type, listener) {
12916try {
12917target.addEventListener(type, listener);
12918return true;
12919} catch {
12920return false;
12921}
12922}
12923function callRemoveEventListener(target, type, listener) {
12924try {
12925target.removeEventListener(type, listener);
12926} catch {
12927}
12928}
12929function callDispatchEvent(target, event) {
12930try {
12931return target.dispatchEvent(event);
12932} catch {
12933return false;
12934}
12935}
12936function rememberBridgeRequestId(ids, id) {
12937ids.add(id);
12938if (ids.size <= 100) return;
12939const oldest = ids.values().next().value;
12940if (oldest) ids.delete(oldest);
12941}
12942function noop() {
12943}
12944function requestViaUserscriptManager(request, config) {
12945return new Promise((resolve, reject) => {
12946const signal = config.signal;
12947if (signal?.aborted) {
12948reject(abortReason(config));
12949return;
12950}
12951let handle;
12952let aborted = false;
12953const tryAbort = () => {
12954if (aborted) return;
12955aborted = true;
12956try {
12957handle?.abort?.();
12958} catch {
12959}
12960};
12961let settled = false;
12962let deadline;
12963const finish = (settle) => {
12964if (settled) return;
12965settled = true;
12966if (deadline !== void 0) clearTimeout(deadline);
12967if (signal) signal.removeEventListener("abort", onAbort);
12968try {
12969settle();
12970} catch (error) {
12971reject(error);
12972}
12973};
12974const handleLoad = (response) => finish(() => {
12975resolve(config.readResponse(response));
12976});
12977const handleError = (error) => finish(() => reject(errorReason(config, error)));
12978const handleTimeout = () => {
12979finish(() => reject(timeoutReason(config)));
12980tryAbort();
12981};
12982const onAbort = () => {
12983finish(() => reject(abortReason(config)));
12984tryAbort();
12985};
12986if (signal) signal.addEventListener("abort", onAbort, { once: true });
12987deadline = setTimeout(handleTimeout, localDeadlineMs(config));
12988const reportProgress = config.details.onprogress;
12989const onprogress = reportProgress === void 0 ? void 0 : (event) => {
12990if (!settled) {
12991if (deadline !== void 0) clearTimeout(deadline);
12992deadline = setTimeout(handleTimeout, localDeadlineMs(config));
12993}
12994reportProgress(event);
12995};
12996try {
12997const result = request({
12998...config.details,
12999...onprogress === void 0 ? {} : { onprogress },
13000onload: handleLoad,
13001onerror: handleError,
13002ontimeout: handleTimeout
13003});
13004if (result && typeof result.abort === "function") {
13005handle = result;
13006}
13007if (isPromiseLike$2(result)) result.then(handleLoad, handleError);
13008} catch (error) {
13009handleError(error);
13010}
13011});
13012}
13013const DROPPED_CALLBACK_DEADLINE_MS = 12e4;
13014function localDeadlineMs(config) {
13015const budget = config.deadlineMs ?? config.details.timeout;
13016return budget && budget > 0 ? budget : DROPPED_CALLBACK_DEADLINE_MS;
13017}
13018function errorReason(config, error) {
13019if (config.onError) return config.onError(error);
13020return error instanceof Error ? error : new Error("Request failed.");
13021}
13022function timeoutReason(config) {
13023return config.onTimeout ? config.onTimeout() : new Error("Request timed out.");
13024}
13025function abortReason(config) {
13026if (config.onAbort) return config.onAbort();
13027if (typeof DOMException === "function") return new DOMException("Aborted", "AbortError");
13028const error = new Error("Aborted");
13029error.name = "AbortError";
13030return error;
13031}
13032const ANKI_NEVER_FORGET_TAG = "yomu-never-forget";
13033class AnkiConnectClient {
13034constructor(getSettings) {
13035const companion = yomuAnkiCompanion();
13036if (!companion) return new DisabledAnkiConnectClient();
13037const Client = companion.AnkiConnectClient;
13038return new Client(getSettings);
13039}
13040}
13041class DisabledAnkiConnectClient {
13042destroy() {
13043}
13044clearAccountContext() {
13045}
13046isConnected = ankiFalse;
13047isAvailableForBackground = ankiFalse;
13048deckNames = ankiEmptyStrings;
13049modelNames = ankiEmptyStrings;
13050noteFieldTargetPlan = ankiNull;
13051scanLibrary = ankiEmptyLibrary;
13052yomuModelUpdatePlan = ankiNull;
13053addMissingYomuModelFields = ankiEmptyStrings;
13054warmStatusIndex = ankiNull;
13055findExistingCards = ankiUntrustedLookup;
13056findCachedStatusBatch = ankiUntrustedLookupBatch;
13057findExistingCardsBatch = ankiUntrustedLookupBatch;
13058rebuildStatusIndex = ankiNull;
13059answerCard = ankiUnavailable;
13060setCardsSuspended = ankiUnavailable;
13061setNotesTag = ankiUnavailable;
13062browseNote = ankiUnavailable;
13063mediaFileDataUrl = ankiUnavailable;
13064mergeYomuData = ankiUnavailable;
13065addCard = ankiUnavailable;
13066addCardViaMobileHandoff = ankiUnavailable;
13067ensureDeckAndModel = ankiUnavailable;
13068invoke = ankiUnavailable;
13069}
13070const ankiFalse = () => Promise.resolve(false);
13071const ankiNull = () => Promise.resolve(null);
13072const ankiEmptyStrings = () => Promise.resolve([]);
13073const ankiEmptyLibrary = () => Promise.resolve({ deckNames: [], models: [], suggestedModel: null });
13074const ankiUntrustedLookup = () => Promise.resolve(untrustedAnkiLookupResult());
13075const ankiUntrustedLookupBatch = (cards) => Promise.resolve(cards.map(() => untrustedAnkiLookupResult()));
13076const ankiUnavailable = () => Promise.reject(new Error("Yomu Anki unavailable."));
13077class AnkiDuplicateNoteError extends Error {
13078constructor(message) {
13079super(message);
13080this.name = "AnkiDuplicateNoteError";
13081}
13082}
13083function isAnkiDuplicateNoteError(error) {
13084return error instanceof AnkiDuplicateNoteError || yomuAnkiCompanion()?.isAnkiDuplicateNoteError(error) || error instanceof Error && error.name === "AnkiDuplicateNoteError" || false;
13085}
13086function ankiLookupWithUnavailableDetails(lookup) {
13087return yomuAnkiCompanion()?.ankiLookupWithUnavailableDetails(lookup) ?? localAnkiLookupWithUnavailableDetails(lookup);
13088}
13089function untrustedAnkiLookupResult() {
13090return { state: "not-in-deck", notes: [], primary: null, trusted: false };
13091}
13092function canUseMobileAnkiHandoff(settings2) {
13093return yomuAnkiCompanion()?.canUseMobileAnkiHandoff(settings2) ?? false;
13094}
13095function mobileAnkiHandoffAppName() {
13096return yomuAnkiCompanion()?.mobileAnkiHandoffAppName() ?? "AnkiMobile";
13097}
13098function captureActiveVideoFrame() {
13099return yomuAnkiCompanion()?.captureActiveVideoFrame();
13100}
13101async function resolveAnkiWordAudio(card, settings2) {
13102return yomuAnkiCompanion()?.resolveAnkiWordAudio(card, settings2) ?? null;
13103}
13104function localAnkiLookupWithUnavailableDetails(lookup) {
13105const mark = (note) => ankiNoteHasRenderableDetails(note) ? note : { ...note, detailsUnavailable: true };
13106const notes = lookup.notes.map(mark);
13107const primary = lookup.primary ? mark(lookup.primary) : null;
13108return { ...lookup, notes, primary };
13109}
13110function ankiNoteHasRenderableDetails(note) {
13111if (note.renderedCards?.some((card) => card.question.trim() || card.answer.trim())) return true;
13112return Object.values(note.fields).some((value) => value.trim());
13113}
13114function renderAnkiActionRow(...args) {
13115return yomuAnkiCompanion()?.renderAnkiActionRow(...args) ?? "";
13116}
13117function renderAnkiExistingSection(...args) {
13118return yomuAnkiCompanion()?.renderAnkiExistingSection(...args) ?? "";
13119}
13120function renderAnkiNewCardPreview(...args) {
13121return yomuAnkiCompanion()?.renderAnkiNewCardPreview(...args) ?? "";
13122}
13123function renderReviewButtons(...args) {
13124return yomuAnkiCompanion()?.renderReviewButtons(...args) ?? "";
13125}
13126function isAppleTouchBrowser() {
13127if (typeof navigator === "undefined") return false;
13128const userAgent = navigator.userAgent ?? "";
13129const platform = navigator.platform ?? "";
13130return /iPad|iPhone|iPod/i.test(userAgent) || (platform === "MacIntel" || /Mac/i.test(platform)) && (navigator.maxTouchPoints ?? 0) > 1 && (/Macintosh|Mac OS X/i.test(userAgent) || platform === "MacIntel");
13131}
13132const SCALE_EPSILON = 0.05;
13133const MAX_PAGE_SCALE = 3;
13134const SAFARI_PAGE_ZOOM_STEPS = [1.15, 1.25, 1.5, 1.75, 2, 2.5, 3];
13135const ZOOM_STEP_TOLERANCE = 0.025;
13136const MIN_AXIS_AGREEMENT = 0.97;
13137const MAX_AXIS_AGREEMENT = 1.4;
13138const APPLE_TOUCH_ADAPTER = "apple-touch-page-scale";
13139const rememberedRectScales = new WeakMap();
13140function overlayPageScale(environment) {
13141if (!environment.appleTouch) return 1;
13142if (!positiveFinite(environment.innerWidth)) return 1;
13143if (positiveFinite(environment.outerWidth)) {
13144const surfaceScale = environment.outerWidth / environment.innerWidth;
13145if (Number.isFinite(surfaceScale) && surfaceScale > 1 + SCALE_EPSILON) {
13146return Math.min(surfaceScale, MAX_PAGE_SCALE);
13147}
13148}
13149return screenDerivedPageScale(environment);
13150}
13151function screenDerivedPageScale(environment) {
13152const { innerWidth, innerHeight, screenWidth, screenHeight } = environment;
13153if (!positiveFinite(innerHeight) || !positiveFinite(screenWidth) || !positiveFinite(screenHeight)) return 1;
13154const pairings = [
13155[screenWidth / innerWidth, screenHeight / innerHeight],
13156[screenHeight / innerWidth, screenWidth / innerHeight]
13157];
13158for (const [widthRatio, heightRatio] of pairings) {
13159if (widthRatio <= 1 + SCALE_EPSILON) continue;
13160const step = nearestSafariZoomStep(widthRatio);
13161if (step === void 0) continue;
13162if (heightRatio < widthRatio * MIN_AXIS_AGREEMENT) continue;
13163if (heightRatio > widthRatio * MAX_AXIS_AGREEMENT) continue;
13164return step;
13165}
13166return 1;
13167}
13168function nearestSafariZoomStep(ratio) {
13169for (const step of SAFARI_PAGE_ZOOM_STEPS) {
13170if (Math.abs(ratio - step) <= step * ZOOM_STEP_TOLERANCE) return step;
13171}
13172return void 0;
13173}
13174function overlayViewport(environment = currentEnvironment()) {
13175const pageScale = overlayPageScale(environment);
13176return {
13177width: environment.innerWidth * pageScale,
13178height: environment.innerHeight * pageScale,
13179pageScale
13180};
13181}
13182function overlayViewportBounds(environment = currentEnvironment(), visualViewport = currentVisualViewport()) {
13183const pageScale = overlayPageScale(environment);
13184const width = positiveFinite(visualViewport?.width) ? visualViewport.width : environment.innerWidth;
13185const height = positiveFinite(visualViewport?.height) ? visualViewport.height : environment.innerHeight;
13186const left = finiteCoordinate(visualViewport?.offsetLeft) * pageScale;
13187const top = finiteCoordinate(visualViewport?.offsetTop) * pageScale;
13188const scaledWidth = width * pageScale;
13189const scaledHeight = height * pageScale;
13190return {
13191left,
13192top,
13193right: left + scaledWidth,
13194bottom: top + scaledHeight,
13195width: scaledWidth,
13196height: scaledHeight,
13197pageScale
13198};
13199}
13200function overlayViewportBottomInset(environment = currentEnvironment(), visualViewport = currentVisualViewport()) {
13201const fullViewport = overlayViewport(environment);
13202const visibleBounds = overlayViewportBounds(environment, visualViewport);
13203return Math.max(0, fullViewport.height - visibleBounds.bottom);
13204}
13205function layoutPointToOverlay(point, pageScale = overlayViewport().pageScale) {
13206return {
13207x: point.x * pageScale,
13208y: point.y * pageScale
13209};
13210}
13211function layoutRectToOverlay(rect, pageScale = overlayViewport().pageScale) {
13212return new DOMRect(
13213rect.left * pageScale,
13214rect.top * pageScale,
13215rect.width * pageScale,
13216rect.height * pageScale
13217);
13218}
13219function rememberOverlaySourceRect(rect, source, pageScale = overlayViewport().pageScale) {
13220const root = compensatedOverlayRoot(source);
13221if (root) rememberedRectScales.set(rect, compensatedRootRectScale(root, pageScale));
13222return rect;
13223}
13224function sourceRectToOverlay(rect, source, pageScale = overlayViewport().pageScale) {
13225const rememberedScale = rememberedRectScales.get(rect);
13226const root = compensatedOverlayRoot(source);
13227const rectScale = rememberedScale ?? (root ? compensatedRootRectScale(root, pageScale) : pageScale);
13228const overlayRect = scaleRect(rect, rectScale);
13229rememberedRectScales.set(overlayRect, 1);
13230return overlayRect;
13231}
13232function compensatedOverlayRoot(source) {
13233const element = source instanceof Element ? source : source?.parentElement;
13234const root = element?.closest(`[data-jpdb-reader-scale-adapter="${APPLE_TOUCH_ADAPTER}"]`);
13235return root instanceof HTMLElement ? root : null;
13236}
13237function applyOverlayPageScale(element, environment = currentEnvironment()) {
13238const pageScale = overlayPageScale(environment);
13239if (pageScale === 1) {
13240clearOwnedScale(element);
13241return pageScale;
13242}
13243const inverseScale = 1 / pageScale;
13244element.style.setProperty("zoom", formatScale(inverseScale), "important");
13245element.dataset.jpdbReaderScaleAdapter = APPLE_TOUCH_ADAPTER;
13246element.dataset.jpdbReaderPageScale = formatScale(pageScale);
13247element.dataset.jpdbReaderScaleCompensation = formatScale(inverseScale);
13248return pageScale;
13249}
13250function hasOverlayPageScale(element) {
13251return element?.dataset.jpdbReaderScaleAdapter === APPLE_TOUCH_ADAPTER;
13252}
13253function currentEnvironment() {
13254return {
13255appleTouch: isAppleTouchBrowser(),
13256innerWidth: window.innerWidth,
13257innerHeight: window.innerHeight,
13258outerWidth: window.outerWidth,
13259screenWidth: window.screen?.width ?? 0,
13260screenHeight: window.screen?.height ?? 0
13261};
13262}
13263function currentVisualViewport() {
13264const viewport = window.visualViewport;
13265return viewport ? {
13266width: viewport.width,
13267height: viewport.height,
13268offsetLeft: viewport.offsetLeft,
13269offsetTop: viewport.offsetTop
13270} : void 0;
13271}
13272function clearOwnedScale(element) {
13273if (!hasOverlayPageScale(element)) return;
13274element.style.removeProperty("zoom");
13275delete element.dataset.jpdbReaderScaleAdapter;
13276delete element.dataset.jpdbReaderPageScale;
13277delete element.dataset.jpdbReaderScaleCompensation;
13278}
13279function compensatedRootRectScale(root, pageScale = overlayViewport().pageScale) {
13280if (pageScale === 1) return 1;
13281const rect = root.getBoundingClientRect();
13282const ratios = [
13283dimensionRatio(rect.width, root.offsetWidth),
13284dimensionRatio(rect.height, root.offsetHeight)
13285].filter((ratio) => ratio !== void 0);
13286if (!ratios.length) return 1;
13287const measuredScale = ratios.reduce((sum, ratio) => sum + ratio, 0) / ratios.length;
13288const inverseScale = Number.parseFloat(root.dataset.jpdbReaderScaleCompensation ?? "") || 1 / pageScale;
13289return Math.abs(measuredScale - inverseScale) < Math.abs(measuredScale - 1) ? pageScale : 1;
13290}
13291function dimensionRatio(rectSize, offsetSize) {
13292if (!positiveFinite(rectSize) || !positiveFinite(offsetSize)) return void 0;
13293return rectSize / offsetSize;
13294}
13295function scaleRect(rect, scale) {
13296return new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale);
13297}
13298function positiveFinite(value) {
13299return typeof value === "number" && Number.isFinite(value) && value > 0;
13300}
13301function finiteCoordinate(value) {
13302return Number.isFinite(value) ? value ?? 0 : 0;
13303}
13304function formatScale(value) {
13305return String(Number(value.toFixed(6)));
13306}
13307const DEFAULT_POPOVER_WRITING_MODE = "horizontal-tb";
13308const SUPPORTED_POPOVER_WRITING_MODES = new Set([
13309"horizontal-tb",
13310"vertical-rl",
13311"vertical-lr",
13312"sideways-rl",
13313"sideways-lr"
13314]);
13315function pauseActiveVideo() {
13316const videos = Array.from(document.querySelectorAll("video"));
13317const playable = videos.filter((video) => video.readyState > 0).sort((a, b) => {
13318const aArea = a.getBoundingClientRect().width * a.getBoundingClientRect().height;
13319const bArea = b.getBoundingClientRect().width * b.getBoundingClientRect().height;
13320return Number(a.paused) - Number(b.paused) || bArea - aArea;
13321});
13322const target = playable[0];
13323if (!target || target.paused) return void 0;
13324target.pause();
13325return target;
13326}
13327function hasVisiblePageVideo() {
13328return Array.from(document.querySelectorAll("video")).some(isVisiblePageVideo);
13329}
13330function isVisiblePageVideo(video) {
13331if (video.closest("[data-jpdb-reader-root]")) return false;
13332if (!hasRenderableVideoRect(video)) return false;
13333if (isVideoHidden(video)) return false;
13334return isAudiblyPlayingVideo(video);
13335}
13336function hasRenderableVideoRect(video) {
13337const rect = video.getBoundingClientRect();
13338return rect.width >= 120 && rect.height >= 90;
13339}
13340function isVideoHidden(video) {
13341const style = getComputedStyle(video);
13342return style.display === "none" || style.visibility === "hidden" || style.opacity === "0";
13343}
13344function isAudiblyPlayingVideo(video) {
13345return !video.paused && !video.ended && !video.muted && video.volume > 0;
13346}
13347function isEditableTarget(target) {
13348const element = target instanceof Element ? target : null;
13349if (!element) return false;
13350if (element.closest("input, textarea, select")) return true;
13351const editable = element.closest("[contenteditable]");
13352return Boolean(editable && editable.getAttribute("contenteditable")?.toLowerCase() !== "false");
13353}
13354function isEditableElement(element) {
13355if (element.matches?.("input, textarea, select")) return true;
13356const editable = element.closest?.("[contenteditable]");
13357return Boolean(editable && editable.getAttribute("contenteditable")?.toLowerCase() !== "false");
13358}
13359function isEditableEventContext(event) {
13360if (isEditableTarget(event.target)) return true;
13361const path = typeof event.composedPath === "function" ? event.composedPath() : [];
13362return path.some((node) => node instanceof Element && isEditableElement(node));
13363}
13364async function copyText(text2) {
13365if (navigator.clipboard?.writeText) {
13366try {
13367await navigator.clipboard.writeText(text2);
13368return;
13369} catch {
13370}
13371}
13372const textarea = document.createElement("textarea");
13373textarea.value = text2;
13374textarea.style.position = "fixed";
13375textarea.style.opacity = "0";
13376document.body.append(textarea);
13377textarea.select();
13378document.execCommand("copy");
13379textarea.remove();
13380}
13381function openUrlInNewTab(url) {
13382if (!isOpenableExternalUrl(url)) return false;
13383const userscriptOpen = userscriptOpenInTab();
13384if (userscriptOpen) {
13385try {
13386userscriptOpen(url, { active: true, insert: true, setParent: false });
13387return true;
13388} catch {
13389}
13390}
13391const opened = window.open(url, "_blank", "noopener");
13392if (opened) {
13393try {
13394opened.opener = null;
13395} catch {
13396}
13397return true;
13398}
13399return false;
13400}
13401function userscriptOpenInTab() {
13402if (typeof GM_openInTab === "function") return GM_openInTab;
13403if (typeof GM !== "undefined" && typeof GM?.openInTab === "function") return GM.openInTab;
13404return void 0;
13405}
13406function isOpenableExternalUrl(value) {
13407try {
13408const url = new URL(value, location.href);
13409return url.protocol === "http:" || url.protocol === "https:";
13410} catch {
13411return false;
13412}
13413}
13414function normalizePressedKey(key) {
13415if (typeof key !== "string" || !key) return "";
13416if (key === " ") return "space";
13417return key.toLowerCase();
13418}
13419function positionPopover(popover, anchor, fallbackRect, options = {}) {
13420const frame = preparePopoverPositionFrame(popover, anchor, fallbackRect, options);
13421if (!frame.sourceRects.length) {
13422positionPopoverWithoutAnchor(popover, frame);
13423return;
13424}
13425positionAnchoredPopover(popover, anchor, frame);
13426}
13427const POPOVER_PLANNING_HEIGHT = 540;
13428const MIN_LOCKED_SIDE_SPACE = 160;
13429function preparePopoverPositionFrame(popover, anchor, fallbackRect, options) {
13430const viewport = getPopoverViewport();
13431const viewportHeight = viewport.bottom - viewport.top;
13432const viewportWidth = viewport.right - viewport.left;
13433popover.style.maxWidth = `${Math.max(0, viewportWidth)}px`;
13434const maxFrameHeight = popoverMaxFrameHeight(viewportHeight, options);
13435popover.style.maxHeight = `${maxFrameHeight}px`;
13436const height = popover.offsetHeight;
13437return {
13438scrollTop: popover.scrollTop,
13439sourceRects: getPopoverSourceRects(anchor, fallbackRect, options),
13440viewport,
13441viewportWidth,
13442viewportHeight,
13443width: popover.offsetWidth,
13444height,
13445planningHeight: Math.max(height, Math.min(maxFrameHeight, options.maxHeight ?? POPOVER_PLANNING_HEIGHT)),
13446preferBefore: Boolean(options.preferBefore),
13447keepPlacementSide: Boolean(options.keepPlacementSide)
13448};
13449}
13450function popoverMaxFrameHeight(viewportHeight, options) {
13451return Math.max(0, Math.min(viewportHeight, options.maxHeight ?? viewportHeight));
13452}
13453function positionPopoverWithoutAnchor(popover, frame) {
13454const margin = 8;
13455const fallbackLeft = (frame.viewport.left + frame.viewport.right - frame.width) / 2;
13456const fallbackTop = frame.viewport.top + frame.viewportHeight * 0.18;
13457popover.style.left = `${Math.max(frame.viewport.left + margin, Math.min(fallbackLeft, frame.viewport.right - frame.width - margin))}px`;
13458popover.style.top = `${Math.max(frame.viewport.top + margin, Math.min(fallbackTop, frame.viewport.bottom - frame.height - margin))}px`;
13459restorePopoverScrollTop(popover, frame.scrollTop);
13460}
13461function positionAnchoredPopover(popover, anchor, frame) {
13462const writingMode = getPopoverWritingMode(anchor);
13463const forcedAfter = frame.keepPlacementSide ? forcedAfterForPlacementSide(writingMode, popover.dataset.jpdbReaderPlacementSide) : void 0;
13464const position = getYomitanLikePopoverPosition(frame.sourceRects, writingMode, frame.viewport, frame.width, frame.height, frame.planningHeight, frame.preferBefore, forcedAfter);
13465popover.style.maxWidth = `${Math.max(0, position.width)}px`;
13466popover.style.maxHeight = `${Math.max(0, position.height)}px`;
13467popover.dataset.jpdbReaderPlacementSide = getPlacementSide(writingMode, position);
13468popover.style.left = `${position.left}px`;
13469popover.style.top = `${position.top}px`;
13470restorePopoverScrollTop(popover, frame.scrollTop);
13471}
13472function restorePopoverScrollTop(popover, scrollTop) {
13473if (popover.scrollTop !== scrollTop) popover.scrollTop = scrollTop;
13474}
13475function getPopoverSourceRects(anchor, fallbackRect, options) {
13476if (options.followPoint) return pointPopoverRects(options.followPoint);
13477const anchorRects = anchorPopoverRects(anchor);
13478if (anchorRects.length) return anchorRects;
13479if (fallbackRect) return [domRectToPopoverRect(fallbackRect)];
13480return selectionPopoverRects();
13481}
13482function pointPopoverRects(point) {
13483const radius = 14;
13484const overlayPoint = layoutPointToOverlay(point);
13485return [{
13486left: overlayPoint.x - radius,
13487top: overlayPoint.y - radius,
13488right: overlayPoint.x + radius,
13489bottom: overlayPoint.y + radius
13490}];
13491}
13492function anchorPopoverRects(anchor) {
13493if (!anchor) return [];
13494const clientRects = rectListToPopoverRects(anchor.getClientRects(), anchor);
13495if (clientRects.length) return clientRects;
13496const rect = domRectToPopoverRect(anchor.getBoundingClientRect(), anchor);
13497return hasRectArea(rect) ? [rect] : [];
13498}
13499function selectionPopoverRects() {
13500const selection = window.getSelection();
13501const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
13502if (!range) return [];
13503const clientRects = rangeClientPopoverRects(range);
13504return clientRects.length ? clientRects : rangeBoundingPopoverRects(range);
13505}
13506function rangeClientPopoverRects(range) {
13507return typeof range.getClientRects === "function" ? rectListToPopoverRects(range.getClientRects(), range.commonAncestorContainer) : [];
13508}
13509function rangeBoundingPopoverRects(range) {
13510if (typeof range.getBoundingClientRect !== "function") return [];
13511const rect = domRectToPopoverRect(range.getBoundingClientRect(), range.commonAncestorContainer);
13512return hasRectArea(rect) ? [rect] : [];
13513}
13514function rectListToPopoverRects(rects, source) {
13515return Array.from(rects, (rect) => domRectToPopoverRect(rect, source)).filter(hasRectArea);
13516}
13517function domRectToPopoverRect(rect, source) {
13518const overlayRect = sourceRectToOverlay(rect, source);
13519return {
13520left: overlayRect.left,
13521top: overlayRect.top,
13522right: overlayRect.right,
13523bottom: overlayRect.bottom
13524};
13525}
13526function hasRectArea(rect) {
13527return rect.right > rect.left || rect.bottom > rect.top;
13528}
13529function getPopoverViewport() {
13530const scaledViewport = overlayViewport();
13531if (scaledViewport.pageScale > 1) {
13532const bounds = overlayViewportBounds();
13533return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
13534}
13535const { visualViewport } = window;
13536if (visualViewport) {
13537const left = visualViewport.offsetLeft;
13538const top = visualViewport.offsetTop;
13539return {
13540left,
13541top,
13542right: left + visualViewport.width,
13543bottom: top + visualViewport.height
13544};
13545}
13546return {
13547left: 0,
13548top: 0,
13549right: window.innerWidth,
13550bottom: window.innerHeight
13551};
13552}
13553function getPopoverWritingMode(anchor) {
13554return anchor ? normalizePopoverWritingMode(getComputedStyle(anchor).writingMode) : DEFAULT_POPOVER_WRITING_MODE;
13555}
13556function normalizePopoverWritingMode(writingMode) {
13557const normalized = writingMode;
13558return SUPPORTED_POPOVER_WRITING_MODES.has(normalized) ? normalized : DEFAULT_POPOVER_WRITING_MODE;
13559}
13560function getYomitanLikePopoverPosition(sourceRects, writingMode, viewport, frameWidth, frameHeight, planningHeight, preferBefore, forcedAfter) {
13561const horizontal = isHorizontalPopoverMode(writingMode);
13562const layout = popoverWritingLayout(writingMode, horizontal, preferBefore, forcedAfter);
13563return bestYomitanPopoverPosition(sourceRects, horizontal, viewport, frameWidth, frameHeight, planningHeight, layout) ?? fallbackPopoverPosition(viewport, frameWidth, frameHeight);
13564}
13565function forcedAfterForPlacementSide(writingMode, side) {
13566if (writingMode === "horizontal-tb") {
13567if (side === "below") return true;
13568if (side === "above") return false;
13569return void 0;
13570}
13571if (side === "right") return true;
13572if (side === "left") return false;
13573return void 0;
13574}
13575function bestYomitanPopoverPosition(sourceRects, horizontal, viewport, frameWidth, frameHeight, planningHeight, layout) {
13576let best = null;
13577for (const candidate of popoverSourceRectCandidates(sourceRects)) {
13578const result = getPositionForWritingMode(candidate.rect, horizontal, frameWidth, frameHeight, planningHeight, viewport, layout);
13579if (!canUsePopoverPosition(candidate, result, sourceRects)) continue;
13580best = tallerPopoverPosition(best, result);
13581if (result.height >= frameHeight) break;
13582}
13583return best;
13584}
13585function isHorizontalPopoverMode(writingMode) {
13586return writingMode === DEFAULT_POPOVER_WRITING_MODE;
13587}
13588function fallbackPopoverPosition(viewport, frameWidth, frameHeight) {
13589return { left: viewport.left, top: viewport.top, width: frameWidth, height: frameHeight, after: true, below: true };
13590}
13591function popoverWritingLayout(writingMode, horizontal, preferBefore, forcedAfter) {
13592const preferAfter = horizontal ? !preferBefore : verticalTextPrefersAfter(writingMode, preferBefore);
13593return {
13594horizontalOffset: horizontal ? 0 : 10,
13595verticalOffset: horizontal ? 10 : 0,
13596preferAfter: forcedAfter ?? preferAfter,
13597lockAfter: forcedAfter !== void 0
13598};
13599}
13600function verticalTextPrefersAfter(writingMode, preferBefore) {
13601const defaultAfter = isVerticalTextPopupOnRight(writingMode);
13602return preferBefore ? !defaultAfter : defaultAfter;
13603}
13604function tallerPopoverPosition(best, next) {
13605return best === null || next.height > best.height ? next : best;
13606}
13607function canUsePopoverPosition(candidate, position, sourceRects) {
13608return candidate.canOverlap || !isOverlapping(position, sourceRects, candidate.index);
13609}
13610function popoverSourceRectCandidates(sourceRects) {
13611const candidates = sourceRects.map((rect, index) => ({ rect, index, canOverlap: false }));
13612return sourceRects.length > 1 ? [...candidates, { rect: getBoundingSourceRect(sourceRects), index: sourceRects.length, canOverlap: true }] : candidates;
13613}
13614function getPositionForWritingMode(sourceRect, horizontal, frameWidth, frameHeight, planningHeight, viewport, layout) {
13615return horizontal ? getPositionForHorizontalText(sourceRect, frameWidth, frameHeight, planningHeight, viewport, layout) : getPositionForVerticalText(sourceRect, frameWidth, frameHeight, viewport, layout);
13616}
13617function getPositionForHorizontalText(sourceRect, frameWidth, frameHeight, planningHeight, viewport, layout) {
13618const [left, width, after] = getConstrainedPosition(
13619sourceRect.right - layout.horizontalOffset,
13620sourceRect.left + layout.horizontalOffset,
13621frameWidth,
13622viewport.left,
13623viewport.right,
13624true
13625);
13626const [top, height, below] = getConstrainedPositionBinary(
13627sourceRect.top - layout.verticalOffset,
13628sourceRect.bottom + layout.verticalOffset,
13629frameHeight,
13630viewport.top,
13631viewport.bottom,
13632layout.preferAfter,
13633planningHeight,
13634layout.lockAfter
13635);
13636return { left, top, width, height, after, below };
13637}
13638function getPositionForVerticalText(sourceRect, frameWidth, frameHeight, viewport, layout) {
13639const [left, width, after] = getConstrainedPositionBinary(
13640sourceRect.left - layout.horizontalOffset,
13641sourceRect.right + layout.horizontalOffset,
13642frameWidth,
13643viewport.left,
13644viewport.right,
13645layout.preferAfter,
13646frameWidth,
13647layout.lockAfter
13648);
13649const [top, height, below] = getConstrainedPosition(
13650sourceRect.bottom - layout.verticalOffset,
13651sourceRect.top + layout.verticalOffset,
13652frameHeight,
13653viewport.top,
13654viewport.bottom,
13655true
13656);
13657return { left, top, width, height, after, below };
13658}
13659function isVerticalTextPopupOnRight(writingMode) {
13660return !(writingMode === "vertical-lr" || writingMode === "sideways-lr");
13661}
13662function getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
13663size = Math.min(size, maxLimit - minLimit);
13664let position;
13665{
13666position = Math.max(minLimit, positionAfter);
13667position = position - Math.max(0, position + size - maxLimit);
13668}
13669return [position, size, after];
13670}
13671function getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after, planningSize = size, lockSide = false) {
13672const spaceBefore = positionBefore - minLimit;
13673const spaceAfter = maxLimit - positionAfter;
13674const lockedSpace = after ? spaceAfter : spaceBefore;
13675const keepLockedSide = lockSide && lockedSpace >= Math.min(MIN_LOCKED_SIDE_SPACE, size);
13676if (!keepLockedSide && (planningSize > spaceAfter || planningSize > spaceBefore)) {
13677after = spaceAfter > spaceBefore;
13678}
13679let position;
13680if (after) {
13681size = Math.max(0, Math.min(size, spaceAfter));
13682position = Math.max(minLimit, positionAfter);
13683} else {
13684size = Math.max(0, Math.min(size, spaceBefore));
13685position = Math.min(maxLimit, positionBefore) - size;
13686}
13687return [position, size, after];
13688}
13689function getBoundingSourceRect(sourceRects) {
13690switch (sourceRects.length) {
13691case 0:
13692return { left: 0, top: 0, right: 0, bottom: 0 };
13693case 1:
13694return sourceRects[0];
13695}
13696let { left, top, right, bottom } = sourceRects[0];
13697for (let i = 1, ii = sourceRects.length; i < ii; ++i) {
13698const sourceRect = sourceRects[i];
13699left = Math.min(left, sourceRect.left);
13700top = Math.min(top, sourceRect.top);
13701right = Math.max(right, sourceRect.right);
13702bottom = Math.max(bottom, sourceRect.bottom);
13703}
13704return { left, top, right, bottom };
13705}
13706function isOverlapping(sizeRect, sourceRects, ignoreIndex) {
13707const { left, top } = sizeRect;
13708const right = left + sizeRect.width;
13709const bottom = top + sizeRect.height;
13710for (let i = 0, ii = sourceRects.length; i < ii; ++i) {
13711if (i === ignoreIndex) continue;
13712const sourceRect = sourceRects[i];
13713if (rectsOverlap({ left, top, right, bottom }, sourceRect)) return true;
13714}
13715return false;
13716}
13717function rectsOverlap(a, b) {
13718return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
13719}
13720function getPlacementSide(writingMode, position) {
13721if (writingMode === "horizontal-tb") return position.below ? "below" : "above";
13722return position.after ? "right" : "left";
13723}
13724function installLocalTapActivation(root) {
13725if (!claimLocalTapActivation(root)) return;
13726root.dataset.yomuPointerActivationInstalled = "true";
13727const resolveControl = (target) => {
13728const control = enabledReaderControl(target);
13729return control && root.contains(control) ? { target: control, root } : null;
13730};
13731installControlTapActivation(root, root.ownerDocument, resolveControl, { stopOnActivate: true });
13732}
13733const CORE_COLOR_TOKENS = {
13734black: "#000000",
13735white: "#ffffff"
13736};
13737const BRAND_COLOR_TOKENS = {
13738consoleAccent: "#247a58"
13739};
13740const READER_THEME_COLOR_TOKENS = {
13741dark: {
13742bg: "#181b20",
13743surface: "#20242b",
13744surface2: "#282e37",
13745accentText: "#11161d"
13746},
13747light: {
13748bg: "#fbfcfe",
13749surface: "#f4f7fa",
13750surface2: "#e8edf3",
13751accentText: CORE_COLOR_TOKENS.white
13752}
13753};
13754const LOOKUP_PILL_COLOR_TOKENS = {
13755jpdb: { bg: "#2563c7", border: "#4f8ff0", text: CORE_COLOR_TOKENS.white },
13756jiten: { bg: "#13845f", border: "#34c89a", text: CORE_COLOR_TOKENS.white },
13757bunpro: { bg: "#be3455", border: "#fb7185", text: CORE_COLOR_TOKENS.white },
13758"yomu-search": { bg: "#b83280", border: "#f472b6", text: CORE_COLOR_TOKENS.white },
13759jisho: { bg: "#4f46c7", border: "#7567f0", text: CORE_COLOR_TOKENS.white },
13760weblio: { bg: "#0f766e", border: "#2dd4bf", text: CORE_COLOR_TOKENS.white },
13761kotobank: { bg: "#be123c", border: "#fb7185", text: CORE_COLOR_TOKENS.white },
13762takoboto: { bg: "#0f5f99", border: "#38bdf8", text: CORE_COLOR_TOKENS.white },
13763"wiktionary-ja": { bg: "#374151", border: "#9ca3af", text: CORE_COLOR_TOKENS.white },
13764"immersion-kit": { bg: "#0e7490", border: "#22d3ee", text: CORE_COLOR_TOKENS.white },
13765nadeshiko: { bg: "#7c3aed", border: "#a78bfa", text: CORE_COLOR_TOKENS.white },
13766uchisen: { bg: "#9a3412", border: "#fb923c", text: CORE_COLOR_TOKENS.white },
13767anki: { bg: "#2f6da8", border: "#68a6e6", text: CORE_COLOR_TOKENS.white },
13768copy: { bg: "#7e3fbf", border: "#a064e5", text: CORE_COLOR_TOKENS.white }
13769};
13770const PAGE_WORD_COLOR_TOKENS = {
13771unknownBackgroundShadow: "var(--jpdb-reader-word-unknown-bg-shadow)"
13772};
13773const LOGGER_COLOR_TOKENS = {
13774debug: "#6b7280",
13775warn: "#a15c00",
13776error: "#b91c1c"
13777};
13778const JITEN_API_KEY_PREFIX = "ak_";
13779function effectiveJpdbApiKey(settings2) {
13780const apiKey = settings2.apiKey.trim();
13781return isJitenApiCredential(apiKey) ? "" : apiKey;
13782}
13783function effectiveJitenApiKey(settings2) {
13784const explicit = settings2.jitenApiKey.trim();
13785if (explicit) return explicit;
13786const apiKey = settings2.apiKey.trim();
13787return isJitenApiCredential(apiKey) ? apiKey : "";
13788}
13789function hasJpdbApiCredential(settings2) {
13790return Boolean(effectiveJpdbApiKey(settings2));
13791}
13792function hasJitenApiCredential(settings2) {
13793return Boolean(effectiveJitenApiKey(settings2));
13794}
13795function chosenWordGradingService(settings2) {
13796if (!hasJpdbApiCredential(settings2) || !hasJitenApiCredential(settings2)) return null;
13797return settings2.apiGradingProvider === "jpdb" ? "jpdb" : "jiten";
13798}
13799function effectiveBunproFrontendApiToken(settings2) {
13800return settings2.bunproFrontendApiToken?.trim() ?? "";
13801}
13802function effectiveBunproLegacyApiKey(settings2) {
13803return settings2.bunproApiKey?.trim() ?? "";
13804}
13805function hasBunproFrontendCredential(settings2) {
13806return Boolean(effectiveBunproFrontendApiToken(settings2));
13807}
13808function isBunproFrontendCredentialExpired(settings2, now = Date.now()) {
13809const raw = settings2.bunproFrontendApiTokenExpiresAt?.trim();
13810if (!raw || !hasBunproFrontendCredential(settings2)) return false;
13811const expiresAt = Date.parse(raw);
13812return Number.isFinite(expiresAt) && expiresAt <= now;
13813}
13814function effectiveWanikaniApiToken(settings2) {
13815return settings2.wanikaniApiToken?.trim() ?? "";
13816}
13817function hasWanikaniApiCredential(settings2) {
13818return Boolean(effectiveWanikaniApiToken(settings2));
13819}
13820function isJitenApiCredential(value) {
13821return value.trim().startsWith(JITEN_API_KEY_PREFIX);
13822}
13823const __vite_import_meta_env__ = { "DEV": false };
13824const LOG_PREFIX = "[Yomu]";
13825const LOG_STYLE = `background: ${BRAND_COLOR_TOKENS.consoleAccent}; color: ${CORE_COLOR_TOKENS.white}; border-radius: 3px; padding: 2px 5px; font-weight: 700;`;
13826const SCOPE_STYLE = `color: ${BRAND_COLOR_TOKENS.consoleAccent}; font-weight: 700;`;
13827const DEBUG_STYLE = `color: ${LOGGER_COLOR_TOKENS.debug};`;
13828const WARN_STYLE = `color: ${LOGGER_COLOR_TOKENS.warn}; font-weight: 700;`;
13829const ERROR_STYLE = `color: ${LOGGER_COLOR_TOKENS.error}; font-weight: 700;`;
13830const RUNTIME_LOG_KEY = "yomu:enable-logs";
13831const REDACTED = "[redacted]";
13832const OPTIONAL_CORS_BRIDGE_MESSAGE = "No configured proxy.";
13833const SECRET_KEY_PATTERN = /(api[-_]?key|authorization|bearer|token|password|secret|credential|oauth|cookie)/i;
13834const env = __vite_import_meta_env__;
13835const BUILD_IS_DEV_MODE = Boolean(env?.DEV);
13836const BUILD_LOGGING_ENABLED = BUILD_IS_DEV_MODE;
13837class ScopedLogger {
13838constructor(parent, scopeName) {
13839this.parent = parent;
13840this.scopeName = scopeName;
13841}
13842debug(message, ...args) {
13843this.parent.write(this.scopeName, message, args, writeDebugToConsole, DEBUG_STYLE);
13844}
13845info(message, ...args) {
13846this.parent.write(this.scopeName, message, args, console.info, "");
13847}
13848warn(message, ...args) {
13849const optional = args.some(isOptionalCorsBridgeError);
13850this.parent.write(this.scopeName, message, args, optional ? writeDebugToConsole : console.warn, optional ? DEBUG_STYLE : WARN_STYLE);
13851}
13852error(message, ...args) {
13853this.parent.write(this.scopeName, message, args, console.error, ERROR_STYLE);
13854}
13855warnOnce(key, message, ...args) {
13856this.parent.warnOnce(`${this.scopeName}:${key}`, this.scopeName, message, args);
13857}
13858time(label, ...args) {
13859if (!this.parent.isEnabled()) return () => void 0;
13860const start = nowMs();
13861this.debug(`${label} started`, ...args);
13862return () => this.debug(`${label} finished`, { durationMs: Math.round((nowMs() - start) * 10) / 10 });
13863}
13864}
13865class LoggerImpl {
13866settingsProvider;
13867forceEnabled = false;
13868onceKeys = new Set();
13869configure(options) {
13870this.settingsProvider = options.settingsProvider ?? this.settingsProvider;
13871this.forceEnabled = options.forceEnabled ?? this.forceEnabled;
13872}
13873scope(scopeName) {
13874return new ScopedLogger(this, scopeName);
13875}
13876isEnabled() {
13877if (BUILD_LOGGING_ENABLED) return true;
13878if (this.forceEnabled || getRuntimeLoggingOverride()) return true;
13879try {
13880return this.settingsProvider?.().enableLogging === true;
13881} catch {
13882return false;
13883}
13884}
13885isDevMode() {
13886return isDevMode();
13887}
13888enable(persist = false) {
13889this.forceEnabled = true;
13890if (persist) setRuntimeLoggingOverride(true);
13891this.scope("Logger").info("Runtime logging enabled.", { persisted: persist });
13892}
13893disable(persist = false) {
13894this.scope("Logger").info("Runtime logging disabled.", { persisted: persist });
13895this.forceEnabled = false;
13896if (persist) setRuntimeLoggingOverride(false);
13897}
13898reset() {
13899this.onceKeys.clear();
13900}
13901warnOnce(key, scope, message, args) {
13902if (this.onceKeys.has(key)) return;
13903this.onceKeys.add(key);
13904this.write(scope, message, args, console.warn, WARN_STYLE);
13905}
13906write(scope, message, args, writer, levelStyle) {
13907if (!this.isEnabled()) return;
13908writer(`%c${LOG_PREFIX}%c [${scope}]%c ${message}`, LOG_STYLE, SCOPE_STYLE, levelStyle, ...args.map(sanitizeForConsole));
13909}
13910}
13911const Logger = new LoggerImpl();
13912setAttemptRecorder((label, error) => Logger.scope("Attempt").debug(`${label} failed`, error));
13913function configureLogger(options) {
13914runtimeLoggingOverride = void 0;
13915Logger.configure(options);
13916}
13917function loggingSettingsSummary(settings2) {
13918return {
13919enableLogging: settings2.enableLogging,
13920hasApiKey: hasJpdbApiCredential(settings2),
13921hasJitenApiKey: hasJitenApiCredential(settings2),
13922localDictionariesEnabled: settings2.localDictionariesEnabled,
13923localDictionarySources: settings2.dictionaryPreferences.length,
13924ankiEnabled: settings2.ankiEnabled,
13925newTabSource: settings2.newTabSource,
13926ocrEnabled: settings2.ocrEnabled,
13927subtitlePlayerEnabled: settings2.subtitlePlayerEnabled,
13928youtubeImmersionEnabled: settings2.youtubeImmersionEnabled
13929};
13930}
13931function isDevMode() {
13932return BUILD_IS_DEV_MODE;
13933}
13934function writeDebugToConsole(...args) {
13935if (isDevMode()) console.log(...args);
13936else console.debug(...args);
13937}
13938function isOptionalCorsBridgeError(value) {
13939return value instanceof Error && value.message === OPTIONAL_CORS_BRIDGE_MESSAGE;
13940}
13941let runtimeLoggingOverride;
13942function getRuntimeLoggingOverride() {
13943if (runtimeLoggingOverride !== void 0) return runtimeLoggingOverride;
13944try {
13945runtimeLoggingOverride = gmStorageGetSync(RUNTIME_LOG_KEY, false) === true;
13946} catch {
13947runtimeLoggingOverride = false;
13948}
13949return runtimeLoggingOverride;
13950}
13951function setRuntimeLoggingOverride(enabled) {
13952runtimeLoggingOverride = enabled;
13953try {
13954if (enabled) gmStorageSetSync(RUNTIME_LOG_KEY, true);
13955else gmStorageDeleteSync(RUNTIME_LOG_KEY);
13956} catch {
13957}
13958}
13959function nowMs() {
13960return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
13961}
13962function sanitizeForConsole(value) {
13963if (typeof value === "string") return redactString(value);
13964if (value === null || value === void 0 || typeof value !== "object") return value;
13965const sanitized = sanitizeSpecialConsoleValue(value);
13966if (sanitized.handled) return sanitized.value;
13967if (Array.isArray(value)) return value.map(sanitizeForConsole);
13968return sanitizeRecordForConsole(value);
13969}
13970function sanitizeSpecialConsoleValue(value) {
13971for (const sanitizer of CONSOLE_VALUE_SANITIZERS) {
13972const sanitized = sanitizer(value);
13973if (sanitized.handled) return sanitized;
13974}
13975return { handled: false };
13976}
13977const CONSOLE_VALUE_SANITIZERS = [
13978(value) => value instanceof Error ? { handled: true, value: { name: value.name, message: value.message, stack: value.stack } } : { handled: false },
13979(value) => typeof URL !== "undefined" && value instanceof URL ? { handled: true, value: value.href } : { handled: false },
13980(value) => typeof Blob !== "undefined" && value instanceof Blob ? { handled: true, value: { type: value.type, size: value.size } } : { handled: false },
13981(value) => typeof Event !== "undefined" && value instanceof Event ? { handled: true, value: { type: value.type } } : { handled: false }
13982];
13983function sanitizeRecordForConsole(record2) {
13984return Object.fromEntries(Object.entries(record2).map(([key, value]) => [
13985key,
13986shouldRedactEntry(key, value) ? REDACTED : sanitizeFlatValue(value)
13987]));
13988}
13989function sanitizeFlatValue(value) {
13990if (typeof value === "string") return redactString(value);
13991if (value instanceof Error) return { name: value.name, message: value.message };
13992return value;
13993}
13994function shouldRedactEntry(key, value) {
13995if (!SECRET_KEY_PATTERN.test(key)) return false;
13996if (typeof value === "number" && /tokens?/i.test(key)) return false;
13997return true;
13998}
13999function redactString(value) {
14000return value.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`).replace(/(["']?(?:api[-_]?key|token|password|secret|authorization)["']?\s*[:=]\s*["'])[^"']+(["'])/gi, `$1${REDACTED}$2`);
14001}
14002if (typeof window !== "undefined") {
14003window.__YOMU_LOGGER__ = Logger;
14004window.YomuLogger = Logger;
14005}
14006const log$a = Logger.scope("CardStateSignal");
14007const CARD_STATE_SIGNAL_KEY = "yomu:private:card-state-signal:v1";
14008const CARD_STATE_CHANNEL_NAME = "yomu:card-state";
14009const SEEN_SIGNAL_LIMIT = 32;
14010function cardStateSignalCard(card) {
14011return {
14012vid: card.vid,
14013sid: card.sid,
14014rid: card.rid,
14015spelling: card.spelling,
14016reading: card.reading,
14017language: card.language,
14018cardState: [...card.cardState],
14019pitchAccent: [...card.pitchAccent],
14020source: card.source,
14021reviewSource: card.reviewSource,
14022dueAt: card.dueAt,
14023lastReviewAt: card.lastReviewAt,
14024deckNames: card.deckNames ? [...card.deckNames] : void 0,
14025ankiDeckNames: card.ankiDeckNames ? [...card.ankiDeckNames] : void 0,
14026jpdbDeckMembership: card.jpdbDeckMembership,
14027sourceDeckName: card.sourceDeckName
14028};
14029}
14030function cardFromCardStateSignal(card) {
14031return {
14032...card,
14033frequencyRank: null,
14034partOfSpeech: [],
14035meanings: [],
14036wordWithReading: null
14037};
14038}
14039function publishCardStateSignal(card) {
14040const signal = {
14041id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
14042at: Date.now(),
14043card: cardStateSignalCard(card)
14044};
14045void gmPrivateStorageSet(CARD_STATE_SIGNAL_KEY, signal).catch((error) => {
14046log$a.debug("GM card-state publish failed", error);
14047});
14048publishBroadcastCardStateSignal(signal);
14049}
14050function publishBroadcastCardStateSignal(signal) {
14051if (!currentAccountDataSurfaceIsTrusted() || typeof BroadcastChannel !== "function") return;
14052try {
14053const channel = new BroadcastChannel(CARD_STATE_CHANNEL_NAME);
14054channel.postMessage(signal);
14055channel.close();
14056} catch (error) {
14057log$a.debug("Broadcast card-state publish failed", error);
14058}
14059}
14060function subscribeToCardStateSignals(onCard) {
14061const cleanups = [];
14062const seenIds = [];
14063const handle = (value) => {
14064const signal = parseCardStateSignal(value);
14065if (!signal || seenIds.includes(signal.id)) return;
14066seenIds.push(signal.id);
14067if (seenIds.length >
14067 SEEN_SIGNAL_LIMIT) seenIds.shift();
14068onCard(cardFromCardStateSignal(signal.card));
14069};
14070cleanups.push(subscribeToStoredValueChanges(CARD_STATE_SIGNAL_KEY, (newValue, source) => {
14071if (source.transport === "web-storage") return;
14072if (source.remote) handle(newValue);
14073}));
14074if (currentAccountDataSurfaceIsTrusted() && typeof BroadcastChannel === "function") {
14075try {
14076const channel = new BroadcastChannel(CARD_STATE_CHANNEL_NAME);
14077channel.onmessage = (event) => handle(event.data);
14078cleanups.push(() => channel.close());
14079} catch (error) {
14080log$a.debug("Broadcast card-state listener failed", error);
14081}
14082}
14083return () => cleanups.forEach((cleanup) => cleanup());
14084}
14085function parseCardStateSignal(value) {
14086if (!value || typeof value !== "object") return null;
14087const signal = value;
14088const card = signal.card;
14089if (typeof signal.id !== "string" || !card || typeof card.spelling !== "string" || !card.spelling) return null;
14090if (!Array.isArray(card.cardState)) return null;
14091return {
14092id: signal.id,
14093at: Number(signal.at) || Date.now(),
14094card: {
14095vid: Number(card.vid) || 0,
14096sid: Number(card.sid) || 0,
14097rid: Number(card.rid) || 0,
14098spelling: card.spelling,
14099reading: typeof card.reading === "string" ? card.reading : "",
14100language: typeof card.language === "string" ? card.language : void 0,
14101cardState: card.cardState,
14102pitchAccent: Array.isArray(card.pitchAccent) ? card.pitchAccent : [],
14103source: card.source ?? "jpdb",
14104reviewSource: typeof card.reviewSource === "string" ? card.reviewSource : void 0,
14105dueAt: card.dueAt === null || typeof card.dueAt === "number" ? card.dueAt : void 0,
14106lastReviewAt: card.lastReviewAt === null || typeof card.lastReviewAt === "number" ? card.lastReviewAt : void 0,
14107deckNames: Array.isArray(card.deckNames) ? card.deckNames : void 0,
14108ankiDeckNames: Array.isArray(card.ankiDeckNames) ? card.ankiDeckNames : void 0,
14109jpdbDeckMembership: typeof card.jpdbDeckMembership === "string" ? card.jpdbDeckMembership : void 0,
14110sourceDeckName: typeof card.sourceDeckName === "string" ? card.sourceDeckName : void 0
14111}
14112};
14113}
14114function sensitiveFingerprint(value) {
14115const secret = value.trim();
14116if (!secret) return "";
14117let first = 2166136261;
14118let second = 2654435769;
14119for (let index = 0; index < secret.length; index += 1) {
14120const code = secret.charCodeAt(index);
14121first = Math.imul(first ^ code, 16777619) >>> 0;
14122second = Math.imul(second ^ code, 2246822507) >>> 0;
14123}
14124return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}:${secret.length}`;
14125}
14126const CARD_STATE_LABEL_KEYS$1 = {
14127new: "stateNew",
14128learning: "stateLearning",
14129young: "stateYoung",
14130mature: "stateMature",
14131known: "stateKnown",
14132mastered: "stateMastered",
14133due: "stateDue",
14134failed: "stateFailed",
14135locked: "stateLocked",
14136"never-forget": "stateNeverForget",
14137blacklisted: "stateBlacklisted",
14138suspended: "stateSuspended",
14139"in-deck": "stateInDeck",
14140"not-in-deck": "stateNotInDeck",
14141redundant: "stateRedundant",
14142frequent: "stateFrequent",
14143unparsed: "stateUnparsed"
14144};
14145function resolveUiLanguage$1(language) {
14146return yomuI18nCompanion()?.resolveUiLanguage(language) ?? fallbackResolveUiLanguage(language);
14147}
14148function uiText$1(language, key) {
14149return yomuI18nCompanion()?.uiText(language, key) ?? fallbackUiText(key);
14150}
14151function cardStateLabel$1(state, language, fallback = state) {
14152return yomuI18nCompanion()?.cardStateLabel(state, language, fallback) ?? fallbackCardStateLabel(state, fallback);
14153}
14154function formatUiText(language, key, values) {
14155return yomuI18nCompanion()?.formatUiText(language, key, values) ?? formatTemplate(fallbackUiText(key), values);
14156}
14157function uiList(language, parts) {
14158return yomuI18nCompanion()?.uiList(language, parts) ?? new Intl.ListFormat(resolveUiLanguage$1(language), { style: "short", type: "conjunction" }).format(parts);
14159}
14160function fallbackResolveUiLanguage(language) {
14161if (language === "ja" || language === "en") return language;
14162const languages2 = typeof navigator === "undefined" ? [] : [
14163...Array.isArray(navigator.languages) ? navigator.languages : [],
14164navigator.language
14165];
14166return languages2.some((value) => typeof value === "string" && value.toLowerCase().startsWith("ja")) ? "ja" : "en";
14167}
14168function fallbackCardStateLabel(state, fallback) {
14169return CARD_STATE_LABEL_KEYS$1[state] ? fallbackUiText(CARD_STATE_LABEL_KEYS$1[state]) : fallback;
14170}
14171function fallbackUiText(key) {
14172return String(key);
14173}
14174function formatTemplate(template, values) {
14175return Object.entries(values).reduce(
14176(text2, [name, value]) => text2.replaceAll(`{${name}}`, String(value)),
14177template
14178);
14179}
14180const five = [
14181["nothing", "gradeNothingLabel", "gradeNothing"],
14182["something", "gradeSomethingLabel", "gradeSomething"],
14183["hard", "gradeHardLabel", "gradeHard"],
14184["okay", "gradeOkayLabel", "gradeOkay"],
14185["easy", "gradeEasyLabel", "gradeEasy"]
14186];
14187const four = [
14188["nothing", "gradeAgainLabel", "gradeNothing"],
14189["hard", "gradeHardLabel", "gradeSomething"],
14190["okay", "gradeGoodLabel", "gradeHard"],
14191["easy", "gradeEasyLabel", "gradeOkay"]
14192];
14193const two = [["fail", "gradeFailLabel", "gradeFail"], ["pass", "gradePassLabel", "gradePass"]];
14194const ankiTwo = [["fail", "gradeAgainLabel", "gradeFail"], ["pass", "gradeGoodLabel", "gradePass"]];
14195const bunproRegular = [["fail", "gradeHardLabel", "gradeFail"], ["pass", "gradeGoodLabel", "gradePass"]];
14196function reviewGradeProfile(card, target) {
14197const destination = target && target !== "both" ? target : card?.reviewSource ?? card?.source;
14198if (destination === "bunpro" || destination === "bunpro-api") return card?.bunproReviewInputMode === "fsrs" ? "bunpro-fsrs" : "bunpro-regular";
14199if (destination === "jiten" || destination === "jiten-api") return "jiten";
14200if (destination === "anki") return "anki";
14201return "standard";
14202}
14203function reviewGradeScale(settings2, profile = "standard") {
14204const entries2 = profile === "bunpro-fsrs" ? four : profile === "bunpro-regular" ? bunproRegular : settings2.twoButtonReviews ? profile === "anki" ? ankiTwo : two : profile === "standard" ? five : four;
14205return {
14206grades: entries2.map(([grade, label]) => [grade, uiText$1(settings2.interfaceLanguage, label)]),
14207shortcuts: entries2.map(([grade, , key]) => [key, grade]),
14208twoButton: entries2.length === 2
14209};
14210}
14211function gradeKeyHintAttributes(settings2, shortcut) {
14212const key = shortcut ? settings2.shortcuts[shortcut]?.trim() ?? "" : "";
14213if (!key) return "";
14214const escaped = key.replace(/[&<>"']/gu, (character) => `&#${character.charCodeAt(0)};`);
14215return ` data-grade-key="${escaped}" aria-keyshortcuts="${escaped}"`;
14216}
14217class BatchReceiptLedger {
14218constructor(limit) {
14219this.limit = limit;
14220}
14221values = new Map();
14222unresolved = new Set();
14223get(key) {
14224return this.values.get(key);
14225}
14226set(key, value) {
14227this.values.set(key, value);
14228}
14229get size() {
14230return this.retainedKeys().size;
14231}
14232reserve(items) {
14233const ids = new Set(items.map((item) => item.id));
14234const related = [...this.unresolved].filter((group) => [...ids].some((id) => group.has(id)));
14235const operation = new Map(related.flatMap((group) => [...group]));
14236items.forEach((item) => operation.set(item.id, item));
14237const remaining = [...this.unresolved].filter((group) => !related.includes(group));
14238if (this.retainedKeys([...remaining, operation]).size > this.limit) return null;
14239related.forEach((group) => this.unresolved.delete(group));
14240this.unresolved.add(operation);
14241return operation;
14242}
14243/** A word turned away before anything was written leaves the operation: there is nothing to finish. */
14244release(operation, id) {
14245operation.delete(id);
14246}
14247finish(operation) {
14248if ([...operation.values()].every((item) => item.required.every((key) => this.values.get(key) === "completed"))) {
14249this.unresolved.delete(operation);
14250}
14251}
14252beginGeneration() {
14253for (const group of this.unresolved) this.finish(group);
14254const protectedKeys = new Set([...this.unresolved].flatMap((group) => [...group.values()].flatMap((item) => item.keys)));
14255for (const [key, value] of this.values) {
14256if (value === "completed" && !protectedKeys.has(key)) this.values.delete(key);
14257}
14258}
14259retainedKeys(groups = [...this.unresolved]) {
14260return new Set([...this.values.keys(), ...groups.flatMap((group) => [...group.values()].flatMap((item) => item.keys))]);
14261}
14262}
14263const commandCapabilities = createPrivateElementStateSlot("commands", immutableCommandSnapshot);
14264function privateCommandAttributes(command) {
14265return commandCapabilities.attributes(command);
14266}
14267function readPrivateCommandCapability(element) {
14268return commandCapabilities.read(element);
14269}
14270function dispatchPrivateCommand(element, handlers) {
14271const command = readPrivateCommandCapability(element);
14272if (!command) return false;
14273const handler = handlers[command.kind];
14274if (!handler) return false;
14275handler(command);
14276return true;
14277}
14278function readCardCommandCapability(element) {
14279const command = readPrivateCommandCapability(element);
14280return command?.kind === "card-action" ? command : void 0;
14281}
14282function readCardUiCommandCapability(element) {
14283const command = readPrivateCommandCapability(element);
14284return command?.kind === "card-ui" ? command : void 0;
14285}
14286function readReviewTargetCapability(element) {
14287const command = readPrivateCommandCapability(element);
14288return command?.kind === "review-target" ? command : void 0;
14289}
14290function privateReviewGradeAllowed(button, command) {
14291const actions = button.closest(".jpdb-reader-actions, [data-newtab-controls]");
14292const select = actions?.querySelector("[data-review-target-select], [data-newtab-grade-target-select]");
14293if (!select) return command.reviewGroup === void 0;
14294const selected = readReviewTargetCapability(select.options[select.selectedIndex]);
14295if (command.reviewGroup) {
14296const selector = readPrivateCommandCapability(select);
14297if (selector?.kind !== "review-selector" || selector.reviewGroup !== command.reviewGroup) return false;
14298}
14299return Boolean(selected && selected.reviewGroup === command.reviewGroup && selected.gradeProfile === (command.gradeProfile ?? "standard"));
14300}
14301function readAnkiAudioMergeCapability(element) {
14302const command = readPrivateCommandCapability(element);
14303return command?.kind === "anki-audio-merge" ? command : void 0;
14304}
14305function readKanjiCommandCapability(element) {
14306const command = readPrivateCommandCapability(element);
14307return command?.kind === "kanji-lookup" ? command : void 0;
14308}
14309function readTokenChoiceCommandCapability(element) {
14310const command = readPrivateCommandCapability(element);
14311return command?.kind === "token-choice" ? command : void 0;
14312}
14313function resolvePrivateReviewSelection(root, fallbackAnkiCardId) {
14314const actions = reviewActionsRoot(root);
14315const selected = selectedReviewTargetCapability(actions);
14316const visible = visibleReviewCardCapability(actions);
14317const reviewTarget = privateReviewTarget(firstDefined(
14318selectedReviewTarget$1(selected),
14319visibleReviewTarget(visible)
14320));
14321const explicitAnkiCardId = positiveAnkiCardId(firstDefined(
14322selectedReviewAnkiCardId$1(selected),
14323visibleReviewAnkiCardId(visible)
14324));
14325const ankiCardId = explicitAnkiCardId === void 0 ? fallbackAnkiCardIdForReviewTarget(reviewTarget, fallbackAnkiCardId) : explicitAnkiCardId;
14326return { reviewTarget, ankiCardId };
14327}
14328const PRIVATE_REVIEW_TARGETS = new Set([
14329"both",
14330"jpdb",
14331"jiten",
14332"bunpro",
14333"yomu-local",
14334"anki"
14335]);
14336function reviewActionsRoot(root) {
14337return root ? root.querySelector(".jpdb-reader-actions") : null;
14338}
14339function selectedReviewTargetCapability(actions) {
14340const select = actions?.querySelector("[data-review-target-select]");
14341if (!select) return void 0;
14342return readReviewTargetCapability(select.options[select.selectedIndex]);
14343}
14344function visibleReviewCardCapability(actions) {
14345const button = actions?.querySelector(
14346'[data-review-target-row]:not([hidden]) [data-action="grade"][data-grade]'
14347);
14348return readCardCommandCapability(button);
14349}
14350function selectedReviewTarget$1(command) {
14351return command ? command.target : void 0;
14352}
14353function visibleReviewTarget(command) {
14354return command ? command.reviewTarget : void 0;
14355}
14356function selectedReviewAnkiCardId$1(command) {
14357return command ? command.ankiCardId : void 0;
14358}
14359function visibleReviewAnkiCardId(command) {
14360return command ? command.ankiCardId : void 0;
14361}
14362function firstDefined(primary, fallback) {
14363return primary === void 0 ? fallback : primary;
14364}
14365function positiveAnkiCardId(value) {
14366if (typeof value !== "number") return void 0;
14367return Number.isFinite(value) && value > 0 ? value : void 0;
14368}
14369function fallbackAnkiCardIdForReviewTarget(reviewTarget, fallbackAnkiCardId) {
14370if (!reviewTarget) return fallbackAnkiCardId;
14371return reviewTarget === "anki" || reviewTarget === "both" ? fallbackAnkiCardId : null;
14372}
14373function privateReviewTarget(value) {
14374return value && PRIVATE_REVIEW_TARGETS.has(value) ? value : void 0;
14375}
14376function immutableCommandSnapshot(command) {
14377if (command.kind === "subtitle-action" && command.batchPlans) {
14378return Object.freeze({ ...command, batchPlans: Object.freeze([...command.batchPlans]) });
14379}
14380if (command.kind === "card-action" && command.audioUrls) {
14381return Object.freeze({ ...command, audioUrls: Object.freeze([...command.audioUrls]) });
14382}
14383return Object.freeze({ ...command });
14384}
14385const NON_JPDB_DECK_SOURCES = new Set(["anki", "jiten", "bunpro", "yomu-local"]);
14386function renderDeckChoiceOptions(settings2, jpdbDecks, ankiDecks, optionsOrIncludeJpdb = {}) {
14387const renderOptions = normalizeDeckChoiceRenderOptions(optionsOrIncludeJpdb);
14388const options = [];
14389if (renderOptions.includeJpdb) addJpdbDeckChoiceOptions(settings2, options, jpdbDecks);
14390if (renderOptions.includeJiten) addJitenDeckChoiceOptions(options, renderOptions.jitenDecks ?? []);
14391if (renderOptions.includeBunpro) addDeckChoiceOption(options, "bunpro", "bunpro", "Bunpro");
14392if (renderOptions.includeYomuLocal && settings2.yomuLocalSrsEnabled) addDeckChoiceOption(options, "yomu-local", "yomu-local", ACADEMY_SRS_LABEL);
14393if (settings2.ankiEnabled) addAnkiDeckChoiceOptions(settings2, options, ankiDecks);
14394if (!options.length) return "";
14395return deckChoicePlaceholderOption(settings2) + options.map(renderDeckChoiceOption).join("");
14396}
14397function jpdbDeckLabel(settings2, deckId, decks) {
14398if (deckId === "forq") return "FORQ";
14399const deck = decks.find((candidate) => candidate.id === deckId);
14400return deck?.name || deckId;
14401}
14402function addJpdbDeckChoiceOptions(settings2, options, jpdbDecks) {
14403const selected = settings2.miningDeck.trim() || "forq";
14404addDeckChoiceOption(options, "jpdb", "forq", "JPDB: FORQ");
14405addDeckChoiceOption(options, "jpdb", selected, `JPDB: ${jpdbDeckLabel(settings2, selected, jpdbDecks)}`);
14406for (const deck of jpdbDecks) {
14407if (!isSpecialJpdbDeck(settings2, deck)) addDeckChoiceOption(options, "jpdb", deck.id, `JPDB: ${deck.name}`);
14408}
14409}
14410function addJitenDeckChoiceOptions(options, jitenDecks) {
14411for (const deck of jitenDecks) addDeckChoiceOption(options, "jiten", deck.id, `Jiten: ${deck.name}`);
14412}
14413function addAnkiDeckChoiceOptions(settings2, options, ankiDecks) {
14414const configuredDeck = settings2.ankiDeck || "よむ";
14415addDeckChoiceOption(options, "anki", configuredDeck, `Anki: ${configuredDeck}`);
14416for (const deck of ankiDecks) addDeckChoiceOption(options, "anki", deck, `Anki: ${deck}`);
14417}
14418function addDeckChoiceOption(options, source, value, label) {
14419const normalizedValue = value.trim();
14420const key = `${source}:${normalizedValue}`;
14421if (!normalizedValue || options.some(([existing]) => existing === key)) return;
14422options.push([key, label]);
14423}
14424function renderDeckChoiceOption([value, label]) {
14425const [rawSource, ...idParts] = value.split(":");
14426const source = deckChoiceSource(rawSource);
14427const deckId = idParts.join(":");
14428const capability = privateCommandAttributes({ kind: "deck-choice", source, id: deckId });
14429return `<option value="${escapeHtml(value)}" data-deck-source="${escapeHtml(source)}" data-deck-id="${escapeHtml(deckId)}"${capability}>${escapeHtml(label)}</option>`;
14430}
14431function deckChoiceSource(value) {
14432return NON_JPDB_DECK_SOURCES.has(value) ? value : "jpdb";
14433}
14434function deckChoicePlaceholderOption(settings2) {
14435return `<option value="" disabled selected>${escapeHtml(uiText$1(settings2.interfaceLanguage, "deck"))}</option>`;
14436}
14437function isSpecialJpdbDeck(settings2, deck) {
14438const neverForgetDeck = settings2.neverForgetDeck.trim();
14439const blacklistDeck = settings2.blacklistDeck.trim();
14440if (deck.id === neverForgetDeck || deck.id === blacklistDeck) return true;
14441return /never\s*-?\s*forget|blacklist|suspend/i.test(`${deck.id} ${deck.name}`);
14442}
14443function normalizeDeckChoiceRenderOptions(value) {
14444return typeof value === "boolean" ? { includeJpdb: value } : value;
14445}
14446function userFacingError(copyKey, options = {}) {
14447return Object.assign(
14448new Error(options.diagnostic ?? uiText$1("en", copyKey), { cause: options.cause }),
14449{ name: "UserFacingError", yomuUiCopyKey: copyKey }
14450);
14451}
14452function userFacingErrorText(language, fallbackKey, error) {
14453const copyKey = userFacingCopyKey(error) ?? fallbackKey;
14454const message = uiText$1(language, copyKey);
14455return typeof message === "string" ? message : uiText$1(language, fallbackKey);
14456}
14457function userFacingCopyKeyOf(error) {
14458if (!error || typeof error !== "object") return void 0;
14459const copyKey = error.yomuUiCopyKey;
14460return typeof copyKey === "string" ? copyKey : void 0;
14461}
14462function userFacingCopyKey(error) {
14463return userFacingCopyKeyOf(error);
14464}
14465const listeners = new Set();
14466let waits = 0;
14467function reportSaveWaitingForAnotherTab(waiting) {
14468waits = Math.max(0, waits + (waiting ? 1 : -1));
14469for (const listener of listeners) {
14470try {
14471listener(waits > 0);
14472} catch {
14473}
14474}
14475}
14476function watchSavesWaitingForAnotherTab(listener) {
14477listeners.add(listener);
14478if (waits > 0) listener(true);
14479return () => {
14480listeners.delete(listener);
14481};
14482}
14483function canonicalLanguageTag(value) {
14484if (typeof value !== "string") return null;
14485const candidate = value.trim().replace(/_/g, "-");
14486if (!candidate || candidate.length > 255) return null;
14487try {
14488return Intl.getCanonicalLocales(candidate)[0] ?? null;
14489} catch {
14490return null;
14491}
14492}
14493function languageSubtag(value) {
14494const canonical = canonicalLanguageTag(value);
14495if (!canonical) return null;
14496try {
14497return new Intl.Locale(canonical).language;
14498} catch {
14499return canonical.split("-")[0]?.toLowerCase() ?? null;
14500}
14501}
14502function languageDisplayName(language, locale = "en") {
14503try {
14504return new Intl.DisplayNames([locale], { type: "language" }).of(language) ?? language;
14505} catch {
14506return language;
14507}
14508}
14509function canonicalStudyCardIdentity(expression, reading = "", options = {}) {
14510const normalizedExpression = expression.normalize("NFKC").trim();
14511if (!normalizedExpression) throw new TypeError("Vocabulary expression is required.");
14512const normalizedReading = (reading || normalizedExpression).normalize("NFKC").trim() || normalizedExpression;
14513const partOfSpeech = options.partOfSpeech?.normalize("NFKC").trim() ?? "";
14514const language = canonicalLanguageTag(options.language ?? "ja");
14515if (!language) throw new TypeError("Vocabulary language must be a valid BCP-47 tag.");
14516const slots = [
14517normalizedExpression,
14518normalizedReading,
14519partOfSpeech,
14520language === "ja" ? "" : language
14521];
14522while (slots.at(-1) === "") slots.pop();
14523return {
14524key: slots.join("\0"),
14525expression: normalizedExpression,
14526reading: normalizedReading,
14527partOfSpeech,
14528language
14529};
14530}
14531const localYomuDeck = aggregateRuntimeModules().localYomuDeck;
14532const {
14533mergeStoredYomuSrsCards,
14534mergeStoredYomuSrsDecks,
14535normalizeStoredYomuSrsDeck,
14536removeAcademyVocabularyProvenance,
14537upsertAcademyVocabulary
14538} = localYomuDeck;
14539const LEGACY_DECK_KEY = "yomu:srs-local:v1";
14540const DECK_KEY_PREFIX = "yomu:srs-local:v2:";
14541const DECK_INDEX_KEY = `${DECK_KEY_PREFIX}index`;
14542const CARD_KEY_PREFIX = `${DECK_KEY_PREFIX}card:`;
14543const TOMBSTONE_KEY_PREFIX = `${DECK_KEY_PREFIX}tombstone:`;
14544registerManagedState({
14545owner: "srs/local-yomu-store",
14546kind: "gm",
14547prefix: DECK_KEY_PREFIX,
14548enumerate: enumerateLocalYomuSrsStorageKeys
14549});
14550function isLocalYomuSrsStorageKey(key) {
14551return key === LEGACY_DECK_KEY || key.startsWith(DECK_KEY_PREFIX);
14552}
14553class LocalYomuSrsStorageError extends Error {
14554constructor(options) {
14555super(isStorageLeaseLapsed(options?.cause) ? "Your Academy deck was not saved because saving was interrupted. Try again." : "Your Academy deck could not be saved. Browser storage may be full. Free some site storage, then try again.", options);
14556this.name = "LocalYomuSrsStorageError";
14557}
14558}
14559function isLocalYomuSrsStorageError(error) {
14560return error instanceof LocalYomuSrsStorageError || Boolean(error && typeof error === "object" && error.name === "LocalYomuSrsStorageError");
14561}
14562function isLocalYomuSrsSaveInterrupted(error) {
14563return isStorageLeaseLapsed(error) || isLocalYomuSrsStorageError(error) && isStorageLeaseLapsed(error.cause);
14564}
14565async function enumerateLocalYomuSrsStorageKeys() {
14566const rawIndex = await gmStorageGetForResetEnumeration(DECK_INDEX_KEY, null);
14567if (rawIndex === null || rawIndex === void 0) return [];
14568const index = normalizeIndex(rawIndex);
14569if (!index) throw new Error("The local SRS index is unreadable.");
14570return [
14571DECK_INDEX_KEY,
14572...index.cardIds.map(cardStorageKey),
14573...index.tombstoneIds.map(tombstoneStorageKey)
14574];
14575}
14576class LocalYomuSrsStore {
14577async read() {
14578const [rawIndex, legacy] = await gmStorageGetMany([DECK_INDEX_KEY, LEGACY_DECK_KEY], null);
14579const index = normalizeIndex(rawIndex);
14580const current = index ? await this.readIndexedDeck(index) : normalizeStoredYomuSrsDeck(null);
14581if (legacy === null || legacy === void 0) return current;
14582const migrated = mergeStoredYomuSrsDecks(current, legacy);
14583await this.write(current, migrated);
14584await gmStorageDelete(LEGACY_DECK_KEY);
14585return migrated;
14586}
14587async write(previousValue, nextValue) {
14588const previous = normalizeStoredYomuSrsDeck(previousValue);
14589const next = normalizeStoredYomuSrsDeck(nextValue);
14590const previousIndex = indexForDeck(previous);
14591const storedIndex = normalizeIndex(await gmStorageGet(DECK_INDEX_KEY, null));
14592const nextIndex = indexForDeck(next, (storedIndex?.revision ?? 0) + 1);
14593const newlyCreatedKeys = [];
14594try {
14595for (const [id, card] of Object.entries(next.cards)) {
14596if (sameStoredValue(previous.cards[id], card)) continue;
14597const key = cardStorageKey(id);
14598await gmStorageSet(key, card);
14599if (!previous.cards[id]) newlyCreatedKeys.push(key);
14600}
14601for (const [id, deletedAt] of Object.entries(next.tombstones ?? {})) {
14602if (previous.tombstones?.[id] === deletedAt) continue;
14603const key = tombstoneStorageKey(id);
14604await gmStorageSet(key, deletedAt);
14605if (previous.tombstones?.[id] === void 0) newlyCreatedKeys.push(key);
14606}
14607await gmStorageSet(DECK_INDEX_KEY, nextIndex);
14608} catch (error) {
14609if (!isStorageLeaseLapsed(error)) await Promise.allSettled(newlyCreatedKeys.map((key) => gmStorageDelete(key)));
14610throw new LocalYomuSrsStorageError({ cause: error });
14611}
14612await Promise.allSettled([
14613...previousIndex.cardIds.filter((id) => !next.cards[id]).map((id) => gmStorageDelete(cardStorageKey(id))),
14614...previousIndex.tombstoneIds.filter((id) => next.tombstones?.[id] === void 0).map((id) => gmStorageDelete(tombstoneStorageKey(id)))
14615]);
14616}
14617async readIndexedDeck(index) {
14618const values = await gmStorageGetMany([
14619...index.cardIds.map(cardStorageKey),
14620...index.tombstoneIds.map(tombstoneStorageKey)
14621], null);
14622const cards = index.cardIds.map((id, position) => [id, values[position]]);
14623const tombstones = index.tombstoneIds.map((id, position) => [id, values[index.cardIds.length + position]]);
14624return normalizeStoredYomuSrsDeck({
14625version: 1,
14626cards: Object.fromEntries(cards.filter((entry) => Boolean(entry[1] && typeof entry[1] === "object"))),
14627tombstones: Object.fromEntries(tombstones.filter((entry) =>
14627 typeof entry[1] === "number" && Number.isFinite(entry[1])))
14628});
14629}
14630}
14631function normalizeIndex(value) {
14632if (!value || typeof value !== "object" || Array.isArray(value)) return null;
14633const candidate = value;
14634if (candidate.version !== 2) return null;
14635if (!Array.isArray(candidate.cardIds) || !candidate.cardIds.every((id) => typeof id === "string")) return null;
14636if (!Array.isArray(candidate.tombstoneIds) || !candidate.tombstoneIds.every((id) => typeof id === "string")) return null;
14637return {
14638version: 2,
14639revision: Number.isSafeInteger(candidate.revision) && Number(candidate.revision) >= 0 ? Number(candidate.revision) : 0,
14640cardIds: [...new Set(candidate.cardIds)].sort(),
14641tombstoneIds: [...new Set(candidate.tombstoneIds)].sort()
14642};
14643}
14644function indexForDeck(deck, revision = 0) {
14645return {
14646version: 2,
14647revision,
14648cardIds: Object.keys(deck.cards).sort(),
14649tombstoneIds: Object.keys(deck.tombstones ?? {}).sort()
14650};
14651}
14652function sameStoredValue(left, right) {
14653if (left === right) return true;
14654if (left === void 0 || right === void 0) return false;
14655return JSON.stringify(left) === JSON.stringify(right);
14656}
14657function cardStorageKey(id) {
14658return `${CARD_KEY_PREFIX}${encodeURIComponent(id)}`;
14659}
14660function tombstoneStorageKey(id) {
14661return `${TOMBSTONE_KEY_PREFIX}${encodeURIComponent(id)}`;
14662}
14663let localDeckMutation = Promise.resolve();
14664const LOCAL_DECK_LEASE = {
14665leaseMs: STORAGE_WORK_LEASE_MS,
14666guards: isLocalYomuSrsStorageKey,
14667onWait: reportSaveWaitingForAnotherTab
14668};
14669const localDeckMutationListeners = new Set();
14670class LocalYomuSrsRepository {
14671constructor(now = () => Date.now()) {
14672this.now = now;
14673}
14674store = new LocalYomuSrsStore();
14675async importBatch(batch) {
14676return this.mutateDeck((deck) => {
14677let imported = 0;
14678let skipped = 0;
14679for (const item of batch.items) {
14680const card = this.cardFromImportItem(item, batch.importedAt);
14681if (!card) {
14682skipped++;
14683continue;
14684}
14685const existing = deck.cards[card.id];
14686deck.cards[card.id] = existing ? mergeStoredYomuSrsCards(existing, card) : card;
14687if (existing) skipped++;
14688else imported++;
14689}
14690return { imported, skipped };
14691});
14692}
14693/** Atomically upserts one semantic card and one idempotent Academy provenance. */
14694async collectAcademyVocabulary(input) {
14695return this.mutateDeck((deck) => {
14696const now = this.now();
14697const result = upsertAcademyVocabulary(deck, input, now);
14698return {
14699cardId: result.card.id,
14700cardCreated: result.cardCreated,
14701provenanceAdded: result.provenanceAdded,
14702provenanceCount: result.provenanceCount,
14703card: this.toReviewable(result.card, now)
14704};
14705});
14706}
14707/** Reads the shared deck without changing any card or schedule. */
14708async academySyllabusProgress(items) {
14709const deck = await this.readDeck();
14710const identities = new Set(items.map((item) => canonicalStudyCardIdentity(item.expression, item.reading).key));
14711const seeded = [...identities].filter((id) => Boolean(deck.cards[id])).length;
14712return { total: identities.size, seeded, unseeded: identities.size - seeded };
14713}
14714/**
14715* Removes only the requested provenance. Independent, multiply-sourced,
14716* or reviewed cards are retained even when their final Academy source is undone.
14717*/
14718async removeAcademyVocabularyProvenance(cardId, provenanceId) {
14719return this.mutateDeck((deck) => {
14720const now = this.now();
14721const result = removeAcademyVocabularyProvenance(deck, cardId, provenanceId, now);
14722return {
14723provenanceRemoved: result.provenanceRemoved,
14724cardDeleted: result.cardDeleted,
14725reason: result.reason,
14726...result.card ? { card: this.toReviewable(result.card, now) } : {}
14727};
14728});
14729}
14730async collection(limit = 50, options = {}) {
14731const now = this.now();
14732return Object.values((await this.readDeck()).cards).filter(inRequestedLanguage(options)).sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id)).slice(0, normalizedQueueLimit(limit)).map((card) => this.toReviewable(card, now));
14733}
14734async queue(limit = 50, options = {}) {
14735const now = this.now();
14736const cards = Object.values((await this.readDeck()).cards).filter((card) => card.reviewEnabled !== false).filter(inRequestedLanguage(options));
14737const cap = normalizedQueueLimit(limit);
14738const byDue = (a, b) => a.dueAt - b.dueAt || a.createdAt - b.createdAt;
14739const due = cards.filter((card) =>
14739 card.dueAt <= now).sort(byDue);
14740const dueNew = due.filter((card) => card.reviews === 0).length;
14741const dueReviews = due.length - dueNew;
14742const queue = due.slice(0, cap).map((card) => this.toReviewable(card, now));
14743return {
14744providerId: "yomu-local",
14745fetchedAt: now,
14746cards: queue,
14747dueCount: dueReviews,
14748newCount: dueNew,
14749reviewCount: due.length
14750};
14751}
14752async stats(options = {}) {
14753const now = this.now();
14754const cards = Object.values((await this.readDeck()).cards).filter((card) => card.reviewEnabled !== false).filter(inRequestedLanguage(options));
14755const today = startOfLocalDay(now);
14756return {
14757providerId: "yomu-local",
14758fetchedAt: now,
14759reviewsDue: cards.filter((card) => card.dueAt <= now).length,
14760reviewsToday: cards.filter((card) => (card.lastReviewAt ?? 0) >= today).length,
14761newToday: cards.filter((card) => card.createdAt >= today).length,
14762levelCounts: {
14763new: cards.filter((card) => card.reviews === 0).length,
14764learning: cards.filter((card) => card.reviews > 0 && card.intervalDays < 21).length,
14765known: cards.filter((card) => card.intervalDays >= 21).length
14766}
14767};
14768}
14769/** Read the normalized encrypted-sync payload without exposing the storage key. */
14770async snapshot() {
14771return structuredClone(await this.readDeck());
14772}
14773/** Merge a decrypted remote snapshot using the deck's schedule conflict rules. */
14774async mergeSnapshot(value, options = {}) {
14775return this.mutateDeck((deck) => {
14776const merged = mergeStoredYomuSrsDecks(deck, value);
14777deck.cards = merged.cards;
14778deck.tombstones = merged.tombstones;
14779return structuredClone(merged);
14780}, options.notifyMutations !== false);
14781}
14782/** Resolves an arbitrary parse batch against one consistent deck snapshot. */
14783async lookupCards(items) {
14784const now = this.now();
14785const deck = await this.readDeck();
14786const cards = new Map();
14787for (const item of items) {
14788try {
14789const identity = canonicalStudyCardIdentity(item.expression, item.reading, {
14790partOfSpeech: item.partOfSpeech,
14791language: item.language
14792});
14793const stored = deck.cards[identity.key];
14794if (stored) cards.set(identity.key, this.toReviewable(stored, now));
14795} catch {
14796}
14797}
14798return [...cards.values()];
14799}
14800async startReview(cardId) {
14801return this.mutateDeck((deck) => {
14802const card = deck.cards[cardId];
14803if (!card) throw new Error("Saved word not found.");
14804const now = this.now();
14805if (card.reviewEnabled === false) {
14806delete card.reviewEnabled;
14807card.dueAt = now;
14808card.updatedAt = now;
14809}
14810return this.toReviewable(card, now);
14811});
14812}
14813async review(request) {
14814return this.mutateDeck((deck) => {
14815const now = this.now();
14816const identity = canonicalStudyCardIdentity(request.card.expression, request.card.reading, {
14817partOfSpeech: request.card.partOfSpeech,
14818language: request.card.language
14819});
14820const { reviewEnabled: _saved, ...existing } = deck.cards[request.card.providerCardId] ?? deck.cards[identity.key] ?? this.cardFromReviewable(request.card, now);
14821const updated = scheduleReviewedCard({ ...existing, id: identity.key }, request.grade, now);
14822if (request.card.providerCardId !== identity.key && deck.cards[request.card.providerCardId]) {
14823delete deck.cards[request.card.providerCardId];
14824deck.tombstones = { ...deck.tombstones ?? {}, [request.card.providerCardId]: now };
14825}
14826deck.cards[identity.key] = updated;
14827if ((deck.tombstones?.[identity.key] ?? -1) < updated.updatedAt) delete deck.tombstones?.[identity.key];
14828return { card: this.toReviewable(updated, now), raw: updated };
14829});
14830}
14831async mine(request) {
14832return this.mutateDeck((deck) => {
14833const now = this.now();
14834const candidate = this.cardFromImportItem({
14835expression: request.expression,
14836reading: request.reading,
14837partOfSpeech: request.partOfSpeech,
14838language: request.language,
14839meanings: request.meaning ? [request.meaning] : [],
14840sentence: request.sentence,
14841sourceUrl: request.sourceUrl,
14842sourceTitle: request.sourceTitle
14843}, now);
14844if (!candidate) throw new TypeError("Vocabulary expression is required.");
14845candidate.reviewEnabled = false;
14846const existing = deck.cards[candidate.id];
14847const stored = existing ? {
14848...mergeStoredYomuSrsCards(existing, candidate),
14849dueAt: existing.dueAt,
14850lastReviewAt: existing.lastReviewAt,
14851reviews: existing.reviews,
14852lapses: existing.lapses,
14853intervalDays: existing.intervalDays,
14854ease: existing.ease,
14855reviewEnabled: existing.reviewEnabled
14856} : candidate;
14857deck.cards[candidate.id] = stored;
14858if ((deck.tombstones?.[candidate.id] ?? -1) < stored.updatedAt) delete deck.tombstones?.[candidate.id];
14859return {
14860card: this.toReviewable(stored, now),
14861raw: { imported: existing ? 0 : 1, skipped: existing ? 1 : 0 }
14862};
14863});
14864}
14865readDeck() {
14866const result = localDeckMutation.then(() => this.readDeckUncoordinated());
14867localDeckMutation = result.then(() => void 0, () => void 0);
14868return result;
14869}
14870async readDeckUncoordinated() {
14871return this.store.read();
14872}
14873writeDeck(previous, deck) {
14874return this.store.write(previous, deck);
14875}
14876mutateDeck(operation, notifyMutations = true) {
14877const result = localDeckMutation.then(async () => {
14878const attempt2 = {};
14879try {
14880await withGmStorageLease("local-yomu-srs-deck", async () => {
14881const deck = await this.readDeckUncoordinated();
14882const previous = structuredClone(deck);
14883const value = operation(deck);
14884attempt2.save = { value, previous, next: normalizeStoredYomuSrsDeck(deck) };
14885await this.writeDeck(previous, attempt2.save.next);
14886}, LOCAL_DECK_LEASE);
14887} catch (error) {
14888await this.confirmInterruptedSave(attempt2.save, error);
14889}
14890const save = attempt2.save;
14891if (notifyMutations) notifyLocalDeckMutations(changedCardIds(save.previous, save.next));
14892return save.value;
14893});
14894localDeckMutation = result.then(() => void 0, () => void 0);
14895return result;
14896}
14897async confirmInterruptedSave(save, error) {
14898const landed = save && isLocalYomuSrsSaveInterrupted(error) && await this.landedBeforeInterruption(save).catch(() => false);
14899if (!landed) throw deckSaveError(error);
14900}
14901landedBeforeInterruption(save) {
14902return withGmStorageLease("local-yomu-srs-deck", async () => {
14903const stored = await this.readDeckUncoordinated();
14904if (changedCardIds(save.previous, save.next).some((id) => deckRecordsDiffer(stored, save.next, id))) return false;
14905await this.writeDeck(stored, stored);
14906return true;
14907}, LOCAL_DECK_LEASE);
14908}
14909cardFromImportItem(item, now) {
14910let identity;
14911try {
14912identity = canonicalStudyCardIdentity(item.expression, item.reading, {
14913partOfSpeech: item.partOfSpeech,
14914language: item.language
14915});
14916} catch {
14917return null;
14918}
14919return {
14920id: identity.key,
14921expression: identity.expression,
14922reading: identity.reading,
14923...identity.partOfSpeech ? { partOfSpeech: identity.partOfSpeech } : {},
14924...identity.language !== "ja" ? { language: identity.language } : {},
14925meanings: uniqueTrimmedStrings(item.meanings ?? []),
14926sentence: item.sentence?.trim() || void 0,
14927sourceProviderId: item.sourceProviderId,
14928sourceCardId: item.sourceCardId,
14929sourceUrl: item.sourceUrl,
14930sourceTitle: item.sourceTitle?.trim() || void 0,
14931tags: uniqueTrimmedStrings(item.tags ?? []),
14932dueAt: item.dueAt ?? now,
14933lastReviewAt: null,
14934createdAt: now,
14935updatedAt: now,
14936reviews: 0,
14937lapses: 0,
14938intervalDays: 0,
14939ease: 2.5,
14940retainWithoutAcademyProvenance: true,
14941academyProvenance: {}
14942};
14943}
14944cardFromReviewable(card, now) {
14945const identity = canonicalStudyCardIdentity(card.expression, card.reading, {
14946partOfSpeech: card.partOfSpeech,
14947language: card.language
14948});
14949return {
14950id: identity.key,
14951expression: identity.expression,
14952reading: identity.reading,
14953...identity.partOfSpeech ? { partOfSpeech: identity.partOfSpeech } : {},
14954...identity.language !== "ja" ? { language: identity.language } : {},
14955meanings: card.meanings.flatMap((meaning) => meaning.glosses),
14956sourceProviderId: card.providerId,
14957sourceCardId: card.providerCardId,
14958sourceUrl: card.sourceUrl,
14959sourceTitle: card.sourceTitle,
14960dueAt: card.dueAt ?? now,
14961lastReviewAt: card.lastReviewAt ?? null,
14962createdAt: now,
14963updatedAt: now,
14964reviews: 0,
14965lapses: 0,
14966intervalDays: 0,
14967ease: 2.5,
14968retainWithoutAcademyProvenance: true,
14969academyProvenance: {}
14970};
14971}
14972toReviewable(card, now) {
14973return {
14974providerId: "yomu-local",
14975providerCardId: card.id,
14976providerReviewId: card.id,
14977kind: "vocabulary",
14978expression: card.expression,
14979reading: card.reading,
14980...card.partOfSpeech ? { partOfSpeech: card.partOfSpeech } : {},
14981...card.language ? { language: card.language } : {},
14982meanings: meaningsFromGlosses(card.meanings),
14983sentence: card.sentence,
14984state: localCardState(card, now),
14985srsLevel: localSrsLevel(card),
14986dueAt: card.reviewEnabled === false ? void 0 : card.dueAt,
14987lastReviewAt: card.lastReviewAt,
14988sourceUrl: card.sourceUrl,
14989sourceTitle: card.sourceTitle,
14990raw: card
14991};
14992}
14993}
14994function sameStoredCard(left, right) {
14995if (left === right) return true;
14996if (!left || !right) return false;
14997return JSON.stringify(left) === JSON.stringify(right);
14998}
14999function deckSaveError(error) {
15000return isStorageLeaseLapsed(error) ? new LocalYomuSrsStorageError({ cause: error }) : error;
15001}
15002function deckRecordsDiffer(left, right, id) {
15003return !sameStoredCard(left.cards[id], right.cards[id]) || left.tombstones?.[id] !== right.tombstones?.[id];
15004}
15005function changedCardIds(previous, next) {
15006const ids = new Set([
15007...Object.keys(previous.cards),
15008...Object.keys(next.cards),
15009...Object.keys(previous.tombstones ?? {}),
15010...Object.keys(next.tombstones ?? {})
15011]);
15012return [...ids].filter((id) => deckRecordsDiffer(previous, next, id));
15013}
15014function notifyLocalDeckMutations(changed) {
15015localDeckMutationListeners.forEach((listener) => {
15016try {
15017listener(changed);
15018} catch {
15019}
15020});
15021}
15022function createYomuLocalSrsAdapter(repository = new LocalYomuSrsRepository()) {
15023return {
15024id: "yomu-local",
15025label: ACADEMY_SRS_LABEL,
15026capabilities: { stats: true, queue: true, review: true, mine: true, import: true },
15027hasCredential: () => true,
15028verify: async () => true,
15029stats: (options) => repository.stats(options),
15030queue: (limit, options) => repository.queue(limit, options),
15031collection: (limit, options) => repository.collection(limit, options),
15032startReview: (cardId) => repository.startReview(cardId),
15033review: (request) => repository.review(request),
15034mine: (request) => repository.mine(request),
15035lookupCards: (items) => repository.lookupCards(items),
15036importBatch: (batch) => repository.importBatch(batch)
15037};
15038}
15039function scheduleReviewedCard(card, grade, now) {
15040const failed = grade === "nothing" || grade === "something" || grade === "fail" || grade === "again";
15041const intervalDays = failed ? 0 : nextIntervalDays(card, grade);
15042return {
15043...card,
15044reviews: card.reviews + 1,
15045lapses: card.lapses + (failed ? 1 : 0),
15046intervalDays,
15047ease: Math.min(3.2, Math.max(1.3, card.ease + easeDelta(grade))),
15048dueAt: now + (failed ? 10 * 6e4 : intervalDays * 864e5),
15049lastReviewAt: now,
15050updatedAt: now
15051};
15052}
15053function nextIntervalDays(card, grade) {
15054if (card.reviews <= 0) return grade === "easy" ? 4 : grade === "hard" ? 1 : 2;
15055const multiplier = grade === "easy" ? card.ease + 0.7 : grade === "hard" ? 1.2 : card.ease;
15056return Math.max(1, Math.round(Math.max(1, card.intervalDays) * multiplier));
15057}
15058function easeDelta(grade) {
15059if (grade === "easy") return 0.15;
15060if (grade === "hard") return -0.15;
15061if (grade === "nothing" || grade === "something" || grade === "fail" || grade === "again") return -0.25;
15062return 0;
15063}
15064function meaningsFromGlosses(glosses) {
15065const normalized = uniqueTrimmedStrings(glosses.map((gloss) => gloss.trim()).filter(Boolean));
15066return normalized.length ? [{ glosses: normalized, partOfSpeech: [] }] : [];
15067}
15068function localCardState(card, now) {
15069if (card.reviewEnabled === false) return ["in-deck"];
15070if (card.reviews === 0) return ["new"];
15071if (card.dueAt <= now) return ["due"];
15072if (card.intervalDays >= 21) return ["known"];
15073return ["learning"];
15074}
15075function localSrsLevel(card) {
15076if (card.reviewEnabled === false) return "Saved";
15077if (card.reviews === 0) return "New";
15078if (card.intervalDays >= 21) return "Known";
15079if (card.intervalDays >= 7) return "Young";
15080return "Learning";
15081}
15082function startOfLocalDay(now) {
15083const date = new Date(now);
15084date.setHours(0, 0, 0, 0);
15085return date.getTime();
15086}
15087function inRequestedLanguage(options) {
15088const language = options.language ? canonicalLanguageTag(options.language) : "";
15089return (card) => !language || canonicalLanguageTag(card.language ?? "ja") === language;
15090}
15091function normalizedQueueLimit(limit) {
15092if (Number.isNaN(limit) || limit <= 0) return 0;
15093return Number.isFinite(limit) ? Math.floor(limit) : Number.MAX_SAFE_INTEGER;
15094}
15095const settings = aggregateRuntimeModules().settings;
15096const {
15097DEFAULT_AUDIO_SOURCES,
15098DEFAULT_OVERLAY_BACKGROUND_COLOR,
15099DEFAULT_POPUP_FONT_FAMILY,
15100DEFAULT_READER_FONT_FAMILY,
15101DEFAULT_SETTINGS,
15102EXPLICIT_USER_SETTINGS_STORAGE_KEY,
15103NO_EXPLICIT_USER_CHOICE,
15104PREFERRED_JAPANESE_SITE_LANGUAGE_STORAGE_KEY,
15105SETTINGS_STORAGE_KEY,
15106SETTINGS_STORAGE_KEYS,
15107accessibleOcrBackgroundColor,
15108accessibleOcrBackgroundOpacity,
15109accentToRgba,
15110applyUrlBootstrapSettings,
15111beginSettingsResetGuard,
15112changedSettingsKeys,
15113coupledSettingsIntentKeys,
15114defaultDictionaryLookupLinks,
15115defaultLookupLinkMode,
15116deleteSettingsStorage,
15117effectiveFuriganaMode,
15118effectiveReaderColorSource,
15119effectiveReaderTextColorSource,
15120effectiveSubtitleColorSource,
15121effectiveSubtitleTextColorSource,
15122endSettingsResetGuard,
15123formatShortcutEvent,
15124isPopupLookupEnabled,
15125loadSettings,
15126matchesShortcut: matchesShortcut$1,
15127normalizeAudioSource,
15128normalizeAudioSubSources,
15129normalizeAudioSources,
15130normalizeDictionaryPreferences,
15131normalizeInterfaceLanguage,
15132normalizeOcrProvider,
15133normalizeReaderSettings,
15134sanitizeAccentColor,
15135saveSettings,
15136shortcutIsPressed,
15137shouldLookupAnkiStatus,
15138shouldLookupBunproWordStates,
15139subscribeToSettingsStorageChanges
15140} = settings;
15141const FALLBACK_HEX_COLOR = "#000000";
15142function normalizeHexColor(color) {
15143return /^#[0-9a-f]{6}$/i.test(color) ? color.toLowerCase() : FALLBACK_HEX_COLOR;
15144}
15145function sharedContrastRatio(a, b, normalizeColor = normalizeHexColor) {
15146const l1 = relativeLuminance(a, normalizeColor);
15147const l2 = relativeLuminance(b, normalizeColor);
15148const light = Math.max(l1, l2);
15149const dark = Math.min(l1, l2);
15150return (light + 0.05) / (dark + 0.05);
15151}
15152function relativeLuminance(color, normalizeColor = normalizeHexColor) {
15153const [red, green, blue] = sharedHexToRgb(color, normalizeColor).map((value) => {
15154const channel = value / 255;
15155return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
15156});
15157return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
15158}
15159function sharedMixHex(from, to, amount, normalizeColor = normalizeHexColor) {
15160const a = sharedHexToRgb(from, normalizeColor);
15161const b = sharedHexToRgb(to, normalizeColor);
15162return `#${a.map((value, index) => Math.round(value + (b[index] - value) * amount).toString(16).padStart(2, "0")).join("")}`;
15163}
15164function sharedHexToRgb(color, normalizeColor = normalizeHexColor) {
15165const safe = normalizeHexColor(normalizeColor(color));
15166return [
15167parseInt(safe.slice(1, 3), 16),
15168parseInt(safe.slice(3, 5), 16),
15169parseInt(safe.slice(5, 7), 16)
15170];
15171}
15172function cssColorToHex(value, backdrop) {
15173const color = cssColorToRgba(value);
15174if (!color) return null;
15175if (color.alpha >= 1) return rgbaToHex(color);
15176return backdrop ? rgbaToHex(blendRgba(color, backdrop)) : null;
15177}
15178function cssColorToRgba(value) {
15179const color = value.trim().toLowerCase();
15180if (!color || color === "transparent") return { red: 0, green: 0, blue: 0, alpha: 0 };
15181const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);
15182if (hex) return hexToRgbaColor(expandHexColor(hex[1]));
15183if (color.startsWith("rgb(") || color.startsWith("rgba(")) return parseRgbFunction(color);
15184if (color.startsWith("color(srgb ")) return parseSrgbFunction(color);
15185if (color.startsWith("oklab(")) return parseOklabFunction(color);
15186if (color.startsWith("oklch(")) return parseOklchFunction(color);
15187return probeCssColor(color);
15188}
15189const probedColors = new Map();
15190let probeContext;
15191function probeCssColor(color) {
15192const cached = probedColors.get(color);
15193if (cached !== void 0) return cached;
15194if (probeContext === void 0) {
15195try {
15196probeContext = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
15197} catch {
15198probeContext = null;
15199}
15200}
15201let parsed = null;
15202if (probeContext) {
15203try {
15204probeContext.fillStyle = "#010203";
15205probeContext.fillStyle = color;
15206const serialized = String(probeContext.fillStyle).toLowerCase();
15207if (serialized !== "#010203") {
15208if (serialized.startsWith("#")) {
15209const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(serialized);
15210parsed = hex ? hexToRgbaColor(expandHexColor(hex[1])) : null;
15211} else if (serialized.startsWith("rgb")) {
15212parsed = parseRgbFunction(serialized);
15213} else if (serialized.startsWith("color(srgb ")) {
15214parsed = parseSrgbFunction(serialized);
15215}
15216if (!parsed) parsed = probePixelColor(probeContext, color);
15217}
15218} catch {
15219parsed = null;
15220}
15221}
15222if (probedColors.size > 512) probedColors.clear();
15223probedColors.set(color, parsed);
15224return parsed;
15225}
15226function probePixelColor(context, color) {
15227try {
15228context.canvas.width = 1;
15229context.canvas.height = 1;
15230context.clearRect(0, 0, 1, 1);
15231context.fillStyle = color;
15232context.fillRect(0, 0, 1, 1);
15233const [red, green, blue, alphaByte] = context.getImageData(0, 0, 1, 1).data;
15234return { red, green, blue, alpha: alphaByte / 255 };
15235} catch {
15236return null;
15237}
15238}
15239function blendRgba(foreground, background) {
15240const alpha = foreground.alpha + background.alpha * (1 - foreground.alpha);
15241if (alpha <= 0) return { red: 0, green: 0, blue: 0, alpha: 0 };
15242const channel = (front, back) => Math.round((front * foreground.alpha + back * background.alpha * (1 - foreground.alpha)) / alpha);
15243return {
15244red: channel(foreground.red, background.red),
15245green: channel(foreground.green, background.green),
15246blue: channel(foreground.blue, background.blue),
15247alpha
15248};
15249}
15250function rgbaToHex(color) {
15251return `#${[color.red, color.green, color.blue].map((value) => clampChannel(value).toString(16).padStart(2, "0")).join("")}`;
15252}
15253function expandHexColor(value) {
15254return value.length === 3 ? `#${value[0]}${value[0]}${value[1]}${value[1]}${value[2]}${value[2]}` : `#${value}`;
15255}
15256function hexToRgbaColor(color) {
15257const [red, green, blue] = sharedHexToRgb(color, sanitizeAccentColor);
15258return { red, green, blue, alpha: 1 };
15259}
15260function parseRgbFunction(value) {
15261const parts = colorFunctionNumbers(value);
15262if (parts.length < 3) return null;
15263return {
15264red: parseRgbChannel(parts[0]),
15265green: parseRgbChannel(parts[1]),
15266blue: parseRgbChannel(parts[2]),
15267alpha: parts[3] ? parseAlpha(parts[3]) : 1
15268};
15269}
15270function parseSrgbFunction(value) {
15271const parts = colorFunctionNumbers(value);
15272if (parts.length < 3) return null;
15273return {
15274red: parseSrgbChannel(parts[0]),
15275green: parseSrgbChannel(parts[1]),
15276blue: parseSrgbChannel(parts[2]),
15277alpha: parts[3] ? parseAlpha(parts[3]) : 1
15278};
15279}
15280function parseOklabFunction(value) {
15281const parts = colorFunctionNumbers(value);
15282if (parts.length < 3) return null;
15283return oklabToRgba({
15284lightness: parseOklabLightness(parts[0]),
15285a: parseOklabAxis(parts[1]),
15286b: parseOklabAxis(parts[2]),
15287alpha: parts[3] ? parseAlpha(parts[3]) : 1
15288});
15289}
15290function parseOklchFunction(value) {
15291const parts = colorFunctionNumbers(value);
15292if (parts.length < 3) return null;
15293const chroma = parseOklabAxis(parts[1]);
15294const hue = Number.parseFloat(parts[2]) * Math.PI / 180;
15295return oklabToRgba({
15296lightness: parseOklabLightness(parts[0]),
15297a: chroma * Math.cos(hue),
15298b: chroma * Math.sin(hue),
15299alpha: parts[3] ? parseAlpha(parts[3]) : 1
15300});
15301}
15302function colorFunctionNumbers(value) {
15303return value.match(/-?\d*\.?\d+%?/g) ?? [];
15304}
15305function parseRgbChannel(value) {
15306return clampChannel(value.endsWith("%") ? Number.parseFloat(value) * 2.55 : Number.parseFloat(value));
15307}
15308function parseSrgbChannel(value) {
15309return clampChannel(value.endsWith("%") ? Number.parseFloat(value) * 2.55 : Number.parseFloat(value) * 255);
15310}
15311function parseOklabLightness(value) {
15312const parsed = Number.parseFloat(value);
15313if (!Number.isFinite(parsed)) return 0;
15314return value.endsWith("%") ? parsed / 100 : parsed;
15315}
15316function parseOklabAxis(value) {
15317const parsed = Number.parseFloat(value);
15318if (!Number.isFinite(parsed)) return 0;
15319return value.endsWith("%") ? parsed / 100 : parsed;
15320}
15321function oklabToRgba(color) {
15322const l = color.lightness + 0.3963377774 * color.a + 0.2158037573 * color.b;
15323const m = color.lightness - 0.1055613458 * color.a - 0.0638541728 * color.b;
15324const s = color.lightness - 0.0894841775 * color.a - 1.291485548 * color.b;
15325const long = l ** 3;
15326const medium = m ** 3;
15327const short = s ** 3;
15328return {
15329red: linearSrgbToChannel(4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short),
15330green: linearSrgbToChannel(-1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short),
15331blue: linearSrgbToChannel(-0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short),
15332alpha: color.alpha
15333};
15334}
15335function linearSrgbToChannel(value) {
15336const channel = value <= 31308e-7 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
15337return clampChannel(channel * 255);
15338}
15339function parseAlpha(value) {
15340const alpha = value.endsWith("%") ? Number.parseFloat(value) / 100 : Number.parseFloat(value);
15341return Math.max(0, Math.min(1, Number.isFinite(alpha) ? alpha : 1));
15342}
15343function clampChannel(value) {
15344return Math.max(0, Math.min(255, Math.round(Number.isFinite(value) ? value : 0)));
15345}
15346function readableOn(color, background, targetContrast) {
15347const safe = sanitizeAccentColor(color);
15348if (contrastRatio(safe, background) >= targetContrast) return safe;
15349const toward = contrastRatio(background, CORE_COLOR_TOKENS.black) > contrastRatio(background, CORE_COLOR_TOKENS.white) ? CORE_COLOR_TOKENS.black : CORE_COLOR_TOKENS.white;
15350for (let amount = 0.08; amount <= 1; amount += 0.08) {
15351const mixed = mixHex(safe, toward, amount);
15352if (contrastRatio(mixed, background) >= targetContrast) return mixed;
15353}
15354return toward;
15355}
15356function readableOnAll(color, backgrounds, targetContrast) {
15357const safe = sanitizeAccentColor(color);
15358if (backgrounds.every((background) => contrastRatio(safe, background) >= targetContrast)) return safe;
15359const candidates = [CORE_COLOR_TOKENS.black, CORE_COLOR_TOKENS.white].map((toward) => closestReadableMix(safe, toward, backgrounds, targetContrast)).filter((candidate) => Boolean(candidate)).sort((a, b) => a.amount - b.amount || b.contrast - a.contrast);
15360if (candidates[0]) return candidates[0].color;
15361return [CORE_COLOR_TOKENS.black, CORE_COLOR_TOKENS.white].map((fallback) => ({ color: fallback, contrast: minContrast(fallback, backgrounds) })).sort((a, b) => b.contrast - a.contrast)[0]?.color ?? CORE_COLOR_TOKENS.black;
15362}
15363function contrastRatio(a, b) {
15364return sharedContrastRatio(a, b, sanitizeAccentColor);
15365}
15366function mixHex(from, to, amount) {
15367return sharedMixHex(from, to, amount, sanitizeAccentColor);
15368}
15369function isHexColor(value) {
15370return /^#[0-9a-f]{6}$/i.test(value);
15371}
15372function closestReadableMix(color, toward, backgrounds, targetContrast) {
15373for (let amount = 0.04; amount <= 1; amount += 0.04) {
15374const mixed = mixHex(color, toward, amount);
15375const contrast = minContrast(mixed, backgrounds);
15376if (contrast >= targetContrast) return { color: mixed, amount, contrast };
15377}
15378return null;
15379}
15380function minContrast(color, backgrounds) {
15381return Math.min(...backgrounds.map((background) => contrastRatio(color, background)));
15382}
15383const TRANSPARENT_DARK_PAGE_FALLBACK = "#181b20";
15384const LIGHT_TEXT_MAX_CONTRAST_ON_WHITE = 2;
15385const OPAQUE_WHITE = { red: 255, green: 255, blue: 255, alpha: 1 };
15386function probePageBackground(element) {
15387const ancestors = [];
15388for (let node = element.parentElement; node; node = node.parentElement) ancestors.push(node);
15389let painted = false;
15390let imageBackdrop = false;
15391let unknownBase = false;
15392let rgba = OPAQUE_WHITE;
15393for (const ancestor of ancestors.reverse()) {
15394const style = getComputedStyle(ancestor);
15395imageBackdrop ||= Boolean(style.backgroundImage && style.backgroundImage !== "none");
15396const color = cssColorToRgba(style.backgroundColor);
15397if (!color) {
15398unknownBase = true;
15399continue;
15400}
15401if (color.alpha <= 0) continue;
15402if (color.alpha >= 1) unknownBase = false;
15403rgba = blendRgba(color, rgba);
15404painted = true;
15405}
15406if (painted && !unknownBase) return { background: pageBackgroundFromRgba(rgba), imageBackdrop: false };
15407return { background: unpaintedBackground(element.parentElement ?? element), imageBackdrop };
15408}
15409function documentBackgroundLooksDark() {
15410if (typeof document === "undefined" || !document.body) return false;
15411let rgba = OPAQUE_WHITE;
15412let found = false;
15413for (const element of [document.documentElement, document.body]) {
15414const color = cssColorToRgba(getComputedStyle(element).backgroundColor);
15415if (!color || color.alpha <= 0) continue;
15416rgba = blendRgba(color, rgba);
15417found = true;
15418}
15419const background = found ? pageBackgroundFromRgba(rgba) : unpaintedBackground(document.body);
15420return contrastRatio(CORE_COLOR_TOKENS.white, background.hex) > contrastRatio(CORE_COLOR_TOKENS.black, background.hex);
15421}
15422function unpaintedBackground(context) {
15423if (darkColorSchemeCanvas(context)) return pageBackgroundFromCss(TRANSPARENT_DARK_PAGE_FALLBACK);
15424const text2 = nearestParsedTextColor(context);
15425return text2 && contrastRatio(text2, CORE_COLOR_TOKENS.white) < LIGHT_TEXT_MAX_CONTRAST_ON_WHITE ? pageBackgroun
15425dFromCss(TRANSPARENT_DARK_PAGE_FALLBACK) : pageBackgroundFromCss(CORE_COLOR_TOKENS.white);
15426}
15427function nearestParsedTextColor(context) {
15428for (const scope of [context, document.body, document.documentElement]) {
15429if (!scope) continue;
15430const hex = cssColorToHex(getComputedStyle(scope).color);
15431if (hex) return hex;
15432}
15433return null;
15434}
15435function darkColorSchemeCanvas(context) {
15436for (const scope of [context, document.body, document.documentElement]) {
15437if (!scope) continue;
15438const tokens = new Set(getComputedStyle(scope).colorScheme.toLowerCase().split(/\s+/).filter(Boolean));
15439if (!tokens.has("dark")) {
15440if (tokens.has("light")) return false;
15441continue;
15442}
15443if (tokens.has("light")) return prefersDarkColorScheme();
15444return true;
15445}
15446return false;
15447}
15448function prefersDarkColorScheme() {
15449if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
15450try {
15451return window.matchMedia("(prefers-color-scheme: dark)").matches;
15452} catch {
15453return false;
15454}
15455}
15456function pageBackgroundFromCss(color) {
15457return pageBackgroundFromRgba(cssColorToRgba(color) ?? OPAQUE_WHITE);
15458}
15459function pageBackgroundFromRgba(rgba) {
15460const hex = rgbaToHex(rgba);
15461return { css: `rgb(${rgba.red}, ${rgba.green}, ${rgba.blue})`, hex, rgba };
15462}
15463const PAGE_WORD_SELECTOR = ".jpdb-reader-word";
15464const YOMU_SURFACE_SELECTOR = "[data-jpdb-reader-root], .jpdb-ocr-layer, .jpdb-subtitle-player, .jpdb-subtitle-list, .asbplayer-subtitles-container-bottom, .asbplayer-offscreen";
15465const TEXT_CONTRAST = 4.5;
15466const DECORATION_CONTRAST = 3;
15467const HIGHLIGHT_CONTRAST = 1.45;
15468const PASSIVE_CHROME_SELECTOR = 'button, [role="button"], [role="tab"], summary, label, .jpdb-reader-control-text-mirror, [data-jpdb-reader-passive-chrome="true"]';
15469const COLORED_READER_WORD_CLASSES = new Set([
15470"jpdb-new",
15471"jpdb-in-deck",
15472"jpdb-learning",
15473"jpdb-known",
15474"jpdb-never-forget",
15475"jpdb-redundant",
15476"jpdb-due",
15477"jpdb-failed",
15478"jpdb-suspended",
15479"jpdb-blacklisted",
15480"jpdb-locked",
15481"anki-new",
15482"anki-learning",
15483"anki-known",
15484"anki-due",
15485"anki-failed",
15486"anki-suspended",
15487"jpdb-pitch-heiban",
15488"jpdb-pitch-atamadaka",
15489"jpdb-pitch-nakadaka",
15490"jpdb-pitch-odaka"
15491]);
15492const NEUTRAL_CLEARED_CONTRAST_VARS = RENDERED_WORD_CONTRAST_VARS.filter(
15493(name) => name !== "--jpdb-reader-highlight-backdrop"
15494);
15495const pendingHoverContrastRefresh = new WeakSet();
15496const appliedContrastState = new WeakMap();
15497function refreshReaderWordContrast(root = document) {
15498const plan = readerWordContrastPlan(readerWords(root));
15499const savedVars = temporarilyClearActiveContrastVars(plan);
15500const measurements = measureActiveReaderWords(plan.activeWords);
15501applyReaderWordContrastPlan(plan, savedVars, measurements);
15502}
15503function readerWordContrastPlan(words) {
15504const plan = {
15505activeWords: [],
15506activeBackgrounds: [],
15507unknownBackgroundWords: [],
15508unknownBackgrounds: [],
15509neutralWords: [],
15510neutralPageWords: [],
15511neutralPageBackgrounds: []
15512};
15513const backgroundByParent = new Map();
15514const cachedPageBackgroundFor = (word) => {
15515const parent = word.parentElement;
15516if (!parent) return probePageBackground(word);
15517const cached = backgroundByParent.get(parent);
15518if (cached) return cached;
15519const probed = probePageBackground(word);
15520backgroundByParent.set(parent, probed);
15521return probed;
15522};
15523words.forEach((word) => classifyReaderWordContrast(plan, word, cachedPageBackgroundFor));
15524return plan;
15525}
15526function classifyReaderWordContrast(plan, word, pageBackgroundFor) {
15527const hasAccessibleColor = hasAnkiAccessibleWordColor(word);
15528const hasInlineTextColor = Boolean(word.style.getPropertyValue("color"));
15529if (preserveExistingAnkiContrast(word, hasAccessibleColor, hasInlineTextColor)) return;
15530const handlers = {
15531reader: () => plan.neutralWords.push(word),
15532"neutral-page": () => addNeutralPageWord(plan, word, pageBackgroundFor(word).background),
15533"active-page": () => addActivePageWord(plan, word, pageBackgroundFor(word), hasAccessibleColor, hasInlineTextColor)
15534};
15535handlers[readerWordContrastSurface(word)]();
15536}
15537function hasAnkiAccessibleWordColor(word) {
15538return [
15539renderedWordHasAnkiState(word),
15540Boolean(word.style.getPropertyValue("--jpdb-reader-word-accessible-color"))
15541].every(Boolean);
15542}
15543const preservedAnkiContrastWords = new WeakSet();
15544function preserveAnkiContrastOnNextRefresh(word) {
15545preservedAnkiContrastWords.add(word);
15546}
15547function preserveExistingAnkiContrast(word, hasAccessibleColor, hasInlineTextColor) {
15548const preserve = [
15549preservedAnkiContrastWords.has(word),
15550hasAccessibleColor,
15551!hasInlineTextColor
15552].every(Boolean);
15553if (preserve) preservedAnkiContrastWords.delete(word);
15554return preserve;
15555}
15556function readerWordContrastSurface(word) {
15557return [
15558[Boolean(word.closest(YOMU_SURFACE_SELECTOR)), "reader"],
15559[isNeutralReaderWord(word), "neutral-page"],
15560[true, "active-page"]
15561].find(([matches]) => matches)?.[1];
15562}
15563function addNeutralPageWord(plan, word, background) {
15564plan.neutralPageWords.push(word);
15565plan.neutralPageBackgrounds.push(background);
15566}
15567function addActivePageWord(plan, word, probed, hasAccessibleColor, hasInlineTextColor) {
15568if (probed.imageBackdrop) {
15569addUnknownBackgroundWord(plan, word, probed.background, hasAccessibleColor, hasInlineTextColor);
15570return;
15571}
15572addMeasuredActiveWord(plan, word, probed.background, hasAccessibleColor, hasInlineTextColor);
15573}
15574function addUnknownBackgroundWord(plan, word, background, hasAccessibleColor, hasInlineTextColor) {
15575if ([hasAccessibleColor, !hasInlineTextColor].every(Boolean)) return;
15576plan.unknownBackgroundWords.push(word);
15577plan.unknownBackgrounds.push(background);
15578}
15579function addMeasuredActiveWord(plan, word, background, hasAccessibleColor, hasInlineTextColor) {
15580const hovered = word.matches(":hover, :focus");
15581scheduleHoveredWordRefresh(word, hovered);
15582const parentColor = getComputedStyle(word.parentElement ?? word).color;
15583if (readerWordContrastStateUnchanged(word, background, hovered, parentColor)) return;
15584if (shouldPreserveAccessibleHoverColor(word, background, hasAccessibleColor, hasInlineTextColor, hovered)) return;
15585plan.activeWords.push(word);
15586plan.activeBackgrounds.push(background);
15587}
15588function scheduleHoveredWordRefresh(word, hovered) {
15589if (hovered) scheduleHoverSettledContrastRefresh(word);
15590}
15591function readerWordContrastStateUnchanged(word, background, hovered, parentColor) {
15592const previous = appliedContrastState.get(word);
15593if (!previous) return false;
15594return [
15595previous.background === background.css,
15596previous.className === word.className,
15597previous.cssText === word.style.cssText,
15598previous.hovered === hovered,
15599previous.parentColor === parentColor
15600].every(Boolean);
15601}
15602function shouldPreserveAccessibleHoverColor(word, background, hasAccessibleColor, hasInlineTextColor, hovered) {
15603const eligible = [
15604hasAccessibleColor,
15605hovered,
15606!hasInlineTextColor
15607].every(Boolean);
15608if (!eligible) return false;
15609return existingAccessibleColorRemainsReadableOnHover(word, background);
15610}
15611function temporarilyClearActiveContrastVars(plan) {
15612return plan.activeWords.map((word, index) => {
15613const saved = RENDERED_WORD_CONTRAST_VARS.map((name) => ({
15614name,
15615value: word.style.getPropertyValue(name),
15616priority: word.style.getPropertyPriority(name)
15617}));
15618RENDERED_WORD_CONTRAST_VARS.forEach((name) => word.style.removeProperty(name));
15619word.style.setProperty("--jpdb-reader-highlight-backdrop", plan.activeBackgrounds[index].css);
15620return saved;
15621});
15622}
15623function measureActiveReaderWords(words) {
15624return words.map((word) => {
15625const style = getComputedStyle(word);
15626const parentStyle = getComputedStyle(word.parentElement ?? word);
15627return {
15628bg: style.backgroundColor,
15629hl: style.getPropertyValue("--jpdb-reader-word-highlight-source"),
15630fg: style.color,
15631deco: measuredWordDecorationColor(style),
15632parentFg: parentStyle.color,
15633hover: style.getPropertyValue("--jpdb-reader-hover"),
15634hovered: word.matches(":hover, :focus")
15635};
15636});
15637}
15638function applyReaderWordContrastPlan(plan, savedVars, measurements) {
15639plan.neutralWords.forEach((word) => clearContrastVars(word));
15640plan.neutralPageWords.forEach((word, index) =>
15640 applyNeutralPageBackdrop(word, plan.neutralPageBackgrounds[index]));
15641plan.unknownBackgroundWords.forEach((word, index) => applyUnknownBackgroundFallback(word, plan.unknownBackgrounds[index]));
15642plan.activeWords.forEach((word, index) => {
15643savedVars[index].forEach(({ name, value, priority: priority2 }) => {
15644if (value) word.style.setProperty(name, value, priority2);
15645});
15646applyWordContrastVars(word, plan.activeBackgrounds[index], measurements[index]);
15647appliedContrastState.set(word, {
15648background: plan.activeBackgrounds[index].css,
15649className: word.className,
15650cssText: word.style.cssText,
15651hovered: measurements[index].hovered,
15652parentColor: measurements[index].parentFg
15653});
15654});
15655}
15656function measuredWordDecorationColor(style) {
15657const underline = style.getPropertyValue("--jpdb-reader-word-underline").trim();
15658if (underline && !underline.includes("var(")) return underline;
15659const source = style.getPropertyValue("--jpdb-reader-word-decoration-source").trim();
15660return source || underline || style.textDecorationColor;
15661}
15662function applyWordContrastVars(word, background, m) {
15663word.style.setProperty("--jpdb-reader-page-bg", background.css);
15664word.style.setProperty("--jpdb-reader-highlight-backdrop", background.css);
15665word.style.removeProperty("--jpdb-reader-word-contrast-shadow");
15666const preserveHostPaint = isPassiveChromeWord(word);
15667const accessibleRgba = resolveHighlight(word, background, m.bg, m.hl, preserveHostPaint);
15668const accessibleHex = rgbaToHex(accessibleRgba);
15669const textBackdropHex = preserveHostPaint ? background.hex : accessibleHex;
15670const sourceText = cssColorToHex(m.fg, accessibleRgba);
15671const nativeText = cssColorToHex(m.parentFg, accessibleRgba) ?? bestTextColor(textBackdropHex);
15672const decoration = resolveDecorationHex(word, m.deco, accessibleRgba);
15673const textSource = sourceText ?? nativeText;
15674const textBackgrounds = preserveHostPaint ? [background.hex] : textBackdropsForMeasurement(m, textBackdropHex);
15675word.style.setProperty("--jpdb-reader-word-highlight-text", readableOnAll(nativeText, textBackgrounds, TEXT_CONTRAST));
15676word.style.setProperty("--jpdb-reader-word-accessible-color", readableOnAll(textSource, textBackgrounds, TEXT_CONTRAST));
15677if (decoration) word.style.setProperty("--jpdb-reader-word-accessible-underline", readableOn(decoration, accessibleHex, DECORATION_CONTRAST));
15678else word.style.removeProperty("--jpdb-reader-word-accessible-underline");
15679}
15680function isPassiveChromeWord(word) {
15681return word.classList.contains("jpdb-reader-passive-word") && Boolean(word.closest(PASSIVE_CHROME_SELECTOR));
15682}
15683function uniqueHexes(colors) {
15684return [...new Set(colors)];
15685}
15686function textBackdropsForMeasurement(m, textBackdropHex) {
15687const hoverBackdrop = hoveredTextBackdropHex(m.hover, textBackdropHex, m.hovered);
15688return uniqueHexes(hoverBackdrop ? [textBackdropHex, hoverBackdrop] : [textBackdropHex]);
15689}
15690function existingAccessibleColorRemainsReadableOnHover(word, background) {
15691const existingText = cssColorToHex(word.style.getPropertyValue("--jpdb-reader-word-accessible-color"), background.rgba);
15692if (!existingText) return false;
15693const existingHighlight = cssColorToHex(word.style.getPropertyValue("--jpdb-reader-word-accessible-highlight"), background.rgba);
15694const baseBackdrop = existingHighlight ?? background.hex;
15695const hoverBackdrop = hoveredTextBackdropHex(getComputedStyle(word).getPropertyValue("--jpdb-reader-hover"), baseBackdrop, true);
15696return uniqueHexes(hoverBackdrop ? [baseBackdrop, hoverBackdrop] : [baseBackdrop]).every((backdrop) => contrastRatio(existingText, backdrop) >= TEXT_CONTRAST);
15697}
15698function hoveredTextBackdropHex(hoverColor, textBackdropHex, hovered) {
15699if (!hovered) return null;
15700const hover = cssColorToRgba(hoverColor);
15701const backdrop = cssColorToRgba(textBackdropHex);
15702if (!hover || !backdrop || hover.alpha <= 0) return null;
15703return rgbaToHex(blendRgba(hover, backdrop));
15704}
15705function resolveHighlight(word, background, wordBgColor, highlight, preserveHostPaint = false) {
15706const colorRgba = cssColorToRgba(wordBgColor);
15707const hasPaint = !!colorRgba?.alpha;
15708const highlightRgba = hasPaint || preserveHostPaint ? null : paintRgba(highlight, word);
15709const rgba = hasPaint ? blendRgba(colorRgba, background.rgba) : highlightRgba && highlightRgba.alpha > 0 ? blendRgba(highlightRgba, background.rgba) : background.rgba;
15710if (hasPaint && !preserveHostPaint) {
15711const highlightHex = readableHighlightBackground(rgbaToHex(rgba), background.hex);
15712word.style.setProperty("--jpdb-reader-word-accessible-highlight", highlightHex);
15713return cssColorToRgba(highlightHex) ?? rgba;
15714}
15715word.style.removeProperty("--jpdb-reader-word-accessible-highlight");
15716return rgba;
15717}
15718function paintRgba(value, el) {
15719const direct = cssColorToRgba(value);
15720if (direct) return direct;
15721const probe = el.appendChild(document.createElement("span"));
15722probe.style.color = value;
15723const rgba = cssColorToRgba(getComputedStyle(probe).color);
15724probe.remove();
15725return rgba;
15726}
15727function resolveDecorationHex(word, decorationColor, accessibleRgba) {
15728const decorationColorRgba = cssColorToRgba(decorationColor) ?? paintRgba(decorationColor, word);
15729return decorationColorRgba && decorationColorRgba.alpha > 0 ? rgbaToHex(decorationColorRgba.alpha < 1 ? blendRgba(decorationColorRgba, accessibleRgba) : decorationColorRgba) : null;
15730}
15731function refreshReaderWordContrastForWord(word) {
15732refreshReaderWordContrast(word.parentElement ?? word);
15733}
15734function refreshContrastForChangedWords(words) {
15735const lines = new Set();
15736for (const word of words) {
15737if (word.isConnected) lines.add(word.parentElement ?? word);
15738}
15739lines.forEach((line) => refreshReaderWordContrast(line));
15740}
15741function isNeutralReaderWord(word) {
15742if (!word.classList.contains("jpdb-not-in-deck") && !word.classList.contains("anki-not-in-deck")) return false;
15743return !Array.from(word.classList).some((className) => COLORED_READER_WORD_CLASSES.has(className));
15744}
15745function scheduleHoverSettledContrastRefresh(word) {
15746if (pendingHoverContrastRefresh.has(word)) return;
15747pendingHoverContrastRefresh.add(word);
15748window.setTimeout(() => {
15749pendingHoverContrastRefresh.delete(word);
15750if (!word.isConnected) return;
15751refreshReaderWordContrastForWord(word);
15752}, 120);
15753}
15754function readerWords(root) {
15755const words = new Set();
15756if (root instanceof HTMLElement && root.matches(PAGE_WORD_SELECTOR)) words.add(root);
15757root.querySelectorAll(PAGE_WORD_SELECTOR).forEach((word) => words.add(word));
15758return [...words];
15759}
15760function bestTextColor(background) {
15761return contrastRatio(CORE_COLOR_TOKENS.black, background) >= contrastRatio(CORE_COLOR_TOKENS.white, background) ? CORE_COLOR_TOKENS.black : CORE_COLOR_TOKENS.white;
15762}
15763function readableHighlightBackground(color, background) {
15764if (contrastRatio(color, background) >= HIGHLIGHT_CONTRAST) return color;
15765const toward = contrastRatio(background, CORE_COLOR_TOKENS.black) > contrastRatio(background, CORE_COLOR_TOKENS.white) ? CORE_COLOR_TOKENS.black : CORE_COLOR_TOKENS.white;
15766for (let amount = 0.04; amount <= 0.24; amount += 0.04) {
15767const mixed = mixHex(color, toward, amount);
15768if (contrastRatio(mixed, background) >= HIGHLIGHT_CONTRAST) return mixed;
15769}
15770return color;
15771}
15772function applyUnknownBackgroundFallback(word, background) {
15773RENDERED_WORD_CONTRAST_VARS_WITHOUT_SHADOW.forEach((name) => word.style.removeProperty(name));
15774word.style.setProperty("--jpdb-reader-highlight-backdrop", background.css);
15775word.style.setProperty("--jpdb-reader-word-contrast-shadow", PAGE_WORD_COLOR_TOKENS.unknownBackgroundShadow);
15776}
15777function applyNeutralPageBackdrop(word, background) {
15778NEUTRAL_CLEARED_CONTRAST_VARS.forEach((name) => word.style.removeProperty(name));
15779if (word.style.getPropertyValue("--jpdb-reader-highlight-backdrop") === background.css) return;
15780word.style.setProperty("--jpdb-reader-highlight-backdrop", background.css);
15781}
15782function clearContrastVars(word) {
15783RENDERED_WORD_CONTRAST_VARS.forEach((name) => word.style.removeProperty(name));
15784}
15785function applyYomuLocalReviewableToCard(card, reviewable) {
15786card.cardState = [...reviewable.state];
15787card.reviewSource = "yomu-local";
15788card.dueAt = reviewable.dueAt;
15789card.lastReviewAt = reviewable.lastReviewAt;
15790card.language = reviewable.language;
15791delete card.provisionalState;
15792return card;
15793}
15794async function hydrateYomuLocalSrsCardStates(parsed, adapter) {
15795if (typeof adapter.lookupCards !== "function") return parsed;
15796const cards = parsed.flatMap((tokens) => tokens.map((token) => token.card));
15797const identities = new Map();
15798for (const card of cards) {
15799const identity = localIdentity(card.spelling, card.reading, card.language);
15800if (identity) identities.set(identity.key, identity);
15801}
15802if (!identities.size) return parsed;
15803const reviewables = await adapter.lookupCards([...identities.values()]);
15804const indexed = new Map(reviewables.map((reviewable) => [
15805canonicalStudyCardIdentity(reviewable.expression, reviewable.reading, {
15806partOfSpeech: reviewable.partOfSpeech,
15807language: reviewable.language
15808}).key,
15809reviewable
15810]));
15811for (const card of cards) {
15812const identity = localIdentity(card.spelling, card.reading, card.language);
15813const reviewable = identity ? indexed.get(identity.key) : void 0;
15814if (reviewable) applyYomuLocalReviewableToCard(card, reviewable);
15815}
15816return parsed;
15817}
15818function repaintYomuLocalSrsRenderedWords(card, roots = typeof document === "undefined" ? [] : [document]) {
15819const changed = repaintYomuLocalSrsRenderedCards([card], roots);
15820refreshContrastForChangedWords(changed);
15821return changed.length;
15822}
15823function repaintYomuLocalSrsRenderedCards(cards, roots = typeof document === "undefined" ? [] : [document]) {
15824const byIdentity = new Map();
15825for (const card of cards) {
15826if (card.reviewSource !== "yomu-local" && card.source !== "yomu-local") continue;
15827const identity = localIdentity(card.spelling, card.reading, card.language);
15828if (identity) byIdentity.set(identity.key, card);
15829}
15830if (!byIdentity.size) return [];
15831const words = new Set();
15832for (const root of roots) {
15833if (root instanceof HTMLElement && root.matches(".jpdb-reader-word[data-expression]")) words.add(root);
15834root.querySelectorAll(".jpdb-reader-word[data-expression]").forEach((word) => words.add(word));
15835}
15836const changed = [];
15837for (const word of words) {
15838const identity = localIdentity(
15839word.dataset.expression ?? "",
15840word.dataset.reading ?? "",
15841word.dataset.language
15842);
15843const card = identity ? byIdentity.get(identity.key) : void 0;
15844if (!card) continue;
15845if (applyLocalYomuSrsStateToRenderedWord(word, card)) changed.push(word);
15846}
15847return changed;
15848}
15849function localIdentity(expression, reading, language) {
15850try {
15851return canonicalStudyCardIdentity(expression, reading, { language });
15852} catch {
15853return null;
15854}
15855}
15856function yomuLocalSrsCardIdentityKey(expression, reading, language) {
15857return localIdentity(expression, reading, language)?.key ?? null;
15858}
15859const LANGUAGE_PROFILE_SCHEMA_VERSION = 2;
15860const SUPPORTED_LANGUAGE_PROFILE_SCHEMA_VERSIONS = [1, 2];
15861function isSupportedLanguageProfileSchemaVersion(value) {
15862return SUPPORTED_LANGUAGE_PROFILE_SCHEMA_VERSIONS.includes(value);
15863}
15864const languages = [
15865{
15866id: "sq",
15867runtimeLocale: "sq",
15868englishName: "Albanian",
15869nativeName: "Shqip",
15870defaultScript: "Latn",
15871scripts: [
15872"Latn"
15873],
15874direction: "ltr"
15875},
15876{
15877id: "grc",
15878runtimeLocale: "grc",
15879englishName: "Ancient Greek",
15880nativeName: "Ἑλληνιστί",
15881defaultScript: "Grek",
15882scripts: [
15883"Grek"
15884],
15885direction: "ltr"
15886},
15887{
15888id: "ar",
15889runtimeLocale: "ar",
15890englishName: "Arabic",
15891nativeName: "العربية",
15892defaultScript: "Arab",
15893scripts: [
15894"Arab"
15895],
15896direction: "rtl"
15897},
15898{
15899id: "yue",
15900runtimeLocale: "yue-Hant",
15901englishName: "Cantonese",
15902nativeName: "粵語",
15903defaultScript: "Hant",
15904scripts: [
15905"Hant"
15906],
15907direction: "ltr"
15908},
15909{
15910id: "zh",
15911runtimeLocale: "zh-Hans",
15912englishName: "Chinese",
15913nativeName: "中文(简体)",
15914defaultScript: "Hans",
15915scripts: [
15916"Hans",
15917"Hant"
15918],
15919direction: "ltr"
15920},
15921{
15922id: "da",
15923runtimeLocale: "da",
15924englishName: "Danish",
15925nativeName: "Dansk",
15926defaultScript: "Latn",
15927scripts: [
15928"Latn"
15929],
15930direction: "ltr"
15931},
15932{
15933id: "nl",
15934runtimeLocale: "nl",
15935englishName: "Dutch",
15936nativeName: "Nederlands",
15937defaultScript: "Latn",
15938scripts: [
15939"Latn"
15940],
15941direction: "ltr"
15942},
15943{
15944id: "en",
15945runtimeLocale: "en",
15946englishName: "English",
15947nativeName: "English",
15948defaultScript: "Latn",
15949scripts: [
15950"Latn"
15951],
15952direction: "ltr"
15953},
15954{
15955id: "fi",
15956runtimeLocale: "fi",
15957englishName: "Finnish",
15958nativeName: "Suomi",
15959defaultScript: "Latn",
15960scripts: [
15961"Latn"
15962],
15963direction: "ltr"
15964},
15965{
15966id: "fr",
15967runtimeLocale: "fr",
15968englishName: "French",
15969nativeName: "Français",
15970defaultScript: "Latn",
15971scripts: [
15972"Latn"
15973],
15974direction: "ltr"
15975},
15976{
15977id: "de",
15978runtimeLocale: "de",
15979englishName: "German",
15980nativeName: "Deutsch",
15981defaultScript: "Latn",
15982scripts: [
15983"Latn"
15984],
15985direction: "ltr"
15986},
15987{
15988id: "el",
15989runtimeLocale: "el",
15990englishName: "Greek",
15991nativeName: "Ελληνικά",
15992defaultScript: "Grek",
15993scripts: [
15994"Grek"
15995],
15996direction: "ltr"
15997},
15998{
15999id: "hu",
16000runtimeLocale: "hu",
16001englishName: "Hungarian",
16002nativeName: "Magyar",
16003defaultScript: "Latn",
16004scripts: [
16005"Latn"
16006],
16007direction: "ltr"
16008},
16009{
16010id: "id",
16011runtimeLocale: "id",
16012englishName: "Indonesian",
16013nativeName: "Bahasa Indonesia",
16014defaultScript: "Latn",
16015scripts: [
16016"Latn"
16017],
16018direction: "ltr"
16019},
16020{
16021id: "it",
16022runtimeLocale: "it",
16023englishName: "Italian",
16024nativeName: "Italiano",
16025defaultScript: "Latn",
16026scripts: [
16027"Latn"
16028],
16029direction: "ltr"
16030},
16031{
16032id: "km",
16033runtimeLocale: "km",
16034englishName: "Khmer",
16035nativeName: "ខ្មែរ",
16036defaultScript: "Khmr",
16037scripts: [
16038"Khmr"
16039],
16040direction: "ltr"
16041},
16042{
16043id: "ko",
16044runtimeLocale: "ko",
16045englishName: "Korean",
16046nativeName: "한국어",
16047defaultScript: "Kore",
16048scripts: [
16049"Kore"
16050],
16051direction: "ltr"
16052},
16053{
16054id: "lo",
16055runtimeLocale: "lo",
16056englishName: "Lao",
16057nativeName: "ລາວ",
16058defaultScript: "Laoo",
16059scripts: [
16060"Laoo"
16061],
16062direction: "ltr"
16063},
16064{
16065id: "la",
16066runtimeLocale: "la",
16067englishName: "Latin",
16068nativeName: "Latina",
16069defaultScript: "Latn",
16070scripts: [
16071"Latn"
16072],
16073direction: "ltr"
16074},
16075{
16076id: "mn",
16077runtimeLocale: "mn-Cyrl",
16078englishName: "Mongolian",
16079nativeName: "Монгол",
16080defaultScript: "Cyrl",
16081scripts: [
16082"Cyrl",
16083"Mong"
16084],
16085direction: "ltr"
16086},
16087{
16088id: "fa",
16089runtimeLocale: "fa",
16090englishName: "Persian",
16091nativeName: "فارسی",
16092defaultScript: "Arab",
16093scripts: [
16094"Arab"
16095],
16096direction: "rtl"
16097},
16098{
16099id: "pl",
16100runtimeLocale: "pl",
16101englishName: "Polish",
16102nativeName: "Polski",
16103defaultScript: "Latn",
16104scripts: [
16105"Latn"
16106],
16107direction: "ltr"
16108},
16109{
16110id: "pt",
16111runtimeLocale: "pt",
16112englishName: "Portuguese",
16113nativeName: "Português",
16114defaultScript: "Latn",
16115scripts: [
16116"Latn"
16117],
16118direction: "ltr"
16119},
16120{
16121id: "ro",
16122runtimeLocale: "ro",
16123englishName: "Romanian",
16124nativeName: "Română",
16125defaultScript: "Latn",
16126scripts: [
16127"Latn"
16128],
16129direction: "ltr"
16130},
16131{
16132id: "ru",
16133runtimeLocale: "ru",
16134englishName: "Russian",
16135nativeName: "Русский",
16136defaultScript: "Cyrl",
16137scripts: [
16138"Cyrl"
16139],
16140direction: "ltr"
16141},
16142{
16143id: "sh",
16144runtimeLocale: "sr-Latn",
16145englishName: "Serbo-Croatian",
16146nativeName: "Srpskohrvatski",
16147defaultScript: "Latn",
16148scripts: [
16149"Latn",
16150"Cyrl"
16151],
16152direction: "ltr"
16153},
16154{
16155id: "es",
16156runtimeLocale: "es",
16157englishName: "Spanish",
16158nativeName: "Español",
16159defaultScript: "Latn",
16160scripts: [
16161"Latn"
16162],
16163direction: "ltr"
16164},
16165{
16166id: "sv",
16167runtimeLocale: "sv",
16168englishName: "Swedish",
16169nativeName: "Svenska",
16170defaultScript: "Latn",
16171scripts: [
16172"Latn"
16173],
16174direction: "ltr"
16175},
16176{
16177id: "tl",
16178runtimeLocale: "fil",
16179englishName: "Tagalog",
16180nativeName: "Tagalog",
16181defaultScript: "Latn",
16182scripts: [
16183"Latn"
16184],
16185direction: "ltr"
16186},
16187{
16188id: "th",
16189runtimeLocale: "th",
16190englishName: "Thai",
16191nativeName: "ไทย",
16192defaultScript: "Thai",
16193scripts: [
16194"Thai"
16195],
16196direction: "ltr"
16197},
16198{
16199id: "tr",
16200runtimeLocale: "tr",
16201englishName: "Turkish",
16202nativeName: "Türkçe",
16203defaultScript: "Latn",
16204scripts: [
16205"Latn"
16206],
16207direction: "ltr"
16208},
16209{
16210id: "vi",
16211runtimeLocale: "vi",
16212englishName: "Vietnamese",
16213nativeName: "Tiếng Việt",
16214defaultScript: "Latn",
16215scripts: [
16216"Latn"
16217],
16218direction: "ltr"
16219}
16220];
16221const languageConfig = {
16222languages
16223};
16224const LEARNER_LANGUAGE_IDS = [
16225"sq",
16226"grc",
16227"ar",
16228"yue",
16229"zh",
16230"da",
16231"nl",
16232"en",
16233"fi",
16234"fr",
16235"de",
16236"el",
16237"hu",
16238"id",
16239"it",
16240"km",
16241"ko",
16242"lo",
16243"la",
16244"mn",
16245"fa",
16246"pl",
16247"pt",
16248"ro",
16249"ru",
16250"sh",
16251"es",
16252"sv",
16253"tl",
16254"th",
16255"tr",
16256"vi"
16257];
16258const configuredLanguages = languageConfig.languages;
16259const LEARNER_LANGUAGES = Object.freeze(
16260configuredLanguages.map(
16261(language) => Object.freeze({
16262...language,
16263scripts: Object.freeze([...language.scripts])
16264})
16265)
16266);
16267const LANGUAGE_BY_ID = new Map(
16268LEARNER_LANGUAGES.map((language) => [language.id, language])
16269);
16270function learnerLanguageById(id) {
16271const language = LANGUAGE_BY_ID.get(id);
16272if (!language) throw new Error(`Unknown Slice 1 learner language: ${id}`);
16273return language;
16274}
16275function isLearnerLanguageId(value) {
16276return LEARNER_LANGUAGE_IDS.includes(value);
16277}
16278const DEFAULT_SLICE1_LEARNER_LANGUAGE = "en";
16279const JAPANESE_TARGET_ROSTER_ENTRY = Object.freeze({
16280id: "ja",
16281runtimeLocale: "ja",
16282englishName: "Japanese",
16283nativeName: "日本語",
16284defaultScript: "Jpan",
16285scripts: Object.freeze(["Jpan"]),
16286direction: "ltr",
16287studyTargetReadiness: "full"
16288});
16289const READING_ONLY_STUDY_TARGET_ID_LIST = "sq grc ar yue zh da nl en fi fr de el hu id it km ko lo la mn fa pl pt ro ru sh es sv tl th tr vi";
16290const READING_ONLY_STUDY_TARGET_IDS = READING_ONLY_STUDY_TARGET_ID_LIST.split(" ");
16291Object.freeze([
16292JAPANESE_TARGET_ROSTER_ENTRY,
16293...LEARNER_LANGUAGES.map((language) => Object.freeze({
16294...language,
16295studyTargetReadiness: READING_ONLY_STUDY_TARGET_IDS.includes(language.id) ? "reading-only" : "planned"
16296}))
16297]);
16298Object.freeze(
16299LEARNER_LANGUAGES.map((language) => canonicalLanguageTag(language.runtimeLocale) ?? language.runtimeLocale)
16300);
16301function canonicalTagForSlice1Language(id) {
16302const runtimeLocale = learnerLanguageById(id).runtimeLocale;
16303return canonicalLanguageTag(runtimeLocale) ?? runtimeLocale;
16304}
16305function slice1LanguageIdForTag(value) {
16306if (typeof value !== "string") return null;
16307const input = value.trim().toLowerCase().replace(/_/g, "-");
16308const inputBase = input.split("-")[0] ?? "";
16309if (isLearnerLanguageId(inputBase)) return inputBase;
16310const canonical = canonicalLanguageTag(value);
16311if (!canonical) return null;
16312const base = languageSubtag(canonical);
16313if (!base) return null;
16314if (base === "sr" || base === "hr" || base === "bs") return "sh";
16315if (base === "fil") return "tl";
16316return isLearnerLanguageId(base) ? base : null;
16317}
16318function normalizeSlice1LearnerLanguage(value, fallback = DEFAULT_SLICE1_LEARNER_LANGUAGE) {
16319if (typeof value === "string") {
16320const input = value.trim().toLowerCase().replace(/_/g, "-");
16321if (isLearnerLanguageId(input)) return canonicalTagForSlice1Language(input);
16322}
16323const canonical = canonicalLanguageTag(value);
16324const canonicalId = canonical ? slice1LanguageIdForTag(canonical) : null;
16325if (canonical && canonicalId) {
16326if (canonicalId === "sh") return canonicalTagForSlice1Language("sh");
16327return canonical;
16328}
16329const fallbackId = slice1LanguageIdForTag(fallback) ?? DEFAULT_SLICE1_LEARNER_LANGUAGE;
16330return canonicalTagForSlice1Language(fallbackId);
16331}
16332const DEFAULT_LANGUAGE_PROFILE_ID = "default-ja";
16333const PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/u;
16334const PARSER_PROVIDERS = new Set(["local", "jiten", "jpdb", "auto"]);
16335function readOutputLanguageField(source) {
16336return source.schemaVersion === 1 ? source.learnerLanguage ?? source.outputLanguage : s
16336ource.outputLanguage ?? source.learnerLanguage;
16337}
16338function createDefaultLanguageProfile(defaults = {}) {
16339return {
16340schemaVersion: LANGUAGE_PROFILE_SCHEMA_VERSION,
16341id: DEFAULT_LANGUAGE_PROFILE_ID,
16342...outputLanguageFields(normalizeSlice1LearnerLanguage(
16343readOutputLanguageField(defaults),
16344DEFAULT_SLICE1_LEARNER_LANGUAGE
16345)),
16346targetLanguage: normalizeLearningTargetLanguage(defaults.targetLanguage),
16347uiLocale: normalizeUiLocale(defaults.uiLocale, "en"),
16348parserProvider: normalizeParserProvider(defaults.parserProvider, "local"),
16349dictionaries: emptyProfileDictionaries(),
16350definitionTranslationProviderIds: []
16351};
16352}
16353function normalizeLanguageProfiles(value, activeProfileId, defaults = {}) {
16354const rawProfiles = Array.isArray(value) ? value : [];
16355const profiles = [];
16356const usedIds = new Set();
16357for (let index = 0; index < rawProfiles.length; index += 1) {
16358const profile = normalizeLanguageProfile(rawProfiles[index], index, defaults);
16359if (!profile) continue;
16360profile.id = uniqueProfileId(profile.id, usedIds);
16361usedIds.add(profile.id);
16362profiles.push(profile);
16363}
16364if (!profiles.length) profiles.push(createDefaultLanguageProfile(defaults));
16365const requestedActiveId = typeof activeProfileId === "string" ? activeProfileId.trim() : "";
16366const active = profiles.find((profile) => profile.id === requestedActiveId) ?? profiles[0];
16367return {
16368profiles,
16369activeProfileId: active.id
16370};
16371}
16372function outputLanguageFields(outputLanguage) {
16373return { outputLanguage, learnerLanguage: outputLanguage };
16374}
16375function activeLanguageProfile(profiles, activeProfileId) {
16376return profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null;
16377}
16378function resolveLanguageProfile(value) {
16379if (isRecord$1(value) && isSupportedLanguageProfileSchemaVersion(value.schemaVersion)) {
16380const normalized2 = normalizeLanguageProfiles([value], value.id, {
16381outputLanguage: readOutputLanguageField(value),
16382uiLocale: value.uiLocale,
16383parserProvider: value.parserProvider
16384});
16385return normalized2.profiles[0];
16386}
16387const source = isRecord$1(value) ? value : {};
16388const normalized = normalizeLanguageProfiles(
16389source.languageProfiles,
16390source.activeLanguageProfileId,
16391{
16392outputLanguage: readOutputLanguageField(source),
16393uiLocale: source.interfaceLanguage,
16394parserProvider: source.parserProvider
16395}
16396);
16397return activeLanguageProfile(normalized.profiles, normalized.activeProfileId) ?? createDefaultLanguageProfile();
16398}
16399function normalizeLanguageProfile(value, index, defaults) {
16400if (!isRecord$1(value)) return null;
16401if (!isSupportedLanguageProfileSchemaVersion(value.schemaVersion)) return null;
16402return {
16403schemaVersion: LANGUAGE_PROFILE_SCHEMA_VERSION,
16404id: normalizeProfileId(value.id, index),
16405...outputLanguageFields(normalizeSlice1LearnerLanguage(
16406readOutputLanguageField(value),
16407normalizeSlice1LearnerLanguage(readOutputLanguageField(defaults))
16408)),
16409targetLanguage: normalizeLearningTargetLanguage(value.targetLanguage ?? defaults.targetLanguage),
16410uiLocale: normalizeUiLocale(value.uiLocale, normalizeUiLocale(defaults.uiLocale, "en")),
16411parserProvider: normalizeParserProvider(value.parserProvider, normalizeParserProvider(defaults.parserProvider, "local")),
16412dictionaries: normalizeProfileDictionaries(value.dictionaries),
16413definitionTranslationProviderIds: normalizeStringIds(value.definitionTranslationProviderIds)
16414};
16415}
16416function normalizeProfileId(value, index) {
16417const candidate = typeof value === "string" ? value.trim() : "";
16418return PROFILE_ID_RE.test(candidate) ? candidate : `profile-${index + 1}`;
16419}
16420function uniqueProfileId(candidate, used) {
16421if (!used.has(candidate)) return candidate;
16422let suffix = 2;
16423while (used.has(`${candidate}-${suffix}`)) suffix += 1;
16424return `${candidate}-${suffix}`;
16425}
16426function normalizeUiLocale(value, fallback) {
16427if (value === "auto") return "auto";
16428return canonicalLanguageTag(value) ?? fallback;
16429}
16430function normalizeParserProvider(value, fallback) {
16431return PARSER_PROVIDERS.has(value) ? value : fallback;
16432}
16433function normalizeProfileDictionaries(value) {
16434if (!isRecord$1(value)) return emptyProfileDictionaries();
16435const enabled = normalizeStringIds(value.enabled);
16436const order = normalizeStringIds(value.order);
16437const installed = normalizeStringIds([
16438...normalizeStringIds(value.installed),
16439...enabled,
16440...order
16441]);
16442const installedSet = new Set(installed);
16443return {
16444installed,
16445enabled: enabled.filter((id) => installedSet.has(id)),
16446order: [
16447...order.filter((id) => installedSet.has(id)),
16448...installed.filter((id) => !order.includes(id))
16449]
16450};
16451}
16452function emptyProfileDictionaries() {
16453return { installed: [], enabled: [], order: [] };
16454}
16455function normalizeStringIds(value) {
16456if (!Array.isArray(value)) return [];
16457const seen = new Set();
16458const result = [];
16459for (const item of value) {
16460if (typeof item !== "string") continue;
16461const id = item.trim();
16462if (!id || id.length > 160 || seen.has(id)) continue;
16463seen.add(id);
16464result.push(id);
16465}
16466return result;
16467}
16468function isRecord$1(value) {
16469return Boolean(value) && typeof value === "object" && !Array.isArray(value);
16470}
16471function targetLanguageOf(value) {
16472return resolveLanguageProfile(value).targetLanguage;
16473}
16474function outputLanguageOf(value) {
16475return resolveLanguageProfile(value).outputLanguage;
16476}
16477function adoptLearningTargetFromSettings(value) {
16478return adoptLearningTargetLanguage(resolveLanguageProfile(value).targetLanguage);
16479}
16480function apiGradingProviderPreference(settings2) {
16481if (settings2.apiGradingProvider === "bunpro") return "bunpro";
16482return settings2.apiGradingProvider === "jiten" ? "jiten" : "jpdb";
16483}
16484function apiSrsSwitchableProviderIds(card, settings2) {
16485if (isBunproUsableCard(card, settings2)) return ["bunpro"];
16486if (isWanikaniUsableCard(card, settings2)) return ["wanikani"];
16487const ids = [];
16488const wordLike = !card.bunproReviewableType || card.bunproReviewableType === "vocabulary";
16489if (wordLike && hasJpdbApiCredential(settings2)) ids.push("jpdb");
16490if (wordLike && hasJitenApiCredential(settings2)) ids.push("jiten");
16491return ids;
16492}
16493function isBunproUsableCard(card, settings2) {
16494return isBunproGradeableCard(card) && hasBunproFrontendCredential(settings2) && !isBunproFrontendCredentialExpired(settings2);
16495}
16496function isWanikaniUsableCard(card, settings2) {
16497return isWanikaniGradeableCard(card) && settings2.wanikaniReviewEnabled && hasWanikaniApiCredential(settings2);
16498}
16499function apiSrsProviderView(id, settings2) {
16500if (id === "yomu-local") {
16501return {
16502id: "yomu-local",
16503label: ACADEMY_SRS_LABEL,
16504deckSource: "yomu-local",
16505hasApiKey: settings2.yomuLocalSrsEnabled
16506};
16507}
16508if (id === "bunpro") {
16509return {
16510id: "bunpro",
16511label: "Bunpro",
16512deckSource: "bunpro",
16513hasApiKey: hasBunproFrontendCredential(settings2) && !isBunproFrontendCredentialExpired(settings2)
16514};
16515}
16516if (id === "wanikani") {
16517return {
16518id: "wanikani",
16519label: "WaniKani",
16520deckSource: "wanikani",
16521hasApiKey: settings2.wanikaniReviewEnabled && hasWanikaniApiCredential(settings2)
16522};
16523}
16524return id === "jiten" ? { id: "jiten", label: "Jiten", deckSource: "jiten", hasApiKey: hasJitenApiCredential(settings2) } : { id: "jpdb", label: "JPDB", deckSource: "jpdb", hasApiKey: hasJpdbApiCredential(settings2) };
16525}
16526function apiSrsProviderViewForCard(card, settings2, isJpdbBackedCard) {
16527const jpdbBacked = isJpdbBackedCard(card);
16528const jitenBacked = isJitenBackedCard(card);
16529const bunproBacked = isBunproBackedCard(card);
16530const wanikaniBacked = isWanikaniBackedCard(card);
16531const jpdbUsable = jpdbBacked && hasJpdbApiCredential(settings2);
16532const jitenUsable = jitenBacked && hasJitenApiCredential(settings2);
16533const bunproUsable = isBunproUsableCard(card, settings2);
16534const wanikaniUsable = isWanikaniUsableCard(card, settings2);
16535const override = card.apiGradingProviderOverride;
16536if (override === "jpdb" && jpdbUsable) return apiSrsProviderView("jpdb", settings2);
16537if (override === "jiten" && jitenUsable) return apiSrsProviderView("jiten", settings2);
16538if (override === "bunpro" && bunproUsable) return apiSrsProviderView("bunpro", settings2);
16539if (wanikaniUsable) return apiSrsProviderView("wanikani", settings2);
16540if (bunproUsable) return apiSrsProviderView("bunpro", settings2);
16541if (jpdbUsable && jitenUsable) {
16542const preferred = apiGradingProviderPreference(settings2);
16543return apiSrsProviderView(preferred === "jpdb" ? "jpdb" : "jiten", settings2);
16544}
16545if (jpdbUsable) return apiSrsProviderView("jpdb", settings2);
16546if (jitenUsable) return apiSrsProviderView("jiten", settings2);
16547if (!bunproBacked && !wanikaniBacked && settings2.yomuLocalSrsEnabled) return apiSrsProviderView("yomu-local", settings2);
16548if (jpdbBacked) return apiSrsProviderView("jpdb", settings2);
16549if (jitenBacked) return apiSrsProviderView("jiten", settings2);
16550if (bunproBacked) return { ...apiSrsProviderView("bunpro", settings2), hasApiKey: false };
16551if (wanikaniBacked) return { ...apiSrsProviderView("wanikani", settings2), hasApiKey: false };
16552return null;
16553}
16554function apiGradingServiceToResolve(card, settings2, isJpdbBackedCard) {
16555const chosen = chosenWordGradingService(settings2);
16556if (!chosen || card.apiGradingProviderOverride) return null;
16557const own = apiSrsProviderViewForCard(card, settings2, isJpdbBackedCard)?.id;
16558return (own === "jpdb" || own === "jiten") && own !== chosen ? chosen : null;
16559}
16560function apiSrsGradingProviderViewForCard(card, settings2, isJpdbBackedCard) {
16561const resolveOn = apiGradingServiceToResolve(card, settings2, isJpdbBackedCard);
16562return resolveOn ? apiSrsProviderView(resolveOn, settings2) : apiSrsProviderViewForCard(card, settings2, isJpdbBackedCard);
16563}
16564const COLLECTION_ACCEPTS = {
16565jpdb: (card, isJpdbBackedCard) => isJpdbBackedCard(card),
16566jiten: isJitenBackedCard,
16567"yomu-local": (card) => Boolean(card.spelling.trim()),
16568bunpro: isBunproMiningCard,
16569wanikani: () => false
16570};
16571const COLLECTION_FALLBACK_ORDER = ["anki", "jpdb", "jiten", "yomu-local", "bunpro"];
16572function collectionDestinationsForCard(card, settings2, isJpdbBackedCard) {
16573const resolveOn = apiGradingServiceToResolve(card, settings2, isJpdbBackedCard);
16574const grading = resolveOn ?? apiSrsProviderViewForCard(card, settings2, isJpdbBackedCard)?.id;
16575const order = grading ? [grading, ...COLLECTION_FALLBACK_ORDER.filter((id) => id !== grading)] : COLLECTION_FALLBACK_ORDER;
16576const accepts = (id) => id === resolveOn || COLLECTION_ACCEPTS[id](card, isJpdbBackedCard);
16577return order.filter((id) => canCollectTo(id, settings2, accepts));
16578}
16579function canCollectTo(id, settings2, accepts) {
16580if (id === "anki") return settings2.ankiEnabled;
16581return isApiSrsProviderEnabled(settings2, id) && apiSrsProviderView(id, settings2).hasApiKey && accepts(id);
16582}
16583function isApiMiningEnabled(settings2) {
16584return settings2.jpdbMiningEnabled || settings2.bunproMiningEnabled || settings2.yomuLocalSrsEnabled;
16585}
16586function isApiSrsProviderEnabled(settings2, providerId) {
16587if (providerId === "yomu-local") return settings2.yomuLocalSrsEnabled;
16588if (providerId === "wanikani") return settings2.wanikaniReviewEnabled;
16589return providerId === "bunpro" ? settings2.bunproMiningEnabled : settings2.jpdbMiningEnabled;
16590}
16591function shouldMineAnkiAlongsideApi(settings2) {
16592return settings2.ankiEnabled && settings2.ankiMineWithJpdb;
16593}
16594function createApiSrsProviderAdapters(options, settings2) {
16595const jpdbProvider = createJpdbSrsProviderAdapter(options.jpdb, options.isJpdbBackedCard, settings2);
16596const providers = options.jiten ? [createJitenSrsProviderAdapter(options.jiten, settings2), jpdbProvider] : [jpdbProvider];
16597if (options.bunpro) providers.unshift(createBunproSrsProviderAdapter(options.bunpro, settings2));
16598if (options.wanikani) providers.unshift(createWanikaniSrsProviderAdapter(options.wanikani, settings2));
16599if (options.yomuLocal) providers.push(createYomuLocalSrsProviderAdapter(options.yomuLocal, settings2));
16600return providers;
16601}
16602function createJpdbSrsProviderAdapter(jpdb, isJpdbBackedCard, settings2) {
16603return {
16604id: "jpdb",
16605label: "JPDB",
16606deckSource: "jpdb",
16607hasApiKey: hasJpdbApiCredential(settings2),
16608addApiKeyRequiredKey: "jpdbAddApiKeyRequired",
16609reviewApiKeyRequiredKey: "addJpdbApiKeyReview",
16610deckStateApiKeyRequiredKey: "jpdbDeckStateApiKeyRequired",
16611addedToastKey: "addedToJpdb",
16612supportsCard: isJpdbBackedCard,
16613supportsDeckState: (state) => state === "never-forget" || state === "blacklisted",
16614selectedDeckId: selectedJpdbDeckId,
16615selectedDeckLabel: (current, data) => jpdbDeckLabel(current, current.miningDeck.trim() || "forq", data.jpdbDecks),
16616addToDeck: async (deckId, card, sentence) => {
16617const targetDeck = selectedJpdbDeckId(deckId, settings2);
16618await jpdb.addToDeck(targetDeck, card, sentence);
16619if (shouldAlsoAddToForq(settings2, targetDeck)) await jpdb.addToDeck("forq", card, sentence);
16620},
16621reviewCard: async (card, grade, reviewOptions = {}) => {
16622const states = normalizeCardStates(card.cardState);
16623const wasNotInDeck = states.includes("not-in-deck");
16624if (wasNotInDeck) await jpdb.addToDeck(reviewOptions.deckId || defaultJpdbDeckId$1(settings2), card, reviewOptions.sentence);
16625await jpdb.reviewCard(card, grade);
16626return { addedBeforeReview: wasNotInDeck };
16627},
16628setDeckState: async (card, state, deckId) => {
16629if (state !== "blacklisted" && state !== "never-forget") return;
16630if (normalizeCardStates(card.cardState).includes(state)) {
16631await jpdb.removeFromDeck(deckId, card);
16632} else {
16633await jpdb.addToDeck(deckId, card);
16634}
16635}
16636};
16637}
16638function createBunproSrsProviderAdapter(adapter, settings2) {
16639return {
16640id: "bunpro",
16641label: "Bunpro",
16642deckSource: "bunpro",
16643hasApiKey: settings2.bunproMiningEnabled && adapter.hasCredential(),
16644addApiKeyRequiredKey: "bunproAddApiKeyRequired",
16645reviewApiKeyRequiredKey: "addBunproApiKeyReview",
16646deckStateApiKeyRequiredKey: "bunproAddApiKeyRequired",
16647addedToastKey: "addedToBunpro",
16648supportsCard: isBunproGradeableCard,
16649supportsMiningCard: isBunproMiningCard,
16650supportsDeckState: () => false,
16651selectedDeckId: () => "bunpro",
16652selectedDeckLabel: () => "Bunpro",
16653addToDeck: async (_deckId, card, sentence, context) => {
16654await adapter.mine(bunproMiningRequestFromCard(card, sentence, context));
16655},
16656reviewCard: async (card, grade, reviewOptions = {}) => {
16657const result = await adapter.review({
16658card: bunproReviewableFromCard(card),
16659grade,
16660sentence: reviewOptions.sentence
16661});
16662if (result.card) applyBunproReviewableToCard(card, result.card);
16663return {};
16664},
16665setDeckState: async () => void 0
16666};
16667}
16668function createWanikaniSrsProviderAdapter(adapter, settings2) {
16669return {
16670id: "wanikani",
16671label: "WaniKani",
16672deckSource: "wanikani",
16673hasApiKey: settings2.wanikaniReviewEnabled && adapter.hasCredential(),
16674addApiKeyRequiredKey: "wanikaniAddApiKeyRequired",
16675reviewApiKeyRequiredKey: "addWanikaniApiKeyReview",
16676deckStateApiKeyRequiredKey: "wanikaniAddApiKeyRequired",
16677addedToastKey: "addedToWanikani",
16678supportsCard: isWanikaniGradeableCard,
16679supportsMiningCard: () => false,
16680supportsDeckState: () => false,
16681selectedDeckId: () => "wanikani",
16682selectedDeckLabel: () => "WaniKani",
16683addToDeck: async () => {
16684throw new Error("WaniKani has no API to add arbitrary words; open the word on wanikani.com instead.");
16685},
16686reviewCard: async (card, grade) => {
16687const result = await adapter.review({ card: wanikaniReviewableFromCard(card), grade });
16688if (result.card) applyWanikaniReviewableToCard(card, result.card);
16689return {};
16690},
16691setDeckState: async () => void 0
16692};
16693}
16694function isWanikaniBackedCard(card) {
16695return card.source === "wanikani" || card.reviewSource === "wanikani-api" || Boolean(card.wanikaniSubjectId);
16696}
16697function isWanikaniGradeableCard(card) {
16698return isWanikaniBackedCard(card) && typeof card.wanikaniAssignmentId === "
16698number" && card.wanikaniAssignmentId > 0;
16699}
16700function wanikaniReviewableFromCard(card) {
16701const expression = card.spelling.trim();
16702const reading = card.reading.trim();
16703return {
16704providerId: "wanikani",
16705providerCardId: String(card.wanikaniAssignmentId ?? ""),
16706providerReviewableId: card.wanikaniSubjectId !== void 0 ? String(card.wanikaniSubjectId) : void 0,
16707kind: card.wanikaniSubjectType === "kanji" ? "kanji" : card.wanikaniSubjectType === "radical" ? "unknown" : "vocabulary",
16708expression,
16709reading,
16710meanings: card.meanings,
16711state: card.cardState,
16712srsLevel: card.wanikaniSrsStage,
16713dueAt: card.dueAt,
16714lastReviewAt: card.lastReviewAt,
16715raw: card
16716};
16717}
16718function applyWanikaniReviewableToCard(card, reviewable) {
16719if (reviewable.state.length) card.cardState = reviewable.state;
16720if (reviewable.dueAt !== void 0) card.dueAt = reviewable.dueAt;
16721if (reviewable.srsLevel) card.wanikaniSrsStage = reviewable.srsLevel;
16722}
16723function isBunproMiningCard(card) {
16724return Boolean(card.spelling.trim()) && card.bunproReviewableType !== "sentence";
16725}
16726function applyBunproReviewableToCard(card, reviewable) {
16727if (reviewable.state.length) card.cardState = reviewable.state;
16728if (reviewable.dueAt !== void 0) card.dueAt = reviewable.dueAt;
16729if (reviewable.lastReviewAt !== void 0) card.lastReviewAt = reviewable.lastReviewAt;
16730if (reviewable.srsLevel) card.bunproSrsLevel = reviewable.srsLevel;
16731if (reviewable.reviewSession) {
16732card.bunproReviewSessionId = reviewable.reviewSession.id;
16733card.bunproReviewInputMode = reviewable.reviewSession.inputMode;
16734card.bunproReviewEndpoint = reviewable.reviewSession.endpoint;
16735}
16736}
16737function createYomuLocalSrsProviderAdapter(adapter, settings2) {
16738return {
16739id: "yomu-local",
16740label: ACADEMY_SRS_LABEL,
16741deckSource: "yomu-local",
16742hasApiKey: settings2.yomuLocalSrsEnabled && adapter.hasCredential(),
16743addApiKeyRequiredKey: "yomuLocalSrsDisabled",
16744reviewApiKeyRequiredKey: "yomuLocalSrsDisabled",
16745deckStateApiKeyRequiredKey: "yomuLocalSrsDisabled",
16746addedToastKey: "addedToYomuLocal",
16747supportsCard: (card) => Boolean(card.spelling.trim()),
16748supportsDeckState: () => false,
16749selectedDeckId: () => "yomu-local",
16750selectedDeckLabel: () => ACADEMY_SRS_LABEL,
16751addToDeck: async (_deckId, card, sentence, context) => {
16752const result = await localYomuMutation(
16753() => adapter.mine(yomuLocalMiningRequestFromCard(card, sentence, context))
16754);
16755if (result.card) applyYomuLocalReviewableToCard(card, result.card);
16756},
16757reviewCard: async (card, grade, reviewOptions = {}) => {
16758const wasNotInDeck = normalizeCardStates(card.cardState).includes("not-in-deck") || card.reviewSource !== "yomu-local";
16759const result = await localYomuMutation(() => adapter.review({
16760card: yomuLocalReviewableFromCard(card),
16761grade,
16762sentence: reviewOptions.sentence
16763}));
16764if (result.card) applyYomuLocalReviewableToCard(card, result.card);
16765return { addedBeforeReview: wasNotInDeck };
16766},
16767setDeckState: async () => void 0
16768};
16769}
16770async function localYomuMutation(operation) {
16771try {
16772return await operation();
16773} catch (error) {
16774if (isLocalYomuSrsStorageError(error)) {
16775throw userFacingError(isLocalYomuSrsSaveInterrupted(error) ? "yomuLocalSrsSaveInterrupted" : "yomuLocalSrsStorageFailed", { cause: error });
16776}
16777throw error;
16778}
16779}
16780function createJitenSrsProviderAdapter(jiten, settings2) {
16781return {
16782id: "jiten",
16783label: "Jiten",
16784deckSource: "jiten",
16785hasApiKey: hasJitenApiCredential(settings2),
16786addApiKeyRequiredKey: "jitenAddApiKeyRequired",
16787reviewApiKeyRequiredKey: "addJitenApiKeyReview",
16788deckStateApiKeyRequiredKey: "jitenDeckStateApiKeyRequired",
16789addedToastKey: "addedToJiten",
16790supportsCard: isJitenBackedCard,
16791supportsDeckState: () => true,
16792selectedDeckId: selectedJitenDeckId,
16793selectedDeckLabel: (_current, data) => jitenDeckLabel$1((data.jitenDecks ?? [])[0]),
16794addToDeck: async (deckId, card, sentence, context) => {
16795await jiten.addToStudyDeck(selectedJitenDeckId(deckId), card, sentence, context?.sourceTitle);
16796await refreshJitenCardState(jiten, card);
16797},
16798reviewCard: async (card, grade) => {
16799await jiten.reviewCard(card, grade);
16800await refreshJitenCardState(jiten, card);
16801return {};
16802},
16803setDeckState: async (card, state) => {
16804const jitenState = jitenVocabularyStateForApiState(state);
16805const currentState = cardStateForApiState(state);
16806const action = currentState && normalizeCardStates(card.cardState).includes(currentState) ? "remove" : "add";
16807await jiten.setVocabularyState(card, jitenState, action);
16808await refreshJitenCardState(jiten, card);
16809}
16810};
16811}
16812async function refreshJitenCardState(jiten, card) {
16813if (typeof jiten.refreshCardState !== "function") return;
16814await jiten.refreshCardState(card).catch(() => void 0);
16815}
16816function isJitenBackedCard(card) {
16817return card.source === "jiten" || finitePositiveInteger$1(card.jitenWordId) !== void 0 && finiteNonNegativeInteger$1(card.jitenReadingIndex) !== void 0;
16818}
16819function isBunproBackedCard(card) {
16820return card.source === "bunpro" || card.reviewSource === "bunpro-api" || Boolean(card.bunproReviewId || card.bunproReviewableId);
16821}
16822function isBunproGradeableCard(card) {
16823const sessionId = Number(card.bunproReviewSessionId);
16824return isBunproBackedCard(card) && typeof card.bunproReviewId === "string" && /^[1-9]\d*$/u.test(card.bunproReviewId.trim()) && Number.isInteger(sessionId) && sessionId > 0 && (card.bunproReviewInputMode === "regular" || card.bunproReviewInputMode === "fsrs") && (card.bunproReviewEndpoint === "review" || card.bunproReviewEndpoint === "ghost-review" || card.bunproReviewEndpoint === "self-study-review");
16825}
16826function bunproReviewableFromCard(card) {
16827const expression = card.spelling.trim();
16828const reading = card.reading.trim() || expression;
16829const providerCardId = card.bunproReviewId || stringifyPositiveNumber(card.bunproReviewableId) || card.sourceCardKey || `${card.vid}:${card.sid}:${card.rid}`;
16830return {
16831providerId: "bunpro",
16832providerCardId,
16833providerReviewId: card.bunproReviewId,
16834providerReviewableId: stringifyPositiveNumber(card.bunproReviewableId),
16835reviewSession: card.bunproReviewSessionId && card.bunproReviewInputMode && card.bunproReviewEndpoint ? {
16836id: card.bunproReviewSessionId,
16837inputMode: card.bunproReviewInputMode,
16838endpoint: card.bunproReviewEndpoint
16839} : void 0,
16840kind: bunproReviewableKind(card.bunproReviewableType),
16841expression,
16842reading,
16843meanings: card.meanings,
16844state: card.cardState,
16845srsLevel: card.bunproSrsLevel,
16846dueAt: card.dueAt,
16847lastReviewAt: card.lastReviewAt,
16848raw: card
16849};
16850}
16851function bunproMiningRequestFromCard(card, sentence, context) {
16852return {
16853expression: card.spelling,
16854reading: card.reading,
16855meaning: card.meanings.flatMap((meaning) => meaning.glosses).join("; "),
16856sentence,
16857sourceTitle: context?.sourceTitle,
16858sourceUrl: context?.sourceUrl,
16859kind: bunproReviewableKind(card.bunproReviewableType)
16860};
16861}
16862function bunproReviewableKind(type) {
16863if (type === "vocabulary" || type === "grammar" || type === "sentence") return type;
16864return "vocabulary";
16865}
16866function yomuLocalReviewableFromCard(card) {
16867const expression = card.spelling.trim();
16868const reading = card.reading.trim() || expression;
16869const providerCardId = card.sourceCardKey || `${expression}\0${reading}`;
16870return {
16871providerId: "yomu-local",
16872providerCardId,
16873providerReviewId: providerCardId,
16874kind: "vocabulary",
16875expression,
16876reading,
16877language: card.language ?? activeLearningTargetLanguage(),
16878meanings: card.meanings,
16879state: card.cardState,
16880dueAt: card.dueAt,
16881lastReviewAt: card.lastReviewAt,
16882raw: card
16883};
16884}
16885function yomuLocalMiningRequestFromCard(card, sentence, context) {
16886return {
16887expression: card.spelling,
16888reading: card.reading,
16889language: card.language ?? activeLearningTargetLanguage(),
16890meaning: card.meanings.flatMap((meaning) => meaning.glosses).join("; "),
16891sentence,
16892sourceTitle: context?.sourceTitle,
16893sourceUrl: context?.sourceUrl,
16894kind: "vocabulary"
16895};
16896}
16897function stringifyPositiveNumber(value) {
16898return typeof value === "number" && Number.isInteger(value) && value > 0 ? String(value) : void 0;
16899}
16900function jitenVocabularyStateForApiState(state) {
16901if (state === "blacklisted") return "blacklist";
16902if (state === "never-forget") return "neverForget";
16903if (state === "suspended") return "suspend";
16904if (state === "forgotten") return "forget";
16905return "mining";
16906}
16907function cardStateForApiState(state) {
16908if (state === "blacklisted") return "blacklisted";
16909if (state === "never-forget") return "never-forget";
16910if (state === "suspended") return "suspended";
16911if (state === "forgotten") return "not-in-deck";
16912return "in-deck";
16913}
16914function finitePositiveInteger$1(value) {
16915return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
16916}
16917function finiteNonNegativeInteger$1(value) {
16918return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
16919}
16920function defaultJpdbDeckId$1(settings2) {
16921return settings2.miningDeck.trim() || "forq";
16922}
16923function selectedJpdbDeckId(deckId, settings2) {
16924const selectedDeckId = deckId.trim();
16925if (selectedDeckId) return selectedDeckId;
16926return defaultJpdbDeckId$1(settings2);
16927}
16928function selectedJitenDeckId(deckId) {
16929return deckId.trim();
16930}
16931function shouldAlsoAddToForq(settings2, targetDeck) {
16932return settings2.addToForq && targetDeck !== "forq";
16933}
16934function jitenDeckLabel$1(deck) {
16935return deck?.name ? `Jiten: ${deck.name}` : "Jiten";
16936}
16937function commonBatchGrades(plans) {
16938const first = plans[0]?.grades;
16939if (!first?.length || plans.some((plan) => JSON.stringify(plan.grades) !== JSON.stringify(first))) return [];
16940return first;
16941}
16942const MAX_BATCH_RECEIPT_KEYS = 4096;
16943class PreparedBatchActions {
16944constructor(deps, receiptLimit = MAX_BATCH_RECEIPT_KEYS) {
16945this.deps = deps;
16946this.receipts = new BatchReceiptLedger(receiptLimit);
16947}
16948entries = new Map();
16949receipts;
16950unmatched = new Set();
16951busy = false;
16952/** A deliberate new scan, not a render or retry, starts a fresh generation. */
16953beginGeneration() {
16954if (this.busy) return false;
16955this.entries.clear();
16956this.receipts.beginGeneration();
16957this.unmatched.clear();
16958return true;
16959}
16960prepare(candidates) {
16961if (this.busy) return [];
16962this.entries.clear();
16963const settings2 = this.deps.getSettings();
16964const context = batchContext(settings2);
16965return candidates.map((candidate) => {
16966const identity = batchCardIdentity(candidate.card);
16967const destination = this.deps.resolveCollectionDestination(candidate.card, settings2);
16968const provider = destination === "anki" ? null : destination;
16969const reviewProvider = this.deps.resolveReviewProvider(candidate.card, settings2);
16970const blocked = normalizeCardStates(candidate.card.cardState).some((state) => ["blacklisted", "never-forget", "redundant", "suspended"].includes(state));
16971const grades = settings2.enableReviews && providerEnabled(reviewProvider, settings2) && !blocked ? reviewGradeScale(settings2, reviewGradeProfile(candidate.card, reviewProvider.id)).grades : [];
16972const entry = {
16973token: Symbol("batch-plan"),
16974source: candidate.card,
16975card: { ...candidate.card, cardState: [...candidate.card.cardState] },
16976sentence: candidate.sentence,
16977states: JSON.stringify(candidate.card.cardState),
16978identity,
16979context,
16980settings: { ...settings2 },
16981destination,
16982reviewProvider,
16983collectApi: Boolean(provider),
16984collectAnki: destination === "anki" || Boolean(provider && shouldMineAnkiAlongsideApi(settings2)),
16985grades,
16986receipts: receiptKeys(candidate.card, settings2, provider, reviewProvider)
16987};
16988this.entries.set(entry.token, entry);
16989return this.view(entry);
16990});
16991}
16992async execute(tokens, action, grade) {
16993if (this.busy) return { items: [], rejected: "busy" };
16994const entries2 = tokens.map((token) => this.entries.get(token));
16995if (!tokens.length || new Set(tokens).size !== tokens.length || entries2.some((entry) => !entry || !this.current(entry))) return this.reject(tokens, "stale");
16996const batch = entries2;
16997if (new Set(batch.map((entry) => entry.receipts[action === "review" ? "review" : entry.collectApi ? "api-collection" : "anki-collection"])).size !== batch.length) return this.reject(tokens, "stale");
16998if (action === "review" && (!grade || !commonBatchGrades(batch.map((entry) => this.view(entry))).some(([value]) => value === grade))) return this.reject(tokens, "incompatible");
16999const writes = action === "collect" ? batch.filter(hasDestination) : batch;
17000if (action === "collect" && (!writes.length || writes.some((entry) => !this.view(entry).canCollect))) return this.reject(tokens, "unavailable");
17001const operation = this.receipts.reserve(writes.map((entry) => receiptItem(entry, action)));
17002if (!operation) return this.reject(tokens, "capacity");
17003this.busy = true;
17004const items = [];
17005let matching;
17006try {
17007for (const entry of batch) {
17008if (!this.current(entry)) {
17009items.push(this.outcome(entry, "stale"));
17010break;
17011}
17012try {
17013if (action === "collect" && !hasDestination(entry)) {
17014items.push(this.outcome(entry, "no-destination"));
17015continue;
17016}
17017const onService = this.goesToGradingService(entry, action);
17018if (onService && needsMatch(entry)) await (matching ??= this.matchOnGradingService(batch, action));
17019if (onService && this.unmatched.has(entry.receipts.review)) {
17020items.push(this.outcome(entry, "unmatched"));
17021continue;
17022}
17023if (action === "collect") await this.collect(entry);
17024else await this.review(entry, grade);
17025items.push(this.outcome(entry, "completed"));
17026} catch (error) {
17027if (action === "collect" && userFacingCopyKeyOf(error) === "bunproNoMatchingWord") {
17028this.receipts.release(operation, receiptItem(entry, action).id);
17029items.push(this.outcome(entry, "no-destination"));
17030continue;
17031}
17032const state = this.completed(entry, action) ? "completed" : error instanceof StaleBatchPlan ? "stale" : this.receipts.get(entry.receipts.review) === "uncertain" ? "uncertain" : "failed";
17033items.push(this.outcome(entry, state));
17034break;
17035}
17036}
17037for (const entry of batch.slice(items.length)) items.push(this.outcome(entry, "unattempted"));
17038return { items };
17039} finally {
17040this.receipts.finish(operation);
17041this.busy = false;
17042}
17043}
17044view(entry) {
17045const stages = this.completedStages(entry);
17046const reviewUncertain = this.receipts.get(entry.receipts.review) === "uncertain";
17047const unmatched = this.unmatched.has(entry.receipts.review);
17048const due = entry.collectApi && !stages.includes("api-collection") || entry.collectAnki && !stages.includes("anki-collection");
17049return Object.freeze({
17050token: entry.token,
17051grades: Object.freeze((stages.includes("review") || reviewUncertain || unmatched ? [] : entry.grades).map((pair) => Object.freeze([...pair]))),
17052canCollect: due && !(unmatched && this.goesToGradingService(entry, "collect")),
17053noDestination: !hasDestination(entry),
17054uncertain: reviewUncertain,
17055unmatched
17056});
17057}
17058goesToGradingService(entry, action) {
17059if (action === "review") return true;
17060const provider = collectProvider(entry);
17061return Boolean(provider && provider.id === entry.reviewProvider?.id && !this.completedStages(entry).includes("api-collection"));
17062}
17063async matchOnGradingService(batch, action) {
17064const pending = batch.filter((entry) => this.goesToGradingService(entry, action) && needsMatch(entry));
17065const matches = await this.deps.findOnGradingService(pending[0].reviewProvider, pending.map((entry) => entry.card));
17066pending.forEach((entry, index) => {
17067const match = matches[index];
17068if (match) Object.assign(entry, { card: { ...match, cardState: [...match.cardState] }, resolved: true });
17069else this.unmatched.add(entry.receipts.review);
17070});
17071}
17072async collect(entry) {
17073const provider = collectProvider(entry);
17074if (provider && !this.completedStages(entry).includes("api-collection")) {
17075const word = this.serviceReceipt(entry, "api-collection");
17076if (this.receipts.get(word) !== "completed") {
17077const deck = await this.deps.collectionDeck(provider, entry.settings);
17078this.assertCurrent(entry);
17079if (!deck) throw new Error("No collection deck");
17080await provider.addToDeck(deck, entry.card, entry.sentence, { sourceTitle: document.title });
17081this.receipts.set(word, "completed");
17082}
17083this.receipts.set(entry.receipts["api-collection"], "completed");
17084this.assertCurrent(entry);
17085this.deps.notify(entry.card);
17086}
17087if (entry.collectAnki && !this.completedStages(entry).includes("anki-collection")) {
17088this.assertCurrent(entry);
17089if (!await this.deps.collectAnki(entry.card, entry.sentence, entry.settings.ankiDeck, () => this.assertCurrent(entry))) throw new Error("Collection not completed");
17090this.receipts.set(entry.receipts["anki-collection"], "completed");
17091}
17092this.assertCurrent(entry);
17093this.keepPageState(entry);
17094}
17095keepPageState(entry) {
17096if (!entry.resolved) entry.source.cardState = entry.card.cardState;
17097}
17098serviceReceipt(entry, stage) {
17099return receiptKeys(entry.card, entry.settings, collectProvider(entry), entry.reviewProvider)[stage];
17100}
17101async review(entry, grade) {
17102this.assertCurrent(entry);
17103const word = this.serviceReceipt(entry, "review");
17104const reviewed = () => {
17105this.receipts.set(entry.receipts.review, "completed");
17106this.receipts.set(word, "completed");
17107};
17108if (this.receipts.get(word) === "completed") {
17109reviewed();
17110return;
17111}
17112if (entry.reviewProvider?.id === "jpdb" && entry.card.cardState.includes("not-in-deck")) {
17113if (!this.completedStages(entry).includes("review-collection")) {
17114await this.deps.collectForReview(entry.card, entry.sentence, entry.settings.miningDeck || "forq");
17115this.receipts.set(entry.receipts["review-collection"], "completed");
17116}
17117this.assertCurrent(entry);
17118entry.card.cardState = [...entry.card.cardState.filter((state) =>
17118 state !== "not-in-deck"), "in-deck"];
17119}
17120try {
17121await this.deps.review(entry.reviewProvider, entry.card, grade, entry.sentence, () => this.assertCurrent(entry), reviewed);
17122reviewed();
17123} catch (error) {
17124if (entry.reviewProvider?.id === "bunpro" && !this.completedStages(entry).includes("review")) this.receipts.set(entry.receipts.review, "uncertain");
17125throw error;
17126}
17127this.assertCurrent(entry);
17128this.keepPageState(entry);
17129}
17130current(entry) {
17131const settings2 = this.deps.getSettings();
17132const destination = this.deps.resolveCollectionDestination(entry.source, settings2);
17133const reviewProvider = this.deps.resolveReviewProvider(entry.source, settings2);
17134return this.entries.get(entry.token) === entry && entry.context === batchContext(settings2) && entry.identity === batchCardIdentity(entry.source) && entry.states === JSON.stringify(entry.source.cardState) && destinationKey(destination) === destinationKey(entry.destination) && reviewProvider?.id === entry.reviewProvider?.id && reviewProvider?.hasApiKey === entry.reviewProvider?.hasApiKey;
17135}
17136assertCurrent(entry) {
17137if (!this.current(entry)) throw new StaleBatchPlan();
17138}
17139outcome(entry, state) {
17140return { token: entry.token, state, completedStages: this.completedStages(entry) };
17141}
17142completedStages(entry) {
17143return ["api-collection", "anki-collection", "review-collection", "review"].filter((stage) => this.receipts.get(entry.receipts[stage]) === "completed");
17144}
17145completed(entry, action) {
17146const stages = this.completedStages(entry);
17147return action === "review" ? stages.includes("review") : (!entry.collectApi || stages.includes("api-collection")) && (!entry.collectAnki || stages.includes("anki-collection"));
17148}
17149reject(tokens, rejected) {
17150return { rejected, items: tokens.map((token) => ({ token, state: "unattempted", completedStages: [] })) };
17151}
17152}
17153class StaleBatchPlan extends Error {
17154}
17155function providerEnabled(provider, settings2) {
17156return Boolean(provider?.hasApiKey && isApiSrsProviderEnabled(settings2, provider.id));
17157}
17158function hasDestination(entry) {
17159return entry.collectApi || entry.collectAnki;
17160}
17161function collectProvider(entry) {
17162return entry.destination === "anki" ? null : entry.destination;
17163}
17164function needsMatch(entry) {
17165return Boolean(entry.reviewProvider && !entry.reviewProvider.supportsCard(entry.card));
17166}
17167function destinationKey(destination) {
17168return destination === "anki" || !destination ? String(destination) : `${destination.id}:${destination.hasApiKey}`;
17169}
17170function receiptItem(entry, action) {
17171const stages = action === "review" ? ["review-collection", "review"] : [...entry.collectApi ? ["api-collection"] : [], ...entry.collectAnki ? ["anki-collection"] : []];
17172const required = action === "review" ? [entry.receipts.review] : stages.map((stage) => entry.receipts[stage]);
17173const id = action === "review" ? entry.receipts.review : entry.collectApi ? entry.receipts["api-collection"] : sensitiveFingerprint(JSON.stringify(["anki-item", entry.settings.activeLanguageProfileId, entry.card.spelling, entry.card.reading]));
17174return { id, keys: stages.map((stage) => entry.receipts[stage]), required };
17175}
17176function receiptKeys(card, settings2, provider, reviewProvider) {
17177const collect = serviceAccount(card, settings2, provider, "collect");
17178const review = serviceAccount(card, settings2, reviewProvider, "review");
17179return {
17180review: sensitiveFingerprint(JSON.stringify(["review", review.account])),
17181"api-collection": sensitiveFingerprint(JSON.stringify(["collect", collect.account, collect.deck])),
17182"review-collection": sensitiveFingerprint(JSON.stringify(["review-collect", review.account, review.deck])),
17183"anki-collection": sensitiveFingerprint(JSON.stringify([
17184"anki-collect",
17185settings2.ankiConnectUrl,
17186settings2.ankiDeck,
17187settings2.ankiModel,
17188settings2.activeLanguageProfileId,
17189card.spelling,
17190card.reading
17191]))
17192};
17193}
17194function serviceAccount(card, settings2, provider, use) {
17195const account = receiptAccount(card, settings2, provider, use);
17196return provider && !provider.supportsCard(card) ? { ...account, account: ["unidentified", card.source, ...account.account] } : account;
17197}
17198function receiptAccount(card, settings2, provider, use) {
17199const credentials = {
17200jiten: effectiveJitenApiKey(settings2),
17201jpdb: effectiveJpdbApiKey(settings2),
17202bunpro: [effectiveBunproFrontendApiToken(settings2), effectiveBunproLegacyApiKey(settings2)],
17203wanikani: effectiveWanikaniApiToken(settings2),
17204"yomu-local": settings2.activeLanguageProfileId
17205};
17206const identity = provider?.id === "jiten" ? [card.jitenWordId ?? card.vid, card.jitenReadingIndex ?? card.sid] : provider?.id === "bunpro" && use === "review" ? [card.bunproReviewId, card.bunproReviewSessionId, card.bunproReviewInputMode, card.bunproReviewEndpoint] : provider?.id === "wanikani" ? [card.wanikaniAssignmentId] : [card.vid, card.sid, card.spelling, card.reading];
17207return {
17208account: [provider?.id, provider ? credentials[provider.id] : "", identity],
17209deck: provider?.id === "jiten" ? "default-study-deck" : settings2.miningDeck
17210};
17211}
17212function batchContext(settings2) {
17213return sensitiveFingerprint(JSON.stringify(settings2));
17214}
17215function batchCardIdentity(card) {
17216return JSON.stringify([
17217card.vid,
17218card.sid,
17219card.rid,
17220card.spelling,
17221card.reading,
17222card.source,
17223card.reviewSource,
17224card.apiGradingProviderOverride,
17225card.jitenWordId,
17226card.jitenReadingIndex,
17227card.ankiCardId,
17228card.bunproReviewId,
17229card.bunproReviewSessionId,
17230card.bunproReviewInputMode,
17231card.bunproReviewEndpoint,
17232card.bunproReviewableId,
17233card.bunproReviewableType,
17234card.wanikaniAssignmentId
17235]);
17236}
17237function sameWordOnService(word, tokens, identifies) {
17238const spelling = word.spelling.trim();
17239const reading = word.reading.trim();
17240if (!spelling || !reading) return null;
17241return tokens.find(({ card }) => card.spelling.trim() === spelling && card.reading.trim() === reading && identifies(card))?.card ?? null;
17242}
17243async function findWordsOnService(words, parse, identifies) {
17244const matchable = words.filter((word) => word.spelling.trim() && word.reading.trim());
17245const parsed = matchable.length ? await parse(matchable.map((word) => word.spelling.trim())) : [];
17246return words.map((word) => sameWordOnService(word, parsed[matchable.indexOf(word)] ?? [], identifies));
17247}
17248const JITEN_TTS_API_BASE_URL = "https://api.jiten.moe/api/tts";
17249const JITEN_TTS_RANDOM_VOICES = ["female", "female2", "male", "male2", "asmr"];
17250function jitenTtsVoicesForValue(value) {
17251const voice = value?.trim();
17252return voice ? [voice] : [...JITEN_TTS_RANDOM_VOICES];
17253}
17254function preferredJitenTtsVoice(settings2) {
17255return settings2.audioSources.find(
17256(source) => source.enabled && source.type === "jiten-tts" && source.voice.trim()
17257)?.voice.trim() ?? "";
17258}
17259function jitenTtsVoicesForSettings(settings2) {
17260return jitenTtsVoicesForValue(preferredJitenTtsVoice(settings2));
17261}
17262function jitenWordTtsUrl(wordId, readingIndex, voice) {
17263return `${JITEN_TTS_API_BASE_URL}/word/${wordId}/${readingIndex}?voice=${encodeURIComponent(voice)}`;
17264}
17265function jitenSentenceTtsUrl(sentenceId, voice) {
17266return `${JITEN_TTS_API_BASE_URL}/sentence/${sentenceId}?voice=${encodeURIComponent(voice)}`;
17267}
17268async function renderStudyToolResult(button, action, sentence, grammarHints, language = "en", options = {}) {
17269await yomuKanjiStudyCompanion()?.renderStudyToolResult?.(button, action, sentence, grammarHints, language, options);
17270}
17271function handleStudyGrammarAction(button, sentence, language = "en", options = {}) {
17272return yomuKanjiStudyCompanion()?.handleStudyGrammarAction?.(button, sentence, language, options) ?? false;
17273}
17274function targetUsesCharacterDictionary() {
17275const target = activeLearningTarget();
17276return target.capabilities["character-lookup"] && target.experiences.characterLookup === "character-dictionary";
17277}
17278function usesJapaneseProviders() {
17279return activeLearningTarget().language === "ja";
17280}
17281function usesJapaneseCharacterStudy() {
17282return targetUsesCharacterDictionary() && usesJapaneseProviders();
17283}
17284function targetCanLookupCharacter(value) {
17285const target = activeLearningTarget();
17286return target.capabilities["character-lookup"] && target.experiences.characterLookup === "character-dictionary" && isUnifiedIdeograph(value);
17287}
17288function assertReviewableApiCardState(states) {
17289if (states.includes("blacklisted")) throw userFacingError("reviewBlockedBlacklisted");
17290if (states.includes("never-forget")) throw userFacingError("reviewBlockedNeverForget");
17291if (states.includes("redundant")) throw userFacingError("reviewBlockedRedundant");
17292}
17293class CardActionController {
17294constructor(options) {
17295this.options = options;
17296this.batchMining = new PreparedBatchActions({
17297getSettings: () => this.options.getSettings(),
17298resolveCollectionDestination: (card, settings2) => this.privateDefaultDestination(card, settings2),
17299resolveReviewProvider: (card, settings2) => this.gradingProviderForCard(card, settings2),
17300collectionDeck: (provider, settings2) => this.privateDefaultDeckId(provider, settings2),
17301collectAnki: (card, sentence, deck, assertCurrent) => this.addToAnkiForBatch(card, sentence, deck, assertCurrent),
17302collectForReview: (card, sentence, deck) => this.options.jpdb.addToDeck(deck, card, sentence),
17303findOnGradingService: (provider, cards) => this.findOnService(provider, cards),
17304review: (provider, card, grade, sentence, assertCurrent, onReviewed) => this.reviewApiCard(grade, card, sentence, { providerId: provider.id, deckId: defaultJpdbDeckId(this.options.getSettings()), suppressToast: true, assertCurrent, onReviewed }),
17305notify: (card) => this.notifyApiCardStateChanged(card)
17306});
17307}
17308batchMining;
17309async perform(command, button, card, sentence, context = {}) {
17310const studyAction = this.performStudyAction(command, button, sentence);
17311if (studyAction !== void 0) return await studyAction;
17312const readerAction = this.performReaderAction(cardCommandAction(command), card);
17313if (readerAction !== void 0) return await readerAction;
17314const miningAction = await this.performMiningAction(command, button, card, sentence, context);
17315if (miningAction !== void 0) return miningAction;
17316return Boolean(command);
17317}
17318performStudyAction(command, button, sentence) {
17319if (!command) return void 0;
17320return this.studyActionHandler(command, button, sentence)?.();
17321}
17322studyActionHandler(command, button, sentence) {
17323const action = command.action;
17324const handlers = {
17325"study-grammar-toggle-known": () => this.performStudyGrammarToggle(button, command, sentence),
17326"study-grammar-toggle-known-visibility": () => this.performStudyGrammarToggle(button, command, sentence),
17327"study-translate": () => this.performStudyTool(button, action, sentence),
17328"study-grammar": () => this.performStudyGrammarTool(button, sentence),
17329"study-read-sentence": () => this.performStudyReadSentence(button, command, sentence),
17330"jpdb-example-audio": () => this.performJpdbExampleAudio(command),
17331"jiten-audio": () => this.performJitenAudio(command, sentence),
17332"bunpro-audio": () => this.performBunproAudio(command, sentence),
17333"wanikani-audio": () => this.performWanikaniAudio(command),
17334"anki-media-audio": () => this.performAnkiMediaAudio(command)
17335};
17336return handlers[action];
17337}
17338performStudyGrammarToggle(button, command, sentence) {
17339const settings2 = this.options.getSettings();
17340handleStudyGrammarAction(button, sentence, settings2.interfaceLanguage, { audioEnabled: settings2.audioEnabled, command });
17341void this.reparsePopoverJapanese(button);
17342return false;
17343}
17344async performStudyTool(button, action, sentence) {
17345const settings2 = this.options.getSettings();
17346await renderStudyToolResult(button, action, sentence, void 0, settings2.interfaceLanguage, {
17347audioEnabled: settings2.audioEnabled,
17348outputLanguage: outputLanguageOf(settings2)
17349});
17350void this.reparsePopoverJapanese(button);
17351return false;
17352}
17353async performStudyGrammarTool(button, sentence) {
17354const settings2 = this.options.getSettings();
17355await renderStudyToolResult(button, "study-grammar", sentence, sentence ? await this.options.detectGrammarHints(sentence) : void 0, settings2.interfaceLanguage, { audioEnabled: settings2.audioEnabled });
17356void this.reparsePopoverJapanese(button);
17357return false;
17358}
17359async performStudyReadSentence(button, command, sentence) {
17360await this.options.playSentenceAudio(command.sentence?.trim() || this.studySentenceFromButton(button) || sentence);
17361return false;
17362}
17363studySentenceFromButton(button) {
17364const original = button.closest(".jpdb-reader-study-sentence-block")?.querySelector("[data-study-original-render]");
17365return original ? readerWordSurfaceText(original).replace(/\s+/g, " ").trim() : "";
17366}
17367async performJpdbExampleAudio(command) {
17368const audioIds = command.audioIds ?? "";
17369const fallbackSentence = command.sentence ?? "";
17370if (!this.options.playJpdbExampleAudio) await this.options.playSentenceAudio(fallbackSentence);
17371else await this.options.playJpdbExampleAudio(audioIds, fallbackSentence);
17372return false;
17373}
17374async performJitenAudio(command, sentence) {
17375const fallbackSentence = command.sentence?.trim() || sentence;
17376const audioUrls = jitenAudioUrlsForCommand(command, this.options.getSettings());
17377const played = await this.playFirstAvailableMediaUrl(audioUrls);
17378if (!played) await this.options.playSentenceAudio(fallbackSentence);
17379return false;
17380}
17381async performBunproAudio(command, sentence) {
17382const fallbackSentence = command.sentence?.trim() || sentence;
17383const audioUrl = command.audioUrl?.trim() ?? "";
17384const played = await this.playOptionalMediaUrl(audioUrl);
17385if (!played) await this.options.playSentenceAudio(fallbackSentence);
17386return false;
17387}
17388async performWanikaniAudio(command) {
17389if (!this.options.playMediaUrl) return false;
17390const audioUrl = safeWanikaniAudioUrl(command);
17391if (!audioUrl) return false;
17392await this.options.playMediaUrl(audioUrl);
17393return false;
17394}
17395async playFirstAvailableMediaUrl(audioUrls) {
17396if (!this.options.playMediaUrl) return false;
17397for (const audioUrl of audioUrls) {
17398if (await this.tryMediaUrl(audioUrl)) return true;
17399}
17400return false;
17401}
17402async playOptionalMediaUrl(audioUrl) {
17403if (!audioUrl || !this.options.playMediaUrl) return false;
17404return this.tryMediaUrl(audioUrl);
17405}
17406async tryMediaUrl(audioUrl) {
17407try {
17408return await this.options.playMediaUrl?.(audioUrl) !== false;
17409} catch {
17410return false;
17411}
17412}
17413async performAnkiMediaAudio(command) {
17414await this.playAnkiMediaAudio(command);
17415return false;
17416}
17417performReaderAction(action, card) {
17418if (!action) return void 0;
17419const handlers = {
17420"copy-word": () => this.copyWord(card),
17421audio: () => this.playCardAudio(card),
17422"setup-dictionaries": () => this.openSettingsPanel("dictionaries"),
17423"setup-jpdb": () => this.openSettingsPanel("api")
17424};
17425return handlers[action]?.();
17426}
17427async copyWord(card) {
17428await copyText(card.spelling);
17429this.options.toast(uiText$1(this.options.getSettings().interfaceLanguage, "copiedWord"));
17430return false;
17431}
17432async playCardAudio(card) {
17433await this.options.playAudio(card, { userGesture: true });
17434return false;
17435}
17436async openSettingsPanel(panel) {
17437this.options.showSettings(panel);
17438return false;
17439}
17440async performMiningAction(command, button, card, sentence, context) {
17441if (!command) return void 0;
17442if (command.action === "grade-provider-toggle") {
17443await this.toggleGradingProvider(card, sentence);
17444return false;
17445}
17446const handler = this.miningActionHandler(command, button, card, sentence, context);
17447if (handler) return this.finishMiningAction(handler());
17448return this.performApiDeckStateAction(command.action, card);
17449}
17450miningActionHandler(command, button, card, sentence, context) {
17451const handlers = {
17452add: () => this.addToSelectedDeck(command, card, sentence, context),
17453"add-default": () => this.addToPrivateDefaultDeck(card, sentence, context),
17454anki: () => this.addToAnki(card, sentence, void 0, context),
17455"anki-edit": () => this.openAnkiNote(command),
17456"anki-merge": () => this.mergeExistingAnkiCard(command, button, card, sentence, context),
17457grade: () => this.gradeCard(command, button, card, sentence)
17458};
17459return handlers[command.action];
17460}
17461async toggleGradingProvider(card, sentence) {
17462const settings2 = this.options.getSettings();
17463const current = this.gradingProviderForCard(card, settings2);
17464if (!current?.hasApiKey) return;
17465const cycle = apiSrsSwitchableProviderIds(card, settings2);
17466if (cycle.length < 2) return;
17467const next = cycle[(cycle.indexOf(current.id) + 1) % cycle.length];
17468if (!next || next === current.id || next === "yomu-local") return;
17469const provider = this.apiProviders(settings2).find((p) => p.id === next && p.hasApiKey);
17470if (!provider) return;
17471const [target] = provider.supportsCard(card) ? [card] : await this.findOnService(provider, [card]
17471).catch(() => [null]);
17472if (!target) return;
17473if (target !== card) copyBunproIdentity(card, target);
17474target.apiGradingProviderOverride = next;
17475await this.refreshProviderState(target, next);
17476this.options.invalidateCardData?.();
17477await this.options.showCard(target, sentence, this.options.getActivePopoverAnchor(), {
17478autoPlay: false,
17479trigger: this.options.getActivePopoverMode() === "hover" ? "hover" : "modal",
17480navigation: "preserve",
17481preservePosition: true
17482});
17483}
17484findOnService(provider, cards) {
17485const parse = (terms) => provider.id === "jiten" ? this.options.jiten?.parse?.(terms) ?? Promise.resolve([]) : this.options.jpdb.parse(terms);
17486return findWordsOnService(cards, parse, (candidate) => provider.supportsCard(candidate));
17487}
17488async refreshProviderState(card, providerId) {
17489try {
17490if (providerId === "bunpro" || providerId === "yomu-local") return;
17491if (providerId === "jiten") await this.options.jiten?.refreshCardState?.(card);
17492else await this.options.jpdb.refreshCardState?.(card);
17493} catch {
17494}
17495}
17496async performApiDeckStateAction(action, card) {
17497if (action === "neverforget") {
17498const settings2 = this.options.getSettings();
17499return this.finishMiningAction(this.changeProviderDeckState(card, "never-forget", settings2.neverForgetDeck));
17500}
17501if (action === "blacklist") {
17502const settings2 = this.options.getSettings();
17503return this.finishMiningAction(this.changeProviderDeckState(card, "blacklisted", settings2.blacklistDeck));
17504}
17505return void 0;
17506}
17507async finishMiningAction(action) {
17508await action;
17509return true;
17510}
17511async reparsePopoverJapanese(button) {
17512const popover = button.closest(".jpdb-reader-popover");
17513if (!popover) return;
17514delete popover.dataset.jpdbReaderParseKey;
17515delete popover.dataset.jpdbReaderParseLoadingKey;
17516delete popover.dataset.jpdbReaderParseLoadingId;
17517await this.options.parsePopoverJapanese(popover);
17518}
17519apiProviders(settings2 = this.options.getSettings()) {
17520return createApiSrsProviderAdapters({
17521jpdb: this.options.jpdb,
17522jiten: this.options.jiten,
17523bunpro: this.options.srsAdapters?.bunpro,
17524wanikani: this.options.srsAdapters?.wanikani,
17525yomuLocal: this.options.srsAdapters?.["yomu-local"],
17526isJpdbBackedCard: this.options.isJpdbBackedCard
17527}, settings2);
17528}
17529apiProviderForCard(card, settings2 = this.options.getSettings()) {
17530const supporting = this.apiProviders(settings2).filter((provider) => provider.supportsCard(card));
17531const keyed = supporting.filter((provider) => provider.hasApiKey);
17532const external = keyed.filter((provider) => provider.id !== "yomu-local");
17533const overridden = card.apiGradingProviderOverride ? external.find((provider) => provider.id === card.apiGradingProviderOverride) : void 0;
17534if (overridden) return overridden;
17535const bunpro = external.find((provider) => provider.id === "bunpro");
17536if (bunpro) return bunpro;
17537if (external.length > 1) {
17538const preferred = external.find((provider) => provider.id === apiGradingProviderPreference(settings2));
17539if (preferred) return preferred;
17540}
17541if (keyed.length) return external[0] ?? keyed[0] ?? null;
17542return supporting.find((provider) => provider.id === apiGradingProviderPreference(settings2)) ?? supporting[0] ?? null;
17543}
17544gradingProviderForCard(card, settings2) {
17545const resolveOn = apiGradingServiceToResolve(card, settings2, this.options.isJpdbBackedCard);
17546return resolveOn ? this.apiProviders(settings2).find((provider) => provider.id === resolveOn) ?? null : this.apiProviderForCard(card, settings2);
17547}
17548apiProviderForDeckSource(source, card, settings2) {
17549const resolveOn = apiGradingServiceToResolve(card, settings2, this.options.isJpdbBackedCard);
17550return this.apiProviders(settings2).find((provider) => provider.deckSource === source && (provider.id === resolveOn || acceptsForCollection(provider, card))) ?? null;
17551}
17552assertApiProviderActionAllowed(provider, copyKey) {
17553const settings2 = this.options.getSettings();
17554if (!isApiSrsProviderEnabled(settings2, provider?.id)) throw userFacingError("apiSrsActionsDisabled");
17555if (!provider?.hasApiKey) throw userFacingError(copyKey);
17556}
17557assertApiProviderReviewAllowed(provider, copyKey) {
17558const settings2 = this.options.getSettings();
17559if (!settings2.enableReviews) throw userFacingError("reviewActionsDisabled");
17560this.assertApiProviderActionAllowed(provider, copyKey);
17561}
17562async addToSelectedDeck(command, card, sentence, context) {
17563const settings2 = this.options.getSettings();
17564const deck = selectedDeckChoice(command, settings2);
17565if (deck.source === "anki") {
17566await this.addToAnki(card, sentence, deck.id, context);
17567return;
17568}
17569const provider = this.apiProviderForDeckSource(deck.source, card, settings2);
17570this.assertApiProviderActionAllowed(provider, providerAddApiKeyRequiredKey(provider, deck.source));
17571const selectedDeckId = provider.selectedDeckId(deck.id, settings2);
17572if (!selectedDeckId) throw userFacingError(missingProviderDeckKey(provider));
17573await this.addToApiProviderDeck(provider, selectedDeckId, card, sentence, context, settings2, await this.wordOnCollectionService(provider, card));
17574}
17575async addToPrivateDefaultDeck(card, sentence, context) {
17576if (this.accountDataSurfaceTrusted()) return this.addToDefaultDestination(card, sentence, context);
17577try {
17578await this.addToDefaultDestination(card, sentence, { ...context, privately: true });
17579} catch (error) {
17580throw privateCollectionFailure(error);
17581}
17582}
17583accountDataSurfaceTrusted() {
17584return this.options.accountDataSurfaceTrusted?.() ?? currentAccountDataSurfaceIsTrusted();
17585}
17586async addToDefaultDestination(card, sentence, context) {
17587const settings2 = this.options.getSettings();
17588const destination = this.privateDefaultDestination(card, settings2);
17589if (destination === "anki") return this.addToAnki(card, sentence, settings2.ankiDeck, c
17589ontext);
17590if (!destination) throw userFacingError("collectNoDestination");
17591const selectedDeckId = await this.privateDefaultDeckId(destination, settings2);
17592if (!selectedDeckId) throw userFacingError(missingProviderDeckKey(destination));
17593await this.addToApiProviderDeck(destination, selectedDeckId, card, sentence, context, settings2, await this.wordOnCollectionService(destination, card));
17594}
17595wordOnCollectionService(provider, card) {
17596return acceptsForCollection(provider, card) ? Promise.resolve(card) : this.resolveWordOnGradingService(card, provider, "collectWordNotFound");
17597}
17598privateDefaultDestination(card, settings2) {
17599const providers = this.apiProviders(settings2).filter((provider) => provider.hasApiKey);
17600for (const id of collectionDestinationsForCard(card, settings2, this.options.isJpdbBackedCard)) {
17601const destination = id === "anki" ? id : providers.find((candidate) => candidate.id === id);
17602if (destination) return destination;
17603}
17604return null;
17605}
17606async privateDefaultDeckId(provider, settings2) {
17607if (provider.id !== "jiten") return provider.selectedDeckId(settings2.miningDeck, settings2);
17608const decks = await this.options.jiten?.listStudyDecks?.().catch(() => []);
17609return String(decks?.[0]?.id ?? "");
17610}
17611async addToApiProviderDeck(provider, selectedDeckId, card, sentence, context, settings2, word = card) {
17612await provider.addToDeck(selectedDeckId, word, sentence, { sourceTitle: document.title, sourceUrl: location.href });
17613const minedToAnkiToo = shouldMineAnkiAlongsideApi(settings2);
17614if (minedToAnkiToo) await this.addToAnki(card, sentence, settings2.ankiDeck, c
17614ontext);
17615const droppedMedia = await this.apiMiningDroppedMedia(provider, minedToAnkiToo, card, sentence);
17616const addedToast = uiText$1(settings2.interfaceLanguage, context.privately ? "addedToDeckToast" : provider.addedToastKey);
17617this.options.toast(apiMiningToast(addedToast, droppedMedia, settings2));
17618this.notifyApiCardStateChanged(word);
17619}
17620async apiMiningDroppedMedia(provider, minedToAnkiToo, card, sentence) {
17621if (!providerCanDropMedia(provider, minedToAnkiToo)) return false;
17622const miningContext = await Promise.resolve(this.options.resolveMiningContext(card, sentence)).catch(() => null);
17623return miningContextHasMedia(miningContext);
17624}
17625async openAnkiNote(command) {
17626const settings2 = this.options.getSettings();
17627const noteId = command.noteId;
17628if (typeof noteId !== "number" || !Number.isFinite(noteId)) throw userFacingError("ankiNoteNotFound");
17629await this.options.anki.browseNote(noteId);
17630this.options.toast(uiText$1(settings2.interfaceLanguage, "openedInAnki"));
17631}
17632async playAnkiMediaAudio(command) {
17633const filename = command.mediaFilename?.trim();
17634if (!filename) throw userFacingError("ankiAudioFileNotFound");
17635if (!this.options.playMediaUrl) throw userFacingError("ankiAudioPlaybackUnavailable");
17636await this.options.playMediaUrl(await this.options.anki.mediaFileDataUrl(filename));
17637}
17638async mergeExistingAnkiCard(command, button, card, sentence, actionContext) {
17639const settings2 = this.options.getSettings();
17640const noteId = requiredAnkiNoteId(command.noteId);
17641const { dictionaryContext, context, wordAudio } = await this.loadAnkiCardAssets(card, sentence, settings2);
17642const result = await this.options.anki.mergeYomuData(noteId, card, miningSentenceForAnki(context.sentence, sentence), {
17643imageDataUrl: context.imageDataUrl,
17644audioDataUrl: context.audioDataUrl,
17645wordAudioDataUrl: wordAudio?.dataUrl,
17646wordAudioUrl: wordAudio?.url,
17647audioMergeMode: selectedAnkiAudioMergeMode(button, command),
17648...dictionaryContext,
17649dictionaryPreferences: settings2.dictionaryPreferences,
17650sentenceTarget: actionContext.sentenceTarget,
17651sourceTitle: ankiSourceTitle(context.sourceTitle),
17652sourceUrl: ankiSourceUrl(context.sourceUrl)
17653});
17654this.notifyAnkiStatusChanged(card);
17655this.options.toast(ankiMergeToast(result, settings2));
17656await this.options.showCard(card, sentence, this.options.getActivePopoverAnchor(), {
17657autoPlay: false,
17658trigger: activePopoverTrigger(this.options.getActivePopoverMode()),
17659navigation: "preserve",
17660preservePosition: true
17661});
17662}
17663async changeProviderDeckState(card, state, deck) {
17664const settings2 = this.options.getSettings();
17665const preferred = this.apiProviderForCard(card, settings2);
17666const provider = preferred?.supportsDeckState(state) ? preferred : this.apiProviders(settings2).find((candidate) => candidate.supportsCard(card) && candidate.supportsDeckState(state)) ?? preferred;
17667if (!provider && settings2.ankiEnabled && isAnkiDeckState(state) && await this.changeAnkiDeckState(card, state, settings2)) return;
17668this.assertApiProviderActionAllowed(provider, provider?.deckStateApiKeyRequiredKey ?? "jpdbDeckStateApiKeyRequired");
17669if (!provider.supportsDeckState(state)) throw userFacingError("actionFailed");
17670const wasSet = normalizeCardStates(card.cardState).includes(cardStateForApiState(state));
17671await provider.setDeckState(card, state, deck);
17672const toastKey = state === "blacklisted" || state === "never-forget" ? wasSet ? "removedFromDeck" : "addedToDeckToast" : "vocabularyStatusUpdated";
17673this.options.toast(uiText$1(settings2.interfaceLanguage, toastKey));
17674this.notifyApiCardStateChanged(card);
17675}
17676async changeAnkiDeckState(card, state, settings2) {
17677const lookup = await this.options.anki.findExistingCards(card).catch(() => null);
17678if (!lookup?.notes.length) return false;
17679if (state === "blacklisted") {
17680const cardIds = lookup.notes.flatMap((note) => note.cardIds);
17681const suspended = lookup.state === "suspended";
17682await this.options.anki.setCardsSuspended(cardIds, !suspended);
17683this.options.toast(uiText$1(settings2.interfaceLanguage, suspended ? "ankiCardsUnsuspended" : "ankiCardsSuspended"));
17684return true;
17685}
17686const noteIds = lookup.notes.map((note) => note.noteId);
17687const tagged = lookup.notes.every((note) => note.tags?.includes(ANKI_NEVER_FORGET_TAG));
17688await this.options.anki.setNotesTag(noteIds, ANKI_NEVER_FORGET_TAG, !tagged);
17689this.options.toast(uiText$1(settings2.interfaceLanguage, tagged ? "ankiNeverForgetTagRemoved" : "ankiNeverForgetTagAdded"));
17690return true;
17691}
17692async gradeCard(command, button, card, sentence) {
17693if (!privateReviewGradeAllowed(button, command)) throw userFacingError("actionFailed");
17694const grade = command.grade;
17695if (!grade) throw userFacingError("actionFailed");
17696const selection = selectedPopoverReviewTarget(button, command);
17697await this.reviewGrade(grade, card, sentence, {
17698target: selection.kind,
17699ankiCardId: selection.ankiCardId,
17700deckId: defaultJpdbDeckId(this.options.getSettings())
17701});
17702}
17703async reviewGrade(grade, card, sentence, options = {}) {
17704const settings2 = this.options.getSettings();
17705if (!settings2.enableReviews) throw userFacingError("reviewActionsDisabled");
17706if (options.target === "both") {
17707await this.reviewApiCard(grade, card, sentence, options);
17708await this.answerAnkiCard(grade, card, options.ankiCardId);
17709return;
17710}
17711if (options.target === "jpdb" || options.target === "jiten" || options.target === "bunpro" || options.target === "yomu-local") {
17712await this.reviewApiCard(grade, card, sentence, { ...options, providerId: options.target });
17713return;
17714}
17715if (options.target === "anki" || options.ankiCardId) {
17716await this.answerAnkiCard(grade, card, options.ankiCardId);
17717return;
17718}
17719await this.reviewApiCard(grade, card, sentence, options);
17720}
17721async answerAnkiCard(grade, card, ankiCardId) {
17722if (ankiCardId) {
17723await this.options.anki.answerCard(ankiCardId, grade);
17724this.notifyAnkiStatusChanged(card);
17725return;
17726}
17727throw userFacingError("missingAnkiCardId");
17728}
17729async reviewApiCard(grade, card, sentence, options) {
17730const settings2 = this.options.getSettings();
17731const resolveOn = apiGradingServiceToResolve(card, settings2, this.options.isJpdbBackedCard);
17732const provider = options.providerId ? this.apiProviders(settings2).find((candidate) => candidate.id === options.providerId && (candidate.supportsCard(card) || c
17732andidate.id === resolveOn)) ?? null : this.gradingProviderForCard(card, settings2);
17733this.assertApiProviderReviewAllowed(provider, provider?.reviewApiKeyRequiredKey ?? "addJpdbApiKeyReview");
17734const target = provider.supportsCard(card) ? card : await this.resolveWordOnGradingService(card, provider);
17735const states = normalizeCardStates(target.cardState);
17736assertReviewableApiCardState(states);
17737const result = await provider.reviewCard(target, grade, { sentence, deckId: this.reviewDeckId(options) });
17738options.onReviewed?.();
17739options.assertCurrent?.();
17740if (result.addedBeforeReview) {
17741if (!options.suppressToast) this.options.toast(uiText$1(settings2.interfaceLanguage, "addedToDeckAndReviewed"));
17742} else if (settings2.autoMineOnReview) await this.autoMineReviewedCard(provider, target, sentence, states, settings2, options.suppressToast === true);
17743this.notifyApiCardStateChanged(target);
17744}
17745async resolveWordOnGradingService(card, provider, notFound = "gradingServiceWordNotFound") {
17746const [match] = await this.findOnService(provider, [card]);
17747if (!match) throw userFacingError(notFound);
17748return match;
17749}
17750async autoMineReviewedCard(provider, card, sentence, states, settings2, suppressToast = false) {
17751if (!states.includes("not-in-deck")) return;
17752try {
17753const deckId = provider.selectedDeckId(this.reviewDeckId({}), settings2);
17754if (!deckId) return;
17755await provider.addToDeck(deckId, card, sentence, { sourceTitle: document.title });
17756if (!suppressToast) this.options.toast(uiText$1(settings2.interfaceLanguage, "addedToDeckAndReviewed"));
17757} catch {
17758}
17759}
17760reviewDeckId(options) {
17761return options.deckId || this.options.getSettings().miningDeck || "forq";
17762}
17763async addToAnki(card, sentence, deckName, context = {}) {
17764const settings2 = this.options.getSettings();
17765if (await this.addToAnkiViaMobileHandoff(card, sentence, deckName, settings2, context)) return;
17766if (await this.showExistingAnkiCardIfPresent(card, sentence, context)) return;
17767const prepared = await this.prepareAnkiAdd(card, sentence, deckName, settings2, context);
17768const noteId = await this.addPreparedAnkiCard(card, prepared);
17769if (noteId === "duplicate") return this.showExistingAnkiCard(card, sentence, context);
17770if (noteId === null) return this.toastMobileAnkiHandoff(context);
17771this.notifyAnkiStatusChanged(card);
17772this.collectionToast(context, ankiSentToast(prepared.context, settings2, prepared.hasWordAudio), "addedToDeckToast");
17773}
17774collectionToast(context, named, neutral) {
17775this.options.toast(context.privately ? uiText$1(this.options.getSettings().interfaceLanguage, neutral) : named);
17776}
17777async addToAnkiForBatch(card, sentence, deckName, assertCurrent) {
17778const settings2 = this.options.getSettings();
17779const existing = await this.options.anki.findExistingCards(card);
17780assertCurrent();
17781if (existing.primary) return true;
17782const prepared = await this.prepareAnkiAdd(card, sentence, deckName, settings2, {});
17783assertCurrent();
17784const noteId = await this.addPreparedAnkiCard(card, prepared);
17785if (noteId === "duplicate") return true;
17786if (noteId === null) return false;
17787this.notifyAnkiStatusChanged(card);
17788return true;
17789}
17790async addToAnkiViaMobileHandoff(card, sentence, deckName, settings2, context) {
17791if (!canUseMobileAnkiHandoff(settings2)) return false;
17792await this.options.anki.addCardViaMobileHandoff(card, mobileAnkiSentence(card, sentence), {
17793deckName,
17794dictionaryPreferences: settings2.dictionaryPreferences,
17795sentenceTarget: context.sentenceTarget
17796});
17797this.toastMobileAnkiHandoff(context);
17798return true;
17799}
17800async showExistingAnkiCardIfPresent(card, sentence, context) {
17801const existing = await this.options.anki.findExistingCards(card);
17802if (!existing.primary) return false;
17803await this.showExistingAnkiCard(card, sentence, context);
17804return true;
17805}
17806async prepareAnkiAdd(card, sentence, deckName, settings2, actionContext) {
17807const { dictionaryContext, context, wordAudio } = await this.loadAnkiCardAssets(card, sentence, settings2);
17808return {
17809context,
17810hasWordAudio: hasResolvedAnkiWordAudio(wordAudio),
17811options: {
17812deckName,
17813imageDataUrl: context.imageDataUrl,
17814audioDataUrl: context.audioDataUrl,
17815wordAudioDataUrl: wordAudio?.dataUrl,
17816wordAudioUrl: wordAudio?.url,
17817...dictionaryContext,
17818dictionaryPreferences: settings2.dictionaryPreferences,
17819sentenceTarget: actionContext.sentenceTarget,
17820sourceTitle: ankiSourceTitle(context.sourceTitle),
17821sourceUrl: ankiSourceUrl(context.sourceUrl)
17822},
17823sentence: miningSentenceForAnki(context.sentence, sentence)
17824};
17825}
17826async addPreparedAnkiCard(card, prepared) {
17827try {
17828return await this.options.anki.addCard(card, prepared.sentence, prepared.options);
17829} catch (error) {
17830return duplicateAnkiAddResult(error);
17831}
17832}
17833async loadAnkiCardAssets(card, sentence, settings2) {
17834const [dictionaryContext, context, wordAudio] = await Promise.all([
17835this.loadAnkiDictionaryContext(card, settings2),
17836this.options.resolveMiningContext(card, sentence),
17837resolveAnkiWordAudio(card, settings2).catch(() => null)
17838]);
17839return { dictionaryContext, context, wordAudio };
17840}
17841toastMobileAnkiHandoff(context) {
17842this.collectionToast(context, uiText$1(this.options.getSettings().interfaceLanguage, "openedMobileAnkiHandoff"), "collectHandoffOpened");
17843}
17844notifyAnkiStatusChanged(card) {
17845this.options.invalidateCardData?.();
17846this.options.onAnkiStatusChanged?.(card);
17847}
17848notifyApiCardStateChanged(card) {
17849this.options.invalidateCardData?.();
17850this.options.onApiCardStateChanged?.(card);
17851publishCardStateSignal(card);
17852}
17853async showExistingAnkiCard(card, sentence, context) {
17854const settings2 = this.options.getSettings();
17855this.collectionToast(context, uiText$1(settings2.interfaceLanguage, "alreadyInAnki"), "collectAlreadySaved");
17856await this.options.showCard(card, sentence, this.options.getActivePopoverAnchor(), {
17857autoPlay: false,
17858trigger: this.options.getActivePopoverMode() === "hover" ? "hover" : "modal",
17859navigation: "preserve",
17860preservePosition: true
17861});
17862}
17863async loadAnkiDictionaryContext(card, settings2) {
17864const [localEntries, kanjiEntries, metaEntries] = await Promise.all([
17865this.lookupAnkiLocalTerms(card, settings2),
17866this.lookupAnkiLocalKanji(card, settings2),
17867this.lookupAnkiLocalMeta(card, settings2)
17868]);
17869return { localEntries, kanjiEntries, metaEntries };
17870}
17871lookupAnkiLocalTerms(card, settings2) {
17872return settings2.localDictionariesEnabled ? this.options.dictionaries.lookup(card.spelling, card.reading, settings2.localDictionaryMaxResults, settings2.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
17873}
17874lookupAnkiLocalKanji(card, settings2) {
17875return targetUsesCharacterDictionary() && settings2.localDictionariesEnabled && settings2.localDictionaryShowKanji ? this.options.dictionaries.lookupKanji(card.spelling, settings2.localDictionaryMaxResults, settings2.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
17876}
17877lookupAnkiLocalMeta(card, settings2) {
17878return settings2.localDictionariesEnabled ? this.options.dictionaries.lookupTermMeta(card.spelling, 12, settings2.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
17879}
17880}
17881function jitenAudioUrlsForCommand(command, settings2) {
17882return uniqueTrimmed([
17883...command.audioUrls ?? [],
17884...generatedJitenAudioUrlsForCommand(command, settings2)
17885]);
17886}
17887function cardCommandAction(command) {
17888return command ? command.action : void 0;
17889}
17890function safeWanikaniAudioUrl(command) {
17891if (typeof command.audioUrl !== "string") return null;
17892const audioUrl = command.audioUrl.trim();
17893if (!audioUrl) return null;
17894const url = new URL(audioUrl);
17895if (url.protocol !== "https:") throw new Error("Blocked an unsafe WaniKani audio URL.");
17896return url.href;
17897}
17898const PROVIDER_ADD_API_KEY_REQUIRED_KEYS = {
17899jpdb: "jpdbAddApiKeyRequired",
17900jiten: "jitenAddApiKeyRequired",
17901bunpro: "bunproAddApiKeyRequired",
17902wanikani: "wanikaniAddApiKeyRequired",
17903"yomu-local": "yomuLocalSrsDisabled"
17904};
17905function providerAddApiKeyRequiredKey(provider, source) {
17906return provider ? provider.addApiKeyRequiredKey : PROVIDER_ADD_API_KEY_REQUIRED_KEYS[source];
17907}
17908const PRIVATE_COLLECTION_FAILURES = new Set(["collectNoDestination", "collectWordNotFound"]);
17909function privateCollectionFailure(error) {
17910const copyKey = userFacingCopyKeyOf(error);
17911return copyKey && PRIVATE_COLLECTION_FAILURES.has(copyKey) ? error : userFacingError("collectNotSaved", { cause: error });
17912}
17913function acceptsForCollection(provider, card) {
17914return provider.supportsMiningCard?.(card) ?? provider.supportsCard(card);
17915}
17916function missingProviderDeckKey(provider) {
17917return provider.id === "jiten" ? "chooseJitenStudyDeck" : provider.addApiKeyRequiredKey;
17918}
17919function providerCanDropMedia(provider, minedToAnkiToo) {
17920return provider.id !== "bunpro" && !minedToAnkiToo;
17921}
17922function miningContextHasMedia(context) {
17923return Boolean(context?.imageDataUrl || context?.audioDataUrl);
17924}
17925function apiMiningToast(addedToast, droppedMedia, settings2) {
17926return droppedMedia ? `${addedToast} ${uiText$1(settings2.interfaceLanguage, "apiDeckMediaNotSupported")}` : addedToast;
17927}
17928function requiredAnkiNoteId(noteId) {
17929if (typeof noteId !== "number" || !Number.isFinite(noteId)) throw userFacingError("ankiNoteNotFound");
17930return noteId;
17931}
17932function activePopoverTrigger(mode) {
17933return mode === "hover" ? "hover" : "modal";
17934}
17935function generatedJitenAudioUrlsForCommand(command, settings2) {
17936const sentenceId = finitePositiveInteger(command.jitenSentenceId);
17937const voices = jitenTtsVoicesForSettings(settings2);
17938if (sentenceId !== void 0) return voices.map((voice) => jitenSentenceTtsUrl(sentenceId, voice));
17939const wordId = finitePositiveInteger(command.jitenWordId);
17940const readingIndex = finiteNonNegativeInteger(command.jitenReadingIndex);
17941if (wordId === void 0 || readingIndex === void 0) return [];
17942return voices.map((voice) => jitenWordTtsUrl(wordId, readingIndex, voice));
17943}
17944function finitePositiveInteger(value) {
17945if (typeof value !== "number") return void 0;
17946if (!Number.isInteger(value)) return void 0;
17947return value > 0 ? value : void 0;
17948}
17949function finiteNonNegativeInteger(value) {
17950if (typeof value !== "number") return void 0;
17951if (!Number.isInteger(value)) return void 0;
17952return value >= 0 ? value : void 0;
17953}
17954function uniqueTrimmed(values) {
17955const seen = new Set();
17956const result = [];
17957for (const value of values) {
17958const trimmed = value.trim();
17959if (!trimmed || seen.has(trimmed)) continue;
17960seen.add(trimmed);
17961result.push(trimmed);
17962}
17963return result;
17964}
17965function miningSentenceForAnki(contextSentence, fallbackSentence) {
17966return contextSentence || fallbackSentence;
17967}
17968function mobileAnkiSentence(card, sentence) {
17969return sentence || card.sentence || "";
17970}
17971function hasResolvedAnkiWordAudio(wordAudio) {
17972return Boolean(wordAudio?.dataUrl || wordAudio?.url);
17973}
17974function duplicateAnkiAddResult(error) {
17975if (isAnkiDuplicateNoteError(error)) return "duplicate";
17976throw error;
17977}
17978function ankiSentToast(context, settings2, hasWordAudio = false) {
17979const language = settings2.interfaceLanguage;
17980const hasAudio = Boolean(context.audioDataUrl || hasWordAudio);
17981if (context.imageDataUrl && hasAudio) return uiText$1(language, "sentToAnkiWithContextImageAndAudio");
17982if (context.imageDataUrl) return uiText$1(language, "sentToAnkiWithContextImage");
17983if (hasAudio) return uiText$1(language, "sentToAnkiWithAudio");
17984return uiText$1(language, "sentToAnki");
17985}
17986function ankiMergeToast(result, settings2) {
17987const language = settings2.interfaceLanguage;
17988if (!result.updatedFields.length && !result.audioAdded && !result.imageAdded) return uiText$1(language, "ankiMergeNoNewData");
17989const parts = [
17990result.updatedFields.length ? `${result.updatedFields.length} ${uiText$1(language, result.updatedFields.length === 1 ? "ankiMergeFieldSingular" : "ankiMergeFieldPlural")}` : "",
17991result.audioAdded ? uiText$1(language, "ankiMergeAudio") : "",
17992result.imageAdded ? uiText$1(language, "ankiMergeImage") : ""
17993].filter(Boolean);
17994return formatUiText(language, "ankiMergeComplete", { parts: uiList(language, parts) });
17995}
17996function selectedAnkiAudioMergeMode(button, command) {
17997const option = selectedOption(button, ".jpdb-reader-anki-card-preview", "[data-anki-audio-merge]");
17998const selected = readAnkiAudioMergeCapability(option);
17999if (selected) return selected.mode;
18000return command.audioMergeMode ?? "both";
18001}
18002function selectedPopoverReviewTarget(button, command) {
18003const option = selectedOption(button, ".jpdb-reader-actions", "[data-review-target-select]");
18004const selected = readReviewTargetCapability(option);
18005return {
18006kind: reviewTargetKind(selectedReviewTarget(selected, command)),
18007ankiCardId: selectedReviewAnkiCardId(selected, command)
18008};
18009}
18010function selectedOption(button, rootSelector, selectSelector) {
18011const root = button.closest(rootSelector);
18012if (!root) return null;
18013const select = root.querySelector(selectSelector);
18014if (!select) return null;
18015return select.options[select.selectedIndex] ?? null;
18016}
18017function selectedReviewTarget(selected, command) {
18018return selected ? selected.target : command.reviewTarget;
18019}
18020function selectedReviewAnkiCardId(selected, command) {
18021return selected ? selected.ankiCardId : command.ankiCardId;
18022}
18023function copyBunproIdentity(source, target) {
18024if (source.bunproReviewId) target.bunproReviewId = source.bunproReviewId;
18025if (source.bunproReviewableId) target.bunproReviewableId = source.bunproReviewableId;
18026if (source.bunproReviewableType) target.bunproReviewableType = source.bunproReviewableType;
18027if (source.bunproSrsLevel) target.bunproSrsLevel = source.bunproSrsLevel;
18028if (source.bunproReviewSessionId) target.bunproReviewSessionId = source.bunproReviewSessionId;
18029if (source.bunproReviewInputMode) target.bunproReviewInputMode = source.bunproReviewInputMode;
18030if (source.bunproReviewEndpoint) target.bunproReviewEndpoint = source.bunproReviewEndpoint;
18031}
18032function reviewTargetKind(value) {
18033if (value === "both" || value === "anki") return value;
18034if (value === "jpdb" || value === "jiten" || value === "bunpro" || value === "yomu-local") return value;
18035return void 0;
18036}
18037function isAnkiDeckState(state) {
18038return state === "never-forget" || state === "blacklisted";
18039}
18040function ankiSourceTitle(sourceTitle) {
18041return sourceTitle || document.title;
18042}
18043function ankiSourceUrl(sourceUrl) {
18044return sourceUrl || location.href;
18045}
18046function selectedDeckChoice(command, settings2) {
18047const source = command.deckSource ?? "jpdb";
18048return {
18049source,
18050id: command.deckId?.trim() || defaultDeckIdForSource(source, settings2)
18051};
18052}
18053function defaultDeckIdForSource(source, settings2) {
18054if (source === "anki") return defaultAnkiDeckName(settings2);
18055if (source === "jiten") return "";
18056if (source === "yomu-local") return "yomu-local";
18057return defaultJpdbDeckId(settings2);
18058}
18059function defaultAnkiDeckName(settings2) {
18060return settings2.ankiDeck || "よむ";
18061}
18062function defaultJpdbDeckId(settings2) {
18063return settings2.miningDeck.trim() || "forq";
18064}
18065const TOAST_STACK_CLASS = "jpdb-reader-toast-stack";
18066const TOAST_VISIBLE_CLASS = "is-visible";
18067const TOAST_EXIT_MS = 220;
18068const toastTimers = new WeakMap();
18069const toastHolds = new WeakMap();
18070function showReaderToast(message, durationMs = 3200) {
18071const toast = readerToast(message);
18072if (!toastHolds.has(toast)) scheduleToastRemoval(toast, durationMs);
18073}
18074function holdReaderToast(message) {
18075const toast = readerToast(message);
18076toastHolds.set(toast, (toastHolds.get(toast) ?? 0) + 1);
18077let held = true;
18078return () => {
18079if (!held) return;
18080held = false;
18081const holds = (toastHolds.get(toast) ?? 1) - 1;
18082if (holds > 0) {
18083toastHolds.set(toast, holds);
18084return;
18085}
18086toastHolds.delete(toast);
18087scheduleToastRemoval(toast, 0);
18088};
18089}
18090function readerToast(message) {
18091const stack = ensureReaderToastStack();
18092const existing = Array.from(stack.children).find((node) => node instanceof HTMLElement && node.textContent === message);
18093if (existing) {
18094window.clearTimeout(toastTimers.get(existing));
18095toastTimers.delete(existing);
18096existing.classList.add(TOAST_VISIBLE_CLASS);
18097return existing;
18098}
18099const toast = document.createElement("div");
18100toast.className = "jpdb-reader-toast";
18101toast.setAttribute("role", "status");
18102toast.setAttribute("aria-live", "polite");
18103toast.textContent = message;
18104stack.append(toast);
18105if (typeof requestAnimationFrame === "function") {
18106requestAnimationFrame(() => toast.classList.add(TOAST_VISIBLE_CLASS));
18107} else {
18108toast.classList.add(TOAST_VISIBLE_CLASS);
18109}
18110return toast;
18111}
18112function ensureReaderToastStack() {
18113const existing = document.querySelector(`.${TOAST_STACK_CLASS}`);
18114if (existing?.isConnected) {
18115applyOverlayPageScale(existing);
18116return existing;
18117}
18118const stack = document.createElement("div");
18119stack.className = TOAST_STACK_CLASS;
18120stack.dataset.jpdbReaderRoot = "true";
18121applyOverlayPageScale(stack);
18122document.body.append(stack);
18123return stack;
18124}
18125function scheduleToastRemoval(toast, durationMs) {
18126window.clearTimeout(toastTimers.get(toast));
18127toastTimers.set(toast, window.setTimeout(() => {
18128toast.classList.remove(TOAST_VISIBLE_CLASS);
18129toastTimers.set(toast, window.setTimeout(() => {
18130toastTimers.delete(toast);
18131toast.remove();
18132if (typeof document === "undefined") return;
18133const stack = document.querySelector(`.${TOAST_STACK_CLASS}`);
18134if (stack && !stack.childElementCount) stack.remove();
18135}, TOAST_EXIT_MS));
18136}, durationMs));
18137}
18138async function withSaveWaitStatus(language, save) {
18139let release;
18140const stopWatching = watchSavesWaitingForAnotherTab((waiting) => {
18141if (waiting) {
18142release ??= holdReaderToast(uiText$1(language, "saveWaitingForAnotherTab"));
18143return;
18144}
18145release?.();
18146release = void 0;
18147});
18148try {
18149return await save();
18150} finally {
18151stopWatching();
18152release?.();
18153}
18154}
18155async function runCardActionOperation(button, run, feedback, finish) {
18156const restoreFocus = keepKeyboardFocus(button);
18157button.disabled = true;
18158try {
18159await withSaveWaitStatus(feedback.language, run);
18160} catch (error) {
18161reportCardActionFailure(feedback, error);
18162} finally {
18163finish();
18164button.disabled = false;
18165restoreFocus();
18166}
18167}
18168function keepKeyboardFocus(button) {
18169const document2 = button.ownerDocument;
18170const action = button.dataset.action;
18171if (!action || document2.activeElement !== button) return () => void 0;
18172return () => {
18173const active = document2.activeElement;
18174if (active && active !== document2.body && !active.matches(".jpdb-reader-popover")) return;
18175const replacement = button.isConnected ? button : [...document2.querySelectorAll(".jpdb-reader-popover button[data-action]")].reverse().find((candidate) => candidate.dataset.action === action);
18176replacement?.focus({ preventScroll: true });
18177};
18178}
18179function reportCardActionFailure(feedback, error) {
18180feedback.logger.warn(feedback.warning, { action: feedback.action, term: feedback.term }, error);
18181feedback.toast(userFacingErrorText(feedback.language, "actionFailed", error));
18182}
18183async function refreshAfterCardAction(action, perform, dismissGrade, refresh) {
18184if (!await perform()) return;
18185if (action === "grade") {
18186dismissGrade();
18187return;
18188}
18189await refresh();
18190}
18191function collectAnkiReviewTargetLabels(seeds, notes) {
18192const candidates = new Map();
18193seeds.forEach((seed) => addAnkiReviewTargetLabel(candidates, seed.cardId, seed.label, seed.cardName));
18194notes.forEach((note) => addAnkiReviewTargetNote(candidates, note));
18195return Array.from(candidates, ([cardId, label]) => ({ cardId, label }));
18196}
18197function compactAnkiReviewTargetLabel(label, cardId) {
18198const suffix = `#${cardId}`;
18199const clean = label.replace(/\s+/g, " ").trim();
18200if (!clean) return `Anki ${suffix}`;
18201return clean.endsWith(suffix) ? clean : `${clean} ${suffix}`;
18202}
18203function addAnkiReviewTargetNote(candidates, note) {
18204const noteLabel = note.deckNames.join(", ") || note.modelName || "Anki";
18205note.renderedCards?.forEach((rendered) => addAnkiReviewTargetLabel(
18206candidates,
18207rendered.cardId,
18208rendered.deckName || noteLabel,
18209rendered.cardName
18210));
18211addAnkiReviewTargetLabel(candidates, note.primaryCardId, noteLabel);
18212note.cardIds.forEach((cardId) => addAnkiReviewTargetLabel(candidates, cardId, noteLabel));
18213}
18214function addAnkiReviewTargetLabel(candidates, cardId, label, cardName = "") {
18215const id = Number(cardId);
18216if (!Number.isFinite(id) || id <= 0 || candidates.has(id)) return;
18217const deck = label.trim() || "Anki";
18218const template = cardName.trim();
18219candidates.set(id, template ? [deck, `${template} #${id}`].join(" · ") : [deck, `#${id}`].join(" "));
18220}
18221function headwordFuriganaSettings(settings2) {
18222return { ...settings2, showFurigana: true, furiganaMode: "all" };
18223}
18224function renderCardSpellingWithFurigana(card, settings2, kanjiNavigation) {
18225const spelling = card.spelling.trim();
18226if (!spelling) return "";
18227const token = cardSpellingFuriganaToken(card, spelling);
18228return shouldRenderRuby(spelling, token, headwordFuriganaSettings(settings2), true, true) ? renderRuby(spelling, token, kanjiNavigation, true) : renderKanjiNavigationText(spelling, kanjiNavigation);
18229}
18230function cardSpellingFuriganaToken(card, spelling) {
18231const rubies = annotatedWordRubies(spelling, card.wordWithReading ?? "");
18232const annotatedReading = rubies.length ? readingFromSurfaceRubies(spelling, rubies) : "";
18233const reading = annotatedReading || activeLearningTarget().normalizeReading(spelling, card.reading).trim();
18234return {
18235card: { ...card, spelling, reading },
18236start: 0,
18237end: spelling.length,
18238length: spelling.length,
18239rubies,
18240pitchClass: "",
18241sentence: spelling
18242};
18243}
18244function renderHeadwordComponentPitchSpans(card, segments, settings2, kanjiNavigation) {
18245const classified = segments.map((segment) => ({
18246segment,
18247pitchClass: segment.pitch ? getPitchClass([segment.pitch.pitch], segment.pitch.reading) : ""
18248}));
18249if (classified.some(({ segment, pitchClass }) => segment.pitch && !pitchClass)) return "";
18250return classified.map(({ segment, pitchClass }) => {
18251if (!segment.pitch) return renderKanjiNavigationText(segment.text, kanjiNavigation);
18252const { text: text2, reading } = segment.pitch;
18253const content = renderCardSpellingWithFurigana({
18254...card,
18255spelling: text2,
18256reading,
18257wordWithReading: null
18258}, settings2, kanjiNavigation);
18259return `<span class="jpdb-reader-pitch-component-headword jpdb-pitch-${pitchClass}" data-pitch-class="${escapeHtml(pitchClass)}">${content}</span>`;
18260}).join("");
18261}
18262const JAPANESE_TEXT_RE = /[\u3040-\u30ff\u3400-\u9fff々〆]/u;
18263function cardHighlightTargets(card) {
18264const spelling = cleanCardHighlightValue(card.spelling);
18265const reading = optionalJapaneseCardReading(card);
18266return uniqueCardHighlightValues([spelling, reading]);
18267}
18268function normalizedJapaneseCardReading(spelling, reading) {
18269const cleanSpelling = cleanCardHighlightValue(spelling);
18270const cleanReading = cleanCardHighlightValue(reading);
18271return cleanReading && JAPANESE_TEXT_RE.test(cleanReading) ? cleanReading : cleanSpelling;
18272}
18273function cleanCardHighlightValue(value) {
18274return (value ?? "").replace(/\s+/g, " ").trim();
18275}
18276function compactCardHighlightValue(value) {
18277return cleanCardHighlightValue(value).replace(/\s+/g, "");
18278}
18279function optionalJapaneseCardReading(card) {
18280const spelling = cleanCardHighlightValue(card.spelling);
18281const reading = normalizedJapaneseCardReading(spelling, card.reading);
18282return reading && reading !== spelling ? reading : "";
18283}
18284function uniqueCardHighlightValues(values) {
18285const seen = new Set();
18286return values.map(cleanCardHighlightValue).filter((value) => {
18287if (!value || seen.has(value)) return false;
18288seen.add(value);
18289return true;
18290});
18291}
18292const CARD_HIGHLIGHT_CLASS = "jpdb-reader-example-target";
18293function cardHighlightScopeAttributes(card) {
18294if (!card) return "";
18295const spelling = cleanCardHighlightValue(card.spelling);
18296if (!spelling) return "";
18297return [
18298`data-card-highlight-spelling="${escapeHtml(spelling)}"`,
18299`data-card-highlight-reading="${escapeHtml(cleanCardHighlightValue(card.reading))}"`,
18300...cardHighlightIdentityAttributes(card)
18301].filter(Boolean).join(" ");
18302}
18303function cardHighlightIdentityAttributes(card) {
18304if (!currentAccountDataSurfaceIsTrusted()) return [];
18305return [
18306cardHighlightIdentityAttribute("vid", card.vid),
18307cardHighlightIdentityAttribute("sid", card.sid)
18308];
18309}
18310function cardHighlightIdentityAttribute(key, value) {
18311return value === void 0 ? "" : `data-card-highlight-${key}="${escapeHtml(String(value))}"`;
18312}
18313function highlightCardTargetWords(root, card) {
18314const words = cardHighlightWords(root);
18315for (const word of words) {
18316if (isCardHighlightWord(word, card)) word.classList.add(CARD_HIGHLIGHT_CLASS);
18317}
18318}
18319function highlightCardTargetScopes(root) {
18320for (const scope of cardHighlightScopes(root)) {
18321const card = cardHighlightTargetFromScope(scope);
18322if (card) highlightCardTargetWords(scope, card);
18323}
18324}
18325function isCardHighlightWord(word, card) {
18326return cardHighlightIdentityMatches(word, card) || cardHighlightSurfaceMatches(word, card);
18327}
18328function cardHighlightIdentityMatches(word, card) {
18329const identity = cardHighlightIdentity(card);
18330if (!identity) return false;
18331return renderedWordPrivateValue(word, "vid") === identity.vid && renderedWordPrivateValue(word, "sid") === identity.sid;
18332}
18333function cardHighlightIdentity(card) {
18334if (card.vid === void 0 || card.sid === void 0) return null;
18335return { vid: String(card.vid), sid: String(card.sid) };
18336}
18337function cardHighlightSurfaceMatches(word, card) {
18338const surface = compactCardHighlightValue(readerWordSurfaceText(word));
18339if (!surface) return false;
18340return cardHighlightTargets(card).map(compactCardHighlightValue).filter(Boolean).some((target) => surface.includes(target));
18341}
18342function cardHighlightWords(root) {
18343const words = Array.from(root.querySelectorAll(".jpdb-reader-word"));
18344return root instanceof HTMLElement && root.matches(".jpdb-reader-word") ? [root, ...words] : words;
18345}
18346function cardHighlightScopes(root) {
18347const selector = "[data-card-highlight-spelling]";
18348const scopes = Array.from(root.querySelectorAll(selector));
18349return root instanceof HTMLElement && root.matches(selector) ? [root, ...scopes] : scopes;
18350}
18351function cardHighlightTargetFromScope(scope) {
18352const spelling = cleanCardHighlightValue(scope.dataset.cardHighlightSpelling);
18353if (!spelling) return null;
18354const reading = cleanCardHighlightValue(scope.dataset.cardHighlightReading);
18355return {
18356spelling,
18357reading,
18358vid: scope.dataset.cardHighlightVid,
18359sid: scope.dataset.cardHighlightSid
18360};
18361}
18362const KANJI_STROKE_SOURCE_ID = "__kanji_stroke__";
18363const KANJI_JPDB_SOURCE_ID = "__kanji_jpdb__";
18364const KANJI_RTK_SOURCE_ID = "__kanji_rtk__";
18365const KANJI_WANIKANI_SOURCE_ID = "__kanji_wanikani__";
18366const KANJI_DICTIONARIES_SOURCE_ID = "__kanji_dictionaries__";
18367const KANJI_SIMILAR_WORDS_SOURCE_ID = "__kanji_similar_words__";
18368const KANJI_ORIGINS_SOURCE_ID = "__kanji_origins__";
18369const KANJI_DICTIONARY_SOURCE_PREFIX = "__kanji_dictionary__:";
18370const BUILT_IN_SOURCE_NAME_KEYS = {
18371[ANKI_SOURCE_ID]: "sourceNameAnki",
18372[STUDY_TRANSLATION_SOURCE_ID]: "sourceNameTranslation",
18373[STUDY_GRAMMAR_SOURCE_ID]: "sourceNameGrammar",
18374[IMMERSION_KIT_SOURCE_ID]: "sourceNameImmersionKit",
18375[KANJI_STROKE_SOURCE_ID]: "sourceNameStrokePractice",
18376[KANJI_JPDB_SOURCE_ID]: "readingsComponents",
18377[KANJI_DICTIONARIES_SOURCE_ID]: "sourceNameImportedKanjiDictionaries",
18378[KANJI_ORIGINS_SOURCE_ID]: "originStructure"
18379};
18380function definitionSourceRows(settings2) {
18381const language = settings2.interfaceLanguage;
18382const builtInRows = [
18383{
18384id: JITEN_DEFINITION_SOURCE_ID,
18385name: "Jiten",
18386alias: settings2.jitenDefinitionsAlias,
18387enabled: settings2.jitenDefinitionsEnabled,
18388priority: settings2.jitenDefinitionsPriority,
18389prefix: "jitenDefinitions",
18390readonly: true,
18391help: uiText$1(language, "sourceHelpJiten")
18392},
18393{
18394id: JPDB_DEFINITION_SOURCE_ID,
18395name: "JPDB",
18396alias: settings2.jpdbDefinitionsAlias,
18397enabled: settings2.jpdbDefinitionsEnabled,
18398priority: settings2.jpdbDefinitionsPriority,
18399prefix: "jpdbDefinitions",
18400readonly: true,
18401help: uiText$1(language, "sourceHelpJpdb")
18402},
18403{
18404id: BUNPRO_DEFINITION_SOURCE_ID,
18405name: "Bunpro",
18406alias: settings2.bunproDefinitionsAlias,
18407enabled: settings2.bunproDefinitionsEnabled,
18408priority: settings2.bunproDefinitionsPriority,
18409prefix: "bunproDefinitions",
18410readonly: true,
18411help: uiText$1(language, "sourceHelpBunpro")
18412},
18413{
18414id: WANIKANI_DEFINITION_SOURCE_ID,
18415name: "WaniKani",
18416alias: settings2.wanikaniDefinitionsAlias,
18417enabled: settings2.wanikaniDefinitionsEnabled,
18418priority: settings2.wanikaniDefinitionsPriority,
18419prefix: "wanikaniDefinitions",
18420readonly: true,
18421help: uiText$1(language, "sourceHelpWanikani")
18422},
18423{
18424id: STUDY_TRANSLATION_SOURCE_ID,
18425name: uiText$1(language, "sourceNameTranslation"),
18426alias: settings2.studyTranslationAlias,
18427enabled: settings2.studyTranslationEnabled,
18428priority: settings2.studyTranslationPriority,
18429prefix: "studyTranslation",
18430readonly: true,
18431help: uiText$1(language, "sourceHelpTranslation")
18432},
18433{
18434id: ANKI_SOURCE_ID,
18435name: "Anki",
18436alias: settings2.ankiSectionAlias,
18437enabled: settings2.ankiSectionEnabled,
18438priority: settings2.ankiSectionPriority,
18439prefix: "ankiSection",
18440readonly: true,
18441help: uiText$1(language, "sourceHelpAnki")
18442},
18443{
18444id: STUDY_GRAMMAR_SOURCE_ID,
18445name: uiText$1(language, "sourceNameGrammar"),
18446alias: settings2.studyGrammarAlias,
18447enabled: settings2.studyGrammarEnabled,
18448priority: settings2.studyGrammarPriority,
18449prefix: "studyGrammar",
18450readonly: true,
18451help: uiText$1(language, "sourceHelpGrammar")
18452},
18453{
18454id: IMMERSION_KIT_SOURCE_ID,
18455name: uiText$1(language, "sourceNameImmersionKit"),
18456alias: settings2.immersionKitAlias,
18457enabled: settings2.immersionKitEnabled,
18458priority: settings2.immersionKitPriority,
18459prefix: "immersionKit",
18460readonly: true,
18461help: uiText$1(language, "sourceHelpImmersionKit")
18462}
18463];
18464return [
18465...builtInRows,
18466...settings2.dictionaryPreferences.filter((preference) => {
18467const type = preference.type ?? "terms";
18468return type === "terms" || type === "kanji";
18469}).map((preference) => ({
18470id: preference.name,
18471name: preference.name,
18472alias: preference.alias,
18473enabled: preference.enabled,
18474priority: preference.priority,
18475prefix: `dictionaryPreferences.${settings2.dictionaryPreferences.indexOf(preference)}`,
18476readonly: false,
18477removable: true,
18478dictionaryType: preference.type === "kanji" ? "kanji" : "terms",
18479help: ""
18480}))
18481].filter((row) => row.id !== IMMERSION_KIT_SOURCE_ID || settings2.immersionKitEnabled).sort(compareSourceRows);
18482}
18483function kanjiSourceRows(settings2) {
18484const language = settings2.interfaceLanguage;
18485const apiSource = activeKanjiFactSource(settings2);
18486const readingsComponentsName = apiSource.name === "Jiten" ? uiText$1(language, "sourceNameJitenKanjiFacts") : uiText$1(language, "readingsComponents");
18487const kanjiDictionaryRows = settings2.dictionaryPreferences.filter((preference) => preference.type === "kanji").map((preference) => ({
18488id: kanjiDictionarySourceId(preference.name),
18489name: preference.name,
18490alias: preference.alias,
18491enabled: settings2.localDictionaryShowKanji && preference.enabled,
18492priority: preference.priority,
18493prefix: `dictionaryPreferences.${settings2.dictionaryPreferences.indexOf(preference)}`,
18494readonly: false,
18495removable: true,
18496dictionaryType: "kanji",
18497help: uiText$1(language, "sourceHelpImportedKanjiDictionary")
18498}));
18499return [
18500{
18501id: KANJI_STROKE_SOURCE_ID,
18502name: uiText$1(language, "sourceNameStrokePractice"),
18503alias: settings2.kanjivgAlias,
18504enabled: settings2.kanjivgEnabled,
18505priority: settings2.kanjivgPriority,
18506prefix: "kanjivg",
18507readonly: true,
18508help: uiText$1(language, "sourceHelpStrokePractice")
18509},
18510{
18511id: KANJI_JPDB_SOURCE_ID,
18512name: readingsComponentsName,
18513alias: settings2.jpdbKanjiAlias,
18514enabled: settings2.jpdbKanjiEnabled,
18515priority: settings2.jpdbKanjiPriority,
18516prefix: "jpdbKanji",
18517readonly: true,
18518help: apiSource.name === "Jiten" ? uiText$1(language, "sourceHelpJitenKanjiFacts") : uiText$1(language, "sourceHelpReadingsComponents")
18519},
18520{
18521id: KANJI_RTK_SOURCE_ID,
18522name: "RTK",
18523alias: settings2.rtkAlias,
18524enabled: settings2.rtkEnabled,
18525priority: settings2.rtkPriority,
18526prefix: "rtk",
18527readonly: true,
18528help: uiText$1(language, "sourceHelpRtk")
18529},
18530{
18531id: IMMERSION_KIT_SOURCE_ID,
18532name: uiText$1(language, "sourceNameImmersionKit"),
18533alias: settings2.kanjiImmersionKitAlias,
18534enabled: settings2.kanjiImmersionKitEnabled,
18535priority: settings2.kanjiImmersionKitPriority,
18536prefix: "kanjiImmersionKit",
18537readonly: true,
18538help: uiText$1(language, "sourceHelpImmersionKit")
18539},
18540{
18541id: KANJI_WANIKANI_SOURCE_ID,
18542name: "WaniKani",
18543alias: settings2.wanikaniKanjiAlias,
18544enabled: settings2.wanikaniKanjiEnabled,
18545priority: settings2.wanikaniKanjiPriority,
18546prefix: "wanikaniKanji",
18547readonly: true,
18548help: uiText$1(language, "sourceHelpWanikaniKanji")
18549},
18550...kanjiDictionaryRows.length ? [] : [{
18551id: KANJI_DICTIONARIES_SOURCE_ID,
18552name: uiText$1(language, "sourceNameImportedKanjiDictionaries"),
18553alias: settings2.kanjiDictionariesAlias,
18554enabled: settings2.localDictionaryShowKanji,
18555priority: settings2.kanjiDictionariesPriority,
18556prefix: "kanjiDictionaries",
18557readonly: true,
18558help: uiText$1(language, "sourceHelpImportedKanjiDictionaries")
18559}],
18560...kanjiDictionaryRows,
18561{
18562id: KANJI_ORIGINS_SOURCE_ID,
18563name: uiText$1(language, "originStructure"),
18564alias: settings2.kanjiOriginsAlias,
18565enabled: settings2.kanjiOriginsEnabled,
18566priority: settings2.kanjiOriginsPriority,
18567prefix: "kanjiOrigins",
18568readonly: true,
18569help: uiText$1(language, "sourceHelpComponentGraph")
18570}
18571].sort(compareSourceRows);
18572}
18573function activeKanjiFactSource(settings2) {
18574return hasJitenApiCredential(settings2) ? { name: "Jiten" } : { name: "JPDB" };
18575}
18576function orderedDefinitionSourceIds(settings2, dictionaryNames) {
18577const preferences = new Map(settings2.dictionaryPreferences.map((item) => [item.name, item]));
18578const sources = [
18579{
18580id: JITEN_DEFINITION_SOURCE_ID,
18581enabled: settings2.jitenDefinitionsEnabled,
18582priority: settings2.jitenDefinitionsPriority,
18583name: "Jiten"
18584},
18585{
18586id: JPDB_DEFINITION_SOURCE_ID,
18587enabled: settings2.jpdbDefinitionsEnabled,
18588priority: settings2.jpdbDefinitionsPriority,
18589name: "JPDB"
18590},
18591{
18592id: BUNPRO_DEFINITION_SOURCE_ID,
18593enabled: settings2.bunproDefinitionsEnabled,
18594priority: settings2.bunproDefinitionsPriority,
18595name: "Bunpro"
18596},
18597{
18598id: WANIKANI_DEFINITION_SOURCE_ID,
18599enabled: settings2.wanikaniDefinitionsEnabled,
18600priority: settings2.wanikaniDefinitionsPriority,
18601name: "WaniKani"
18602},
18603{
18604id: ANKI_SOURCE_ID,
18605enabled: settings2.ankiSectionEnabled,
18606priority: settings2.ankiSectionPriority,
18607name: "Anki"
18608},
18609{
18610id: STUDY_TRANSLATION_SOURCE_ID,
18611enabled: settings2.studyTranslationEnabled,
18612priority: settings2.studyTranslationPriority,
18613name: "Translation"
18614},
18615{
18616id: STUDY_GRAMMAR_SOURCE_ID,
18617enabled: settings2.studyGrammarEnabled,
18618priority: settings2.studyGrammarPriority,
18619name: "Grammar"
18620},
18621{
18622id: IMMERSION_KIT_SOURCE_ID,
18623enabled: settings2.immersionKitEnabled,
18624priority: settings2.immersionKitPriority,
18625name: "Immersion Kit"
18626},
18627...dictionaryNames.filter((name) => (preferences.get(name)?.type ?? "terms") === "terms").map((name, index) => {
18628const preference = preferences.get(name);
18629return {
18630id: name,
18631enabled: preference?.enabled ?? true,
18632priority: preference?.priority ?? 1e3 + index,
18633name
18634};
18635})
18636];
18637return sources.filter((source) => source.enabled).sort(compareSourceOrder).map((source) => source.id);
18638}
18639function definitionSourceLabel(settings2, sourceId, fallback = "", language = settings2.interfaceLanguage) {
18640const row = definitionSourceRows(settings2).find((candidate) => candidate.id === sourceId);
18641return localizedSourceRowLabel(row, language) || fallback;
18642}
18643function orderedKanjiSourceIds(settings2) {
18644return kanjiSourceRows(settings2).filter((row) => row.enabled).filter((row) => row.id !== KANJI_SIMILAR_WORDS_SOURCE_ID).filter((row) => row.id !== IMMERSION_KIT_SOURCE_ID || settings2.immersionKitEnabled).filter((row) => row.id !== KANJI_DICTIONARIES_SOURCE_ID || !settings2.dictionaryPreferences.some((preference) => preference.type === "kanji")).map((row) => row.id);
18645}
18646function kanjiSourceLabel(settings2, sourceId, fallback = "", language = settings2.interfaceLanguage) {
18647const row = kanjiSourceRows(settings2).find((candidate) => candidate.id === sourceId);
18648return localizedSourceRowLabel(row, language) || fallback;
18649}
18650function kanjiDictionarySourceId(name) {
18651return `${KANJI_DICTIONARY_SOURCE_PREFIX}${name}`;
18652}
18653function kanjiDictionaryNameFromSourceId(sourceId) {
18654return sourceId.startsWith(KANJI_DICTIONARY_SOURCE_PREFIX) ? sourceId.slice(KANJI_DICTIONARY_SOURCE_PREFIX.length) : null;
18655}
18656function compareSourceRows(a, b) {
18657return a.priority - b.priority || a.name.localeCompare(b.name);
18658}
18659function compareSourceOrder(a, b) {
18660return a.priority - b.priority || a.name.localeCompare(b.name);
18661}
18662function localizedSourceRowLabel(row, language) {
18663if (!row) return "";
18664if (row.alias) return row.alias;
18665if (row.id === KANJI_JPDB_SOURCE_ID && row.name !== uiText$1(language, "readingsComponents")) return row.name;
18666const key = builtInSourceNameKey(row.id);
18667return key ? uiText$1(language, key) : row.name;
18668}
18669function builtInSourceNameKey(sourceId) {
18670return BUILT_IN_SOURCE_NAME_KEYS[sourceId];
18671}
18672const LOCAL_TAG_SPLIT_RE = /[\s,;|/]+/;
18673const HIDDEN_LOCAL_TERM_TAGS = new Set(["0", "1", "2", "3", "4", "5"]);
18674const LOCAL_TERM_TAG_LABELS = new Map([
18675["n", "noun"],
18676["pn", "pronoun"],
18677["r", "rare"],
18678["uk", "usually kana"],
18679["adj-i", "i-adjective"],
18680["adj-na", "na-adjective"],
18681["adv", "adverb"],
18682["exp", "expression"],
18683["int", "interjection"],
18684["prt", "particle"],
18685["suf", "suffix"],
18686["pref", "prefix"],
18687["vs", "suru verb"],
18688["vi", "intransitive"],
18689["vt", "transitive"]
18690]);
18691const LOCAL_TERM_TAG_LABELS_JA = new Map([
18692["n", "名詞"],
18693["pn", "代名詞"],
18694["r", "まれ"],
18695["uk", "かな表記が多い"],
18696["adj-i", "い形容詞"],
18697["adj-na", "な形容詞"],
18698["adv", "副詞"],
18699["exp", "表現"],
18700["int", "感動詞"],
18701["prt", "助詞"],
18702["suf", "接尾辞"],
18703["pref", "接頭辞"],
18704["vs", "する動詞"],
18705["vi", "自動詞"],
18706["vt", "他動詞"]
18707]);
18708function localTermTags(entries2, language = "en") {
18709const tags = entries2.flatMap((entry) => [entry.definitionTags, entry.termTags, entry.rules]).flatMap((value) => typeof value === "string" ? value.split(LOCAL_TAG_SPLIT_RE) : []).map((value) => value.trim()).map((tag) => localTermTagLabel(tag, language)).filter(Boolean);
18710return [...new Set(tags)].slice(0, 8);
18711}
18712function localTermTagLabel(tag, language) {
18713const normalized = tag.toLowerCase();
18714if (!normalized || HIDDEN_LOCAL_TERM_TAGS.has(normalized)) return "";
18715if (language === "ja") return LOCAL_TERM_TAG_LABELS_JA.get(normalized) ?? tag;
18716return LOCAL_TERM_TAG_LABELS.get(normalized) ?? tag;
18717}
18718function hasRichStructuredGlossary(value) {
18719if (!value || typeof value !== "object") return false;
18720if (Array.isArray(value)) return value.some(hasRichStructuredGlossary);
18721const record2 = value;
18722return isRichStructuredGlossaryRecord(record2) || hasRichStructuredGlossary(record2.content);
18723}
18724function isRichStructuredGlossaryRecord(record2) {
18725const tag = typeof record2.tag === "string" ? record2.tag.toLowerCase() : "";
18726return record2.type === "image" || "path" in record2 || tag === "img" || tag === "table";
18727}
18728function pillStyle(key) {
18729const hue = stableHue(key);
18730return `--chip-bg:hsl(${hue} 70% 36%);--chip-border:hsl(${hue} 72% 50%);--chip-text:#fff;`;
18731}
18732function lookupPillStyle(id) {
18733const token = LOOKUP_PILL_COLOR_TOKENS[id];
18734return token ? `--chip-bg:${token.bg};--chip-border:${token.border};--chip-text:${token.text};` : pillStyle(`lookup:${id}`);
18735}
18736function bestFrequencyEntries(entries2) {
18737const bestByDictionary = new Map();
18738const others = [];
18739for (const entry of entries2) {
18740if (entry.mode !== "freq") {
18741others.push(entry);
18742continue;
18743}
18744const current = bestByDictionary.get(entry.dictionary);
18745if (!current || metaFrequencyRank(entry.data) < metaFrequencyRank(current.data)) bestByDictionary.set(entry.dictionary, entry);
18746}
18747return [...bestByDictionary.values(), ...others];
18748}
18749function metaFrequencyRank(value) {
18750if (typeof value === "number") return value;
18751if (typeof value === "string") return numericFrequencyRank(value);
18752const nested = nestedMetaFrequencyValue(value);
18753return nested === void 0 ? Number.POSITIVE_INFINITY : metaFrequencyRank(nested);
18754}
18755function nestedMetaFrequencyValue(value) {
18756if (!value || typeof value !== "object") return void 0;
18757const record2 = value;
18758return record2.frequency ?? record2.value ?? record2.displayValue;
18759}
18760function numericFrequencyRank(value) {
18761return Number(value.replace(/[^\d.]/g, "")) || Number.POSITIVE_INFINITY;
18762}
18763function normalizeFrequencyChipValue(label, value) {
18764const marker2 = label.match(/[㋕㋐]$/u)?.[0];
18765return marker2 && value.endsWith(marker2) ? value.slice(0, -marker2.length) : value;
18766}
18767function stableHue(value) {
18768let hash = 0;
18769for (let index = 0; index < value.length; index++) hash = (hash << 5) - hash + value.charCodeAt(index) | 0;
18770return Math.abs(hash) % 360;
18771}
18772function formatLookupUrl(template, values) {
18773const replacements = {
18774query: values.query,
18775queryascii: asciiFoldLookupQuery(values.query),
18776word: values.word,
18777term: values.word,
18778reading: values.reading,
18779vid: values.vid,
18780sid: values.sid
18781};
18782const url = template.replace(/\{([a-z]+)\}/gi, (_, key) => encodeURIComponent(replacements[key.toLowerCase()] ?? values.query));
18783try {
18784const parsed = new URL(url);
18785return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "";
18786} catch {
18787return "";
18788}
18789}
18790function asciiFoldLookupQuery(value) {
18791return value.normalize("NFKD").replace(/\p{Mark}+/gu, "").replace(/[^\x00-\x7F]/g, "");
18792}
18793const LEARNER_GLOSSARY_SOURCE_RE = /\b(?:JMdict|JMDict|Tatoeba)\b.*$/i;
18794const LEARNER_GLOSSARY_TAG_RE = /^(?:\[[^\]]+\]\s*)?(?:(?:adj-(?:i|ix|ku|na|no|pn|t|f)|na-adj|adv(?:-to)?|aux(?:-[a-z]+)?|conj|ctr|exp|int|n(?:-[a-z]+)?|noun|pn|pref|prt|suf|suffix|vs(?:-[a-z]+)?|v[0-9a-z-]+|vi|vk|vn|vr|vs|vt|suru|transitive|intransitive|adjective|adverb|kana|usually|uk|arch|abbr|hon|hum|pol|sl|col|obs|obscure|rare|relative)\s+)+/i;
18795const LEARNER_GLOSSARY_SEPARATOR_RE = /\s*(?:;|,|\/|\||\u3001|\u30fb)\s*/;
18796function splitLearnerGlossaryText(text2) {
18797const withoutExamples = learnerGlossaryWithoutExamples(text2);
18798return withoutExamples.split(LEARNER_GLOSSARY_SEPARATOR_RE).map((item) => item.trim()).filter(Boolean);
18799}
18800function learnerGlossaryWithoutExamples(text2) {
18801const normalized = text2.replace(/\s+/g, " ").trim();
18802if (!normalized) return "";
18803return cutBeforeExampleText(normalized).replace(LEARNER_GLOSSARY_SOURCE_RE, "").trim();
18804}
18805function summarizeLearnerGlossaryTexts(texts, limit = 3) {
18806const cleaned = texts.flatMap(splitLearnerGlossaryText).map(cleanLearnerGlossaryText).filter(Boolean);
18807return Array.from(new Set(cleaned)).slice(0, limit).join(", ");
18808}
18809function cleanLearnerGlossaryText(text2) {
18810let clean = text2.replace(/^\[[^\]]+\]\s*/u, "").replace(LEARNER_GLOSSARY_TAG_RE, "").replace(/^\((?:relative|usually|kana|uk|arch|abbr|hon|hum|pol|sl|col|obs|obscure|rare)\)\s*/iu, "").replace(/\s+/g, " ").trim();
18811clean = humanizeTerseGlosses(trimLearnerMeaning(clean));
18812if (!clean || HAS_JAPANESE.test(clean) || looksLikeGrammarTag(clean)) return "";
18813return clean;
18814}
18815function cutBeforeExampleText(text2) {
18816const japaneseIndex = text2.search(HAS_JAPANESE);
18817const sentenceIndex = text2.search(/\s+[A-Z][^.;!?]*(?:[.;!?]|$)/u);
18818const indexes = [japaneseIndex, sentenceIndex].filter((index) => index >= 0);
18819const cutoff = indexes.length ? Math.min(...indexes) : -1;
18820return cutoff >= 0 ? text2.slice(0, cutoff) : text2;
18821}
18822function trimLearnerMeaning(text2, maxLength = 56) {
18823if (text2.length <= maxLength) return text2;
18824const truncated = text2.slice(0, maxLength).replace(/\s+\S*$/u, "").trim();
18825return truncated || text2.slice(0, maxLength).trim();
18826}
18827function humanizeTerseGlosses(text2) {
18828const words = text2.split(/\s+/).filter(Boolean);
18829if (words.length < 2 || words.length > 4) return text2;
18830if (words.some((word) => /^(?:a|an|and|as|for|in|of|on|or|the|to|with)$/i.test(word))) return text2;
18831if (words.every((word) => /^[a-z][a-z'-]*$/i.test(word))) return words.join(", ");
18832return text2;
18833}
18834function looksLikeGrammarTag(text2) {
18835return /^(?:adj|adv|aux|conj|ctr|exp|int|n|noun|pn|pref|prt|suf|suffix|v[0-9a-z-]+|vi|vt|vs|vk|vn|vr|suru|transitive|intransitive|adjective|adverb|kana|uk)(?:\s|$)/i.test(text2);
18836}
18837function extractFrequency(value) {
18838const rank = frequencyRank$1(value);
18839return Number.isFinite(rank) ? rank : void 0;
18840}
18841function frequencyRank$1(value) {
18842if (typeof value === "number") return value;
18843if (typeof value === "string") return rankFromFrequencyString(value);
18844const nested = nestedFrequencyValue(value);
18845return nested === void 0 ? Number.POSITIVE_INFINITY : frequencyRank$1(nested);
18846}
18847function rankFromFrequencyString(value) {
18848return Number(value.replace(/[^\d.]/g, "")) || Number.POSITIVE_INFINITY;
18849}
18850function nestedFrequencyValue(value) {
18851if (!value || typeof value !== "object") return void 0;
18852const record2 = value;
18853return record2.frequency ?? record2.value ?? record2.displayValue;
18854}
18855const YOMITAN_DATABASE_NAME = "jpdb-popup-reader-yomitan";
18856function firefoxXrayWaiver(value) {
18857if (typeof value !== "object" && typeof value !== "function" || value === null) return value;
18858try {
18859const wrapped = value.wrappedJSObject;
18860return wrapped !== void 0 && wrapped !== null ? wrapped : value;
18861} catch {
18862return value;
18863}
18864}
18865function localBytesFromArrayBuffer(value) {
18866return localBytesFromBufferSource(value);
18867}
18868function localBytesFromBufferSource(value) {
18869if (firefoxXrayWaiver(value) === value) {
18870const local = safelyOwnBufferSource(value);
18871if (local) return local;
18872}
18873return cloneForeignBufferSource(value);
18874}
18875async function localBytesFromBlob(value) {
18876if (typeof value.arrayBuffer === "function") {
18877return localBytesFromArrayBuffer(await value.arrayBuffer());
18878}
18879const buffer = await new Promise((resolve, reject) => {
18880const reader = new FileReader();
18881reader.onload = () => resolve(reader.result);
18882reader.onerror = () => reject(reader.error ?? new Error("Could not read binary data."));
18883reader.readAsArrayBuffer(value);
18884});
18885return localBytesFromArrayBuffer(buffer);
18886}
18887function safelyOwnBufferSource(value) {
18888try {
18889if (ArrayBuffer.isView(value)) {
18890const view = value;
18891const backing = view.buffer;
18892const length = view.byteLength;
18893if (view instanceof Uint8Array && backing instanceof ArrayBuffer) return view;
18894const bytes2 = new Uint8Array(length);
18895bytes2.set(view);
18896return bytes2;
18897}
18898if (value instanceof ArrayBuffer) return new Uint8Array(value);
18899const source = new Uint8Array(value);
18900const bytes = new Uint8Array(source.byteLength);
18901bytes.set(source);
18902return bytes;
18903} catch {
18904return void 0;
18905}
18906}
18907function cloneForeignBufferSource(value) {
18908const clone = globalThis.structuredClone;
18909if (typeof clone === "function") {
18910const waived = firefoxXrayWaiver(value);
18911for (const candidate of waived === value ? [value] : [value, waived]) {
18912try {
18913const copied = clone(candidate);
18914if (isArrayBufferValue(copied) || ArrayBuffer.isView(copied)) {
18915const bytes = copyClonedBufferSource(copied);
18916if (bytes) return bytes;
18917}
18918} catch {
18919}
18920}
18921}
18922throw new Error("This browser could not copy a cross-realm dictionary BufferSource. Update Firefox or import the ZIP with the extension.");
18923}
18924function isArrayBufferValue(value) {
18925try {
18926return value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]";
18927} catch {
18928return false;
18929}
18930}
18931function copyClonedBufferSource(value) {
18932try {
18933const source = ArrayBuffer.isView(value) ? value : new Uint8Array(value);
18934const bytes = new Uint8Array(source.byteLength);
18935bytes.set(source);
18936return bytes;
18937} catch {
18938return void 0;
18939}
18940}
18941Logger.scope("DictionaryArchiveCache");
18942const log$9 = Logger.scope("DictionaryReplicaPurge");
18943const PURGE_REQUEST_KEY = "yomu:dictionary-replica-purge:v1";
18944const PURGE_HONORED_KEY = "yomu:dictionary-replica-purged:v1";
18945const STATE_STORE = "managedState";
18946const FRESHNESS_KEY = "dictionary-replica-purge";
18947async function dictionaryReplicaPurgeRequest() {
18948return timestamp(await gmStorageGet(PURGE_REQUEST_KEY, 0));
18949}
18950async function honorDictionaryReplicaPurge() {
18951const requestedAt = await dictionaryReplicaPurgeRequest();
18952if (!requestedAt || typeof indexedDB === "undefined") return false;
18953let token;
18954try {
18955await ensureManagedWebStorageCurrent();
18956if (requestedAt <= honoredAt()) return false;
18957token = managedStateEpochToken(await assertManagedStateMutationAllowed());
18958} catch {
18959return false;
18960}
18961const check = await checkDictionaryDatabase(requestedAt, token);
18962if (check !== "retry") recordHonored(requestedAt);
18963if (check === "cleared") log$9.info("Removed this origin's dictionary copy after an all-sites purge");
18964return check === "cleared";
18965}
18966function timestamp(value) {
18967const parsed = Number(value);
18968return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
18969}
18970function honoredAt() {
18971try {
18972return timestamp(managedLocalStorage.getItem(PURGE_HONORED_KEY));
18973} catch {
18974return 0;
18975}
18976}
18977function recordHonored(requestedAt) {
18978try {
18979managedLocalStorage.setItem(PURGE_HONORED_KEY, String(Math.max(honoredAt(), requestedAt)));
18980} catch {
18981}
18982}
18983function readFreshness(tx, ready) {
18984const store = tx.objectStore(STATE_STORE);
18985const epoch = store.get("epoch");
18986epoch.onsuccess = () => {
18987const token = typeof epoch.result?.token === "string" ? epoch.result.token : null;
18988const request = store.get(FRESHNESS_KEY);
18989request.onsuccess = () => ready(store, request.result, token);
18990};
18991}
18992function checkDictionaryDatabase(requestedAt, expectedToken) {
18993return new Promise((resolve) => {
18994let settled = false;
18995let acquired = false;
18996let connection;
18997let transaction;
18998const finish = (check) => {
18999if (settled) return;
19000settled = true;
19001clearTimeout(timeout);
19002connection?.close();
19003resolve(check);
19004};
19005const timeout = setTimeout(() => {
19006if (acquired) return;
19007try {
19008transaction?.abort();
19009} catch {
19010}
19011finish("retry");
19012}, 1e3);
19013try {
19014let absent = false;
19015const request = indexedDB.open(YOMITAN_DATABASE_NAME);
19016request.onupgradeneeded = () => {
19017absent = true;
19018request.transaction?.abort();
19019};
19020request.onerror = () => finish(absent ? "cleared" : "retry");
19021request.onblocked = () => finish("retry");
19022request.onsuccess = () => {
19023const db = request.result;
19024if (settled) {
19025db.close();
19026return;
19027}
19028connection = db;
19029const names = Array.from(db.objectStoreNames);
19030if (!names.length) {
19031finish("cleared");
19032return;
19033}
19034try {
19035const tx = transaction = db.transaction(names, "readwrite");
19036let cleared = false;
19037tx.oncomplete = () => finish(cleared ? "cleared" : "current");
19038tx.onabort = () => finish("retry");
19039tx.onerror = () => {
19040};
19041const run = (store, record2, token = null) => {
19042if (settled) {
19043tx.abort();
19044return;
19045}
19046acquired = true;
19047clearTimeout(timeout);
19048if (token !== null && token !== expectedToken) {
19049tx.abort();
19050return;
19051}
19052const prior = record2?.token === token ? timestamp(record2?.requestedAt) : 0;
19053if (prior >= requestedAt) return;
19054try {
19055for (const name of names) if (name !== STATE_STORE) tx.objectStore(name).clear();
19056store?.put({ key: FRESHNESS_KEY, token, requestedAt, kind: "purge" });
19057cleared = true;
19058} catch {
19059tx.abort();
19060}
19061};
19062const first = tx.objectStore(names[0]).get(FRESHNESS_KEY);
19063first.onsuccess = () => {
19064if (settled) {
19065tx.abort();
19066return;
19067}
19068acquired = true;
19069clearTimeout(timeout);
19070if (names.includes(STATE_STORE)) readFreshness(tx, run);
19071else run();
19072};
19073} catch {
19074finish("retry");
19075}
19076};
19077} catch {
19078finish("retry");
19079}
19080});
19081}
19082function isAbortError$2(error) {
19083return (error instanceof Error || error instanceof DOMException) && error.
19083name === "AbortError";
19084}
19085class RetryableTimeoutError extends Error {
19086constructor(message = "Request timed out.") {
19087super(message);
19088this.name = "RetryableTimeoutError";
19089}
19090}
19091const PRIVATE_IPV4_RANGES = [
19092[0, 16777215],
19093[167772160, 184549375],
19094[1681915904, 1686110207],
19095[2130706432, 2147483647],
19096[2851995648, 2852061183],
19097[2886729728, 2887778303],
19098[3232235520, 3232301055]
19099];
19100function isPrivateOrLocalHostname(hostname) {
19101const host = stripIpv6Brackets(hostname.trim().toLowerCase());
19102if (!host) return true;
19103return isLocalhostName(host) || isPrivateIpv4(host) || isPrivateIpv6(host);
19104}
19105function stripIpv6Brackets(host) {
19106return host.replace(/^\[/u, "").replace(/\]$/u, "");
19107}
19108function isLocalhostName(host) {
19109return host === "localhost" || host.endsWith(".localhost");
19110}
19111function isPrivateIpv4(host) {
19112const value = ipv4LiteralToInt(host);
19113return value !== null && isPrivateIpv4Int(value);
19114}
19115function isPrivateIpv4Int(value) {
19116return PRIVATE_IPV4_RANGES.some(([low, high]) => value >= low && value <= high);
19117}
19118function ipv4LiteralToInt(host) {
19119const fields = host.split(".");
19120if (fields.length === 0 || fields.length > 4) return null;
19121const values = [];
19122for (const field of fields) {
19123const value = parseIpv4Field(field);
19124if (value === null) return null;
19125values.push(value);
19126}
19127const head = values.slice(0, -1);
19128if (head.some((value) => value > 255)) return null;
19129const tail = values[values.length - 1];
19130const tailBytes = 4 - head.length;
19131const tailMax = tailBytes >= 4 ? 4294967295 : 256 ** tailBytes - 1;
19132if (tail > tailMax) return null;
19133let result = 0;
19134for (const value of head) result = result * 256 + value;
19135return result * 256 ** tailBytes + tail;
19136}
19137function parseIpv4Field(field) {
19138if (!field) return null;
19139if (/^0x[0-9a-f]+$/iu.test(field)) return finiteNonNegative(parseInt(field.slice(2), 16));
19140if (/^0[0-7]+$/u.test(field)) return finiteNonNegative(parseInt(field.slice(1), 8));
19141if (/^[0-9]+$/u.test(field)) return finiteNonNegative(parseInt(field, 10));
19142return null;
19143}
19144function finiteNonNegative(value) {
19145return Number.isFinite(value) && value >= 0 ? value : null;
19146}
19147function isPrivateIpv6(host) {
19148if (!host.includes(":")) return false;
19149if (host === "::1" || host === "::") return true;
19150const mapped = host.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/u);
19151if (mapped) {
19152const value = ipv4LiteralToInt(mapped[1]);
19153if (value !== null && isPrivateIpv4Int(value)) return true;
19154}
19155return host.startsWith("fc") || host.startsWith("fd") || /^fe[89ab]/u.test(host);
19156}
19157const SENSITIVE_REQUEST_KEY_RE = /(?:api[-_]?key|authorization|bearer|token|password|secret|credential|oauth|cookie|csrf)/i;
19158const READ_METHODS = new Set(["GET", "HEAD"]);
19159const IMMERSION_KIT_API_HOSTS = new Set([
19160"apiv2express.immersionkit.com",
19161"apiv2.immersionkit.com"
19162]);
19163const KNOWN_CORS_BLOCKED_PUBLIC_AUDIO_CDN_HOSTS = new Set([
19164"d1pra95f92lrn3.cloudfront.net",
19165"d1vjc5dkcd3yh2.cloudfront.net",
19166"dk3kgylsgq3k1.cloudfront.net"
19167]);
19168const YOMU_PUBLIC_PROXY_HOSTS = new Set([
19169"yomu-jpdb-public-proxy.henry-robert-christopher-russell.workers.dev",
19170"edge.yomureader.com",
19171"proxy.yomureader.com"
19172]);
19173const YOMU_SHARED_PUBLIC_PROXY_URL = "https://edge.yomureader.com/";
19174const YOMU_SHARED_PUBLIC_PROXY_FALLBACK_URLS = [
19175YOMU_SHARED_PUBLIC_PROXY_URL,
19176"https://yomu-jpdb-public-proxy.henry-robert-christopher-russell.workers.dev/"
19177];
19178function configuredProxyFetchUrl(targetUrl, configuredProxyUrl) {
19179const proxyUrl = configuredProxyUrl.trim();
19180if (!proxyUrl) return null;
19181try {
19182const url = new URL(proxyUrl);
19183url.searchParams.set("url", targetUrl);
19184return url.href;
19185} catch {
19186return null;
19187}
19188}
19189function isProxySafeRequest(targetUrl, options) {
19190return !hasSensitiveRequestHeaders(options.headers) && !hasCredentialedRequest(options.credentials) && !isPrivateJpdbTarget(targetUrl, options) && !isPrivateNetworkTarget(targetUrl) && !hasSensitiveUrlParams(targetUrl);
19191}
19192function isSharedPublicProxySafeRequest(targetUrl, options) {
19193const target = fetchTarget(targetUrl);
19194return Boolean(target && isProxySafeRequest(targetUrl, options) && isReadMethod(options.method) && isSharedPublicProxyAllowlistedTarget(target));
19195}
19196function shouldPreferProxyFirst(targetUrl, hasDirectCandidate, proxySafe) {
19197return hasDirectCandidate && proxySafe && !isKnownDirectCorsTarget(targetUrl) && isHostedGithubPagesApp() && isCrossOriginHttpUrl(targetUrl);
19198}
19199function isKnownCorsBlockedPublicAudioCdnUrl(target) {
19200try {
19201const url = typeof target === "string" ? typeof location === "undefined" ? new URL(target) : new URL(target, location.href) : target;
19202return KNOWN_CORS_BLOCKED_PUBLIC_AUDIO_CDN_HOSTS.has(url.hostname) && url.pathname.startsWith("/audio/");
19203} catch {
19204return false;
19205}
19206}
19207function shouldSkipDirectCrossOriginFetch(targetUrl, options) {
19208const target = fetchTarget(targetUrl);
19209const method = requestMethod(options);
19210return Boolean(target && isCrossOriginHttpTarget(target) && (isKnownCorsBlockedConfiguredProxyTarget(target, method) || isJpdbPublicLookupTarget(target, method) || isLocalHostedBrowserCorsTarget(target, method)));
19211}
19212function builtInProxyUrls(targetUrl, options) {
19213if (!isSharedPublicProxySafeRequest(targetUrl, options)) return [];
19214return YOMU_SHARED_PUBLIC_PROXY_FALLBACK_URLS.map((proxyUrl) => configuredProxyFetchUrl(targetUrl, proxyUrl)).filter((url) => Boolean(url));
19215}
19216function isJpdbPublicAudioUrl(targetUrl) {
19217try {
19218const target = new URL(targetUrl, location.href);
19219return target.hostname === "jpdb.io" && target.pathname.startsWith("/static/v/") || isKnownCorsBlockedPublicAudioCdnUrl(target);
19220} catch {
19221return false;
19222}
19223}
19224function isYomuPublicProxyUrl(candidateUrl) {
19225try {
19226const url = new URL(candidateUrl);
19227return YOMU_PUBLIC_PROXY_HOSTS.has(url.hostname);
19228} catch {
19229return false;
19230}
19231}
19232function isKnownDirectCorsTarget(targetUrl) {
19233try {
19234const target = new URL(targetUrl, location.href);
19235return IMMERSION_KIT_API_HOSTS.has(target.hostname) || target.hostname === "api.nadeshiko.co" || target.hostname === "raw.githubusercontent.com";
19236} catch {
19237return false;
19238}
19239}
19240function isKnownCorsBlockedConfiguredProxyTarget(target, method) {
19241return method === "GET" && (isJpdbPublicAudioUrl(target.href) || target.hostname === "jisho.org" && target.pathname.startsWith("/search/") || target.hostname === "assets.languagepod101.com" && target.pathname === "/dictionary/japanese/audiomp3.php" || target.hostname === "cdn.innovativelanguage.com" && target.pathname.includes("/learningcenter/audio/") || target.hostname === "api.jiten.moe" && (target.pathname.startsWith("/api/tts/word/") || target.pathname.startsWith("/api/tts/sentence/") || target.pathname === "/api/vocabulary/search" || target.pathname === "/api/vocabulary/parse" || /^\/api\/vocabulary\/\d+\/\d+\/info$/u.test(target.pathname)));
19242}
19243function isSharedPublicProxyAllowlistedTarget(target) {
19244const host = target.hostname.toLowerCase();
19245const path = target.pathname;
19246if (target.protocol !== "https:") return false;
19247if (host === "api.jiten.moe") {
19248return path.startsWith("/api/tts/word/") || path.startsWith("/api/tts/sentence/") || path === "/api/vocabulary/search" || path === "/api/vocabulary/parse" || path === "/api/vocabulary/parse-normalised" || /^\/api\/vocabulary\/\d+\/\d+\/info$/u.test(path) || path.startsWith("/api/kanji/");
19249}
19250if (host === "jpdb.io") {
19251return path === "/search" || path.startsWith("/vocabulary/") || path.startsWith("/kanji/") || path.startsWith("/static/v/");
19252}
19253if (host === "jisho.org") return path.startsWith("/search/");
19254if (host === "assets.languagepod101.com") return path === "/dictionary/japanese/audiomp3.php";
19255if (host === "cdn.innovativelanguage.com") return path.includes("/learningcenter/audio/");
19256if (KNOWN_CORS_BLOCKED_PUBLIC_AUDIO_CDN_HOSTS.has(host)) return path.startsWith("/audio/");
19257return IMMERSION_KIT_API_HOSTS.has(host) && path === "/search";
19258}
19259function isJpdbPublicLookupTarget(target, method) {
19260return method === "GET" && target.hostname === "jpdb.io" && (target.pathname === "/search" || target.pathname.startsWith("/vocabulary/"));
19261}
19262function isLocalHostedBrowserCorsTarget(target, method) {
19263return method === "GET" && isLocalHostedApp() && IMMERSION_KIT_API_HOSTS.has(target.hostname) && target.pathname === "/search";
19264}
19265function isHostedGithubPagesApp() {
19266if (typeof location === "undefined") return false;
19267try {
19268const current = new URL(location.href);
19269const path = current.pathname.replace(/\/index\.html$/, "/");
19270return current.origin === DOCS_ORIGIN || current.origin === GITHUB_PAGES_ORIGIN && path.startsWith(`/${APP_REPOSITORY_NAME}/`);
19271} catch {
19272return false;
19273}
19274}
19275function isLocalHostedApp() {
19276if (typeof location === "undefined") return false;
19277return ["127.0.0.1", "localhost", "::1"].includes(location.hostname);
19278}
19279function isCrossOriginHttpUrl(targetUrl) {
19280const target = fetchTarget(targetUrl);
19281return Boolean(target && isCrossOriginHttpTarget(target));
19282}
19283function isCrossOriginHttpTarget(target) {
19284return typeof location !== "undefined" && /^https?:$/i.test(target.protocol) && target.origin !== location.origin;
19285}
19286function fetchTarget(targetUrl) {
19287try {
19288return typeof location === "undefined" ? new URL(targetUrl) : new URL(targetUrl, location.href);
19289} catch {
19290return null;
19291}
19292}
19293function requestMethod(options) {
19294return String(options.method ?? "GET").toUpperCase();
19295}
19296function hasSensitiveRequestHeaders(headers) {
19297if (!headers) return false;
19298if (headers instanceof Headers) {
19299return Array.from(headers.keys()).some((header) => SENSITIVE_REQUEST_KEY_RE.test(header));
19300}
19301if (Array.isArray(headers)) return headers.some(([header]) => SENSITIVE_REQUEST_KEY_RE.test(header));
19302return Object.keys(headers).some((header) => SENSITIVE_REQUEST_KEY_RE.test(header));
19303}
19304function hasCredentialedRequest(credentials) {
19305return credentials === "include";
19306}
19307function isPrivateJpdbTarget(targetUrl, options) {
19308try {
19309const url = new URL(targetUrl, location.href);
19310if (url.hostname !== "jpdb.io") return false;
19311if (!isReadMethod(options.method)) return true;
19312return url.pathname.startsWith("/api/") || /^\/(?:prioritize|review|settings|login)(?:\/|$)/.test(url.pathname);
19313} catch {
19314return false;
19315}
19316}
19317function isPrivateNetworkTarget(targetUrl) {
19318try {
19319const url = new URL(targetUrl, location.href);
19320return isPrivateOrLocalHostname(url.hostname);
19321} catch {
19322return false;
19323}
19324}
19325function hasSensitiveUrlParams(targetUrl) {
19326try {
19327const url = new URL(targetUrl, location.href);
19328return Array.from(url.searchParams.keys()).some((key) => SENSITIVE_REQUEST_KEY_RE.test(key));
19329} catch {
19330return false;
19331}
19332}
19333function isReadMethod(method) {
19334return READ_METHODS.has(String(method ?? "GET").toUpperCase());
19335}
19336const NO_PROXY_TRANSPORT_MESSAGE = "No configured proxy.";
19337function isMissingProxyTransportError(error) {
19338return error instanceof Error && error.message === NO_PROXY_TRANSPORT_MESSAGE;
19339}
19340async function fetchWithCorsFallbacks(targetUrl, configuredProxyUrl = "", options = {}) {
19341const candidates = fetchUrlCandidates(targetUrl, configuredProxyUrl, options);
19342if (!candidates.length) throw new Error(NO_PROXY_TRANSPORT_MESSAGE);
19343let lastError;
19344for (const [index, candidate] of candidates.entries()) {
19345if (options.signal?.aborted) throw abortReasonFor(options.signal);
19346try {
19347const attempt2 = fetchAttemptForCandidate(targetUrl, candidate, options);
19348const response = await fetchWithTimeout$1(attempt2.url, attempt2.options);
19349if (shouldTryNextFetchCandidate(response, candidate, index, candidates)) {
19350lastError = new Error(`Proxy request failed (${response.status}).`);
19351continue;
19352}
19353return response;
19354} catch (error) {
19355if (options.signal?.aborted) throw abortReasonFor(options.signal);
19356lastError = error;
19357}
19358}
19359throw lastError instanceof Error ? lastError : new Error("Cross-origin request failed.");
19360}
19361function abortReasonFor(signal) {
19362return signal.reason ?? new DOMException("Aborted", "AbortError");
19363}
19364function fetchAttemptForCandidate(targetUrl, candidate, options) {
19365if (candidate.kind === "direct" || !isJpdbPublicAudioUrl(targetUrl) || !isYomuPublicProxyUrl(candidate.url)) {
19366return { url: candidate.url, options };
19367}
19368return {
19369url: proxyControlUrl(candidate.url, options.headers),
19370options: {
19371...options,
19372headers: stripProxyOnlyHeaders(options.headers, ["x-access", "x-forcecaf"])
19373}
19374};
19375}
19376function proxyControlUrl(candidateUrl, headers) {
19377const forceCaf = headerValue(headers, "x-forcecaf");
19378if (!forceCaf) return candidateUrl;
19379try {
19380const url = new URL(candidateUrl);
19381url.searchParams.set("x-forcecaf", forceCaf);
19382return url.href;
19383} catch {
19384return candidateUrl;
19385}
19386}
19387function stripProxyOnlyHeaders(headers, names) {
19388if (!headers) return headers;
19389const excluded = new Set(names.map((name) => name.toLowerCase()));
19390const sanitized = {};
19391new Headers(headers).forEach((value, key) => {
19392if (!excluded.has(key.toLowerCase())) sanitized[key] = value;
19393});
19394return Object.keys(sanitized).length ? sanitized : void 0;
19395}
19396function headerValue(headers, name) {
19397if (!headers) return "";
19398return new Headers(headers).get(name) ?? "";
19399}
19400function fetchUrlCandidates(targetUrl, configuredProxyUrl, options) {
19401const proxySafe = isProxySafeRequest(targetUrl, options);
19402const configuredProxySafe = proxySafe || options.allowSensitiveConfiguredProxy === true;
19403const configuredUrl = configuredProxyFetchUrl(targetUrl, configuredProxyUrl);
19404const configuredUrlIsSharedPublicProxy = configuredUrl ? isYomuPublicProxyUrl(configuredUrl) : false;
19405const configured = configuredProxySafe && options.allowConfiguredProxy !== false && !configuredUrlIsSharedPublicProxy ? configuredUrl : null;
19406const publicProxySafe = proxySafe && options.allowPublicProxies !== false;
19407const configuredPublicProxy = publicProxySafe && configuredUrlIsSharedPublicProxy ? c
19407onfiguredUrl : null;
19408const publicProxies = publicProxySafe ? [
19409configuredPublicProxy,
19410...builtInProxyUrls(targetUrl, options)
19411].filter((url) => Boolean(url)) : [];
19412const proxyCandidates = [
19413configured ? { url: configured, kind: "configured-proxy" } : null,
19414...publicProxies.map((url) => ({ url, kind: "public-proxy" }))
19415].filter((candidate) => Boolean(candidate));
19416const direct = directFetchUrl(targetUrl, options, proxyCandidates.length > 0);
19417const directCandidate = direct ? { url: direct, kind: "direct" } : null;
19418const orderedCandidates = shouldPreferProxyFirst(targetUrl, Boolean(directCandidate), proxySafe) ? [...proxyCandidates, directCandidate] : [directCandidate, ...proxyCandidates];
19419return uniqueFetchCandidates([
19420...orderedCandidates
19421]);
19422}
19423function directFetchUrl(targetUrl, options, hasProxyCandidate) {
19424if (!options.allowDirectCrossOrigin) return browserReadableUrl(targetUrl);
19425if (hasProxyCandidate && shouldSkipDirectCrossOriginFetch(targetUrl, options)) return browserReadableUrl(targetUrl);
19426return targetUrl;
19427}
19428function uniqueFetchCandidates(candidates) {
19429const seen = new Set();
19430return candidates.filter((candidate) => {
19431if (!candidate || seen.has(candidate.url)) return false;
19432seen.add(candidate.url);
19433return true;
19434});
19435}
19436function shouldTryNextFetchCandidate(response, _candidate, index, candidates) {
19437return !response.ok && response.status !== 429 && index < candidates.length - 1;
19438}
19439function browserReadableUrl(url) {
19440if (!isHttpUrl(url)) return url;
19441try {
19442const target = new URL(url, location.href);
19443return target.origin === location.origin ? target.href : null;
19444} catch {
19445return null;
19446}
19447}
19448function isHttpUrl(url) {
19449return /^https?:\/\//i.test(url);
19450}
19451async function fetchWithTimeout$1(url, options) {
19452const {
19453timeoutMs,
19454timeoutLabel,
19455allowPublicProxies: _allowPublicProxies,
19456allowConfiguredProxy: _allowConfiguredProxy,
19457allowSensitiveConfiguredProxy: _allowSensitiveConfiguredProxy,
19458allowDirectCrossOrigin: _allowDirectCrossOrigin,
19459signal,
19460...init
19461} = options;
19462if (!timeoutMs) return fetch(url, { ...init, signal });
19463const scope = fetchTimeoutScope(signal, timeoutMs, timeoutLabel);
19464try {
19465return await fetchWithinAbortScope(url, init, scope.signal);
19466} finally {
19467scope.dispose();
19468}
19469}
19470function fetchTimeoutScope(signal, timeoutMs, timeoutLabel) {
19471const controller = new AbortController();
19472const timeout = globalThis.setTimeout(() => {
19473controller.abort(new RetryableTimeoutError(timeoutLabel));
19474}, timeoutMs);
19475const abort = () => controller.abort(signal ? abortReasonFor(signal) : void 0);
19476if (signal?.aborted) abort();
19477else signal?.addEventListener("abort", abort, { once: true });
19478return {
19479signal: controller.signal,
19480dispose: () => {
19481globalThis.clearTimeout(timeout);
19482signal?.removeEventListener("abort", abort);
19483}
19484};
19485}
19486async function fetchWithinAbortScope(url, init, signal) {
19487try {
19488const response = await fetch(url, { ...init, signal });
19489throwIfFetchAborted(signal);
19490return response;
19491} catch (error) {
19492throwIfFetchAborted(signal, error);
19493throw error;
19494}
19495}
19496function throwIfFetchAborted(signal, fallback) {
19497if (signal.aborted) throw signal.reason ?? fallback;
19498}
19499Logger.scope("Yomitan");
19500const GLOSSARY_DISPLAY_TEXT_KEYS = new Set(["text", "content", "description", "alt", "title"]);
19501function glossaryValueToText(value) {
19502return glossaryValueToProfileText(value, {
19503includeDirectDataAttributes: true,
19504fallbackTextKeys: GLOSSARY_DISPLAY_TEXT_KEYS
19505});
19506}
19507function glossaryValueToProfileText(value, options) {
19508const primitiveText = primitiveGlossaryText(value);
19509if (primitiveText !== void 0) return primitiveText;
19510if (Array.isArray(value)) {
19511return value.map((child) => glossaryValueToProfileText(child, options)).filter(Boolean).join(" ");
19512}
19513return isRecord$2(value) ? glossaryRecordToText(value, options) : "";
19514}
19515function primitiveGlossaryText(value) {
19516if (value == null) return "";
19517if (typeof value === "string") return value;
19518if (typeof value === "number" || typeof value === "boolean") return String(value);
19519return void 0;
19520}
19521function glossaryRecordToText(record2, options) {
19522if (typeof record2.text === "string") return record2.text;
19523if ("content" in record2) return glossaryValueToProfileText(record2.content, options);
19524const values = glossaryRecordTextValues(record2, options);
19525if (values.length) return values.join(" ");
19526if ("path" in record2) return glossaryPathRecordText(record2);
19527return "";
19528}
19529function glossaryPathRecordText(record2) {
19530return String(record2.description || record2.alt || "");
19531}
19532function glossaryRecordTextValues(record2, options) {
19533const values = [];
19534for (const [key, childValue] of Object.entries(record2)) {
19535if (!shouldReadRecordTextKey(key, options)) continue;
19536const childText = glossaryValueToProfileText(childValue, options);
19537if (childText) values.push(childText);
19538}
19539return values;
19540}
19541function shouldReadRecordTextKey(key, options) {
19542return options.fallbackTextKeys.has(key) || options.includeDirectDataAttributes && key.startsWith("data-");
19543}
19544function renderStructuredGlossaryHtml(value, dictionary = "", options = {}) {
19545const render = yomuLocalDictionaries()?.renderStructuredGlossaryHtml;
19546return render ? render(value, dictionary, options) : escapeHtml(glossaryValueToText(value));
19547}
19548function glossaryToText(value) {
19549return glossaryValueToText(value);
19550}
19551function glossaryToHtml(value, dictionary = "", options = {}) {
19552const html = renderStructuredGlossaryHtml(value, dictionary, options);
19553return html;
19554}
19555new TextDecoder();
19556Logger.scope("Yomitan");
19557function formatMetaFrequency(value) {
19558const display = metaFrequencyDisplayValue(value);
19559if (display == null) return "";
19560return `#${display}`;
19561}
19562function metaFrequencyDisplayValue(value) {
19563const primitive = primitiveMetaValue(value);
19564if (primitive !== null) return primitive;
19565const record2 = objectRecord$2(value);
19566return record2 ? scalarMetaValue(nestedMetaValue(record2)) : null;
19567}
19568function scalarMetaValue(value) {
19569const primitive = primitiveMetaValue(value);
19570if (primitive !== null) return primitive;
19571const record2 = objectRecord$2(value);
19572return record2 ? scalarMetaValue(nestedMetaValue(record2)) : null;
19573}
19574function primitiveMetaValue(value) {
19575return typeof value === "number" || typeof value === "string" ? String(value) : null;
19576}
19577function objectRecord$2(value) {
19578return value && typeof value === "object" ? value : null;
19579}
19580function nestedMetaValue(record2) {
19581return record2.displayValue ?? record2.frequency ?? record2.value;
19582}
19583function groupTermEntriesByDictionary(entries2) {
19584const grouped = new Map();
19585for (const entry of entries2) {
19586const group = grouped.get(entry.dictionary) ?? [];
19587group.push(entry);
19588grouped.set(entry.dictionary, group);
19589}
19590return grouped;
19591}
19592function groupTermEntriesByHeadword(entries2) {
19593const grouped = new Map();
19594const meaningKeys = new Map();
19595for (const entry of entries2) {
19596const key = termHeadwordKey(entry);
19597const group = grouped.get(key) ?? createLearnerTermGroup(entry);
19598group.entries.push(entry);
19599updateLearnerTermFrequency(group, entry);
19600addLearnerTermMeaning(group, entry, key, meaningKeys);
19601grouped.set(key, group);
19602}
19603return [...grouped.values()];
19604}
19605function termHeadwordKey(entry) {
19606return `${entry.expression || entry.reading}
19607${entry.reading || ""}`;
19608}
19609function createLearnerTermGroup(entry) {
19610return { expression: entry.expression || entry.reading, reading: entry.reading || "", entries: [], meanings: [] };
19611}
19612function updateLearnerTermFrequency(group, entry) {
19613if (entry.jpdbFrequency !== void 0 && (group.frequency === void 0 || entry.jpdbFrequency < group.frequency)) {
19614group.frequency = entry.jpdbFrequency;
19615}
19616}
19617function addLearnerTermMeaning(group, entry, key, meaningKeys) {
19618const meaning = summarizeLearnerGlossary(entry);
19619if (!meaning) return;
19620const seen = meaningKeys.get(key) ?? new Set();
19621const meaningKey = meaning.toLocaleLowerCase();
19622if (!seen.has(meaningKey)) {
19623seen.add(meaningKey);
19624group.meanings.push(meaning);
19625}
19626meaningKeys.set(key, seen);
19627}
19628function summarizeLearnerGlossary(entry) {
19629return summarizeLearnerGlossaryTexts(entry.glossary.map((item) => glossaryToText(item)));
19630}
19631const renderJpdbDefinitionSource = (...args) => yomuJpdbCompanion()?.renderJpdbDefinitionSource?.(...args) ?? "";
19632function renderLocalDefinitionSourcesSection(dictionaries, grouped, settings2, sourceAttributes, dictionaryLabel, reference) {
19633const groupsByDictionary = dictionaries.map((dictionary) => ({ dictionary, groups: groupTermEntriesByHeadword(grouped.get(dictionary) ?? []) })).filter((source) => source.groups.length);
19634const dictionarySections = groupsByDictionary.map((source) => renderLocalDictionaryGroup(source.dictionary, source.groups, sourceAttributes, dictionaryLabel, settings2.interfaceLanguage, reference)).filter(Boolean);
19635return dictionarySections.join("");
19636}
19637function renderKanjiDefinitions(entries2, sourceAttributes, dictionaryLabel, sourceId = KANJI_DICTIONARIES_SOURCE_ID, title = void 0, language = "en") {
19638if (!entries2.length) return "";
19639const heading = title ?? uiText$1(language, "kanjiDictionaries");
19640return `
19641        <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-kanji" data-source="local-kanji-dictionaries" ${sourceAttributes(kanjiSourceStateKey(sourceId)
19641)}>
19642            <summary class="jpdb-reader-local-title" data-jpdb-reader-surface-ignore>${escapeHtml(heading)}</summary>
19643            ${entries2.map((entry) => `
19644<div class="jpdb-reader-local-entry">
19645<div class="jpdb-reader-local-head">
19646<span class="jpdb-reader-kanji-char">${escapeHtml(entry.character)}</span>
19647<span class="jpdb-reader-local-dict">${escapeHtml(dictionaryLabel(entry.dictionary))}</span>
19648</div>
19649<div class="jpdb-reader-kanji-readings">
19650${entry.onyomi.length ? `<span>${escapeHtml(uiText$1(language, "onReading"))} ${escapeHtml(entry.onyomi.join("、"))}</span>` : ""}
19651${entry.kunyomi.length ? `<span>${escapeHtml(uiText$1(language, "kunReading"))} ${escapeHtml(entry.kunyomi.join("、"))}</span>` : ""}
19652</div>
19653<div
19654class="jpdb-reader-local-glossary jpdb-reader-parseable"
19655data-dictionary="${escapeHtml(entry.dictionary)}"
19656data-definition-translation-text
19657data-definition-translation-source-id="${escapeHtml(entry.dictionary)}"
19658data-definition-translation-payload="${escapeHtml(entry.meanings.slice(0, 6).join("\n"))}"
19659>
19660${entry.meanings.slice(0, 6).map((meaning) => `<div>${escapeHtml(meaning)}</div>`).join("")}
19661</div>
19662</div>
19663`).join("")}
19664        </details>
19665    `;
19666}
19667function definitionSourceStateKey(sourceId) {
19668return `definition-source:${sourceId}`;
19669}
19670function localDictionaryStateKey(dictionary) {
19671return `definition-dictionary:${dictionary}`;
19672}
19673function kanjiSourceStateKey(sourceId) {
19674return `kanji:${sourceId}`;
19675}
19676function kanjiFactProviderTitle(source) {
19677return source === "jiten" ? "Jiten" : "JPDB";
19678}
19679function renderLocalDictionaryGroup(dictionary, groups, sourceAttributes, dictionaryLabel, language, reference) {
19680const entryCount = groups.length;
19681return `
19682        <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-dictionary-group" data-source="local-dictionary" data-dictionary="${escapeHtml(dictionary)}" ${cardHighlightScopeAttributes(reference)} ${sourceAttributes(localDictionaryStateKey(dictionary))}>
19683            <summary class="jpdb-reader-local-title jpdb-reader-dictionary-source-title" title="${escapeHtml(dictionaryLabel(dictionary))}" data-jpdb-reader-surface-ignore>
19684                <span>${escapeHtml(dictionaryLabel(dictionary))}</span>
19685                <span class="jpdb-reader-source-status">${entryCount} ${escapeHtml(uiText$1(language, entryCount === 1 ? "localWordSingular" : "localWordPlural"))}</span>
19686            </summary>
19687            <div class="jpdb-reader-local-terms">
19688                ${groups.map((group) => renderLocalTermGroup(dictionary, group, dictionaryLabel, language, reference, { showDictionaryTag: false })).join("")}
19689            </div>
19690        </details>
19691    `;
19692}
19693function renderLocalTermGroup(dictionary, group, dictionaryLabel, language, reference, options = {}) {
19694return `
19695        <article class="jpdb-reader-local-entry jpdb-reader-local-term">
19696            ${renderLocalTermHead(group, reference)}
19697            ${renderLocalTermTags(dictionary, group, dictionaryLabel, options.showDictionaryTag ?? true, language)}
19698            ${renderLocalTermMeaning(dictionary, group)}
19699        </article>
19700    `;
19701}
19702function renderLocalTermHead(group, reference) {
19703if (repeatsLookupHeadword(group, reference)) return "";
19704return `<div class="jpdb-reader-local-head">
19705        <span class="jpdb-reader-local-expression">${escapeHtml(group.expression)}</span>
19706        ${renderLocalTermReading(group)}
19707        ${renderLocalTermFrequency(group)}
19708    </div>`;
19709}
19710function repeatsLookupHeadword(group, reference) {
19711if (!matchesLookupExpression(group, reference)) return false;
19712return matchesLookupReading(group, reference);
19713}
19714function matchesLookupExpression(group, reference) {
19715if (!reference) return false;
19716return group.expression === reference.spelling;
19717}
19718function matchesLookupReading(group, reference) {
19719if (!reference.reading) return true;
19720if (group.reading === reference.reading) return true;
19721return group.reading === group.expression;
19722}
19723function renderLocalTermReading(group) {
19724return group.reading && group.reading !== group.expression ? `<span class="jpdb-reader-local-reading">${escapeHtml(group.reading)}</span>` : "";
19725}
19726function renderLocalTermTags(dictionary, group, dictionaryLabel, showDictionaryTag, language) {
19727const tagItems = [
19728showDictionaryTag ? `<span class="jpdb-reader-dict-tag jpdb-reader-source-tag">${escapeHtml(dictionaryLabel(dictionary))}</span>` : "",
19729...localTermTags(group.entries, language).map((tag) => `<span class="jpdb-reader-dict-tag" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`)
19730].filter(Boolean);
19731return tagItems.length ? `<div class="jpdb-reader-local-tags">${tagItems.join("")}</div>` : "";
19732}
19733function renderLocalTermMeaning(dictionary, group) {
19734if (group.entries.some(hasAdditionalLocalDictionaryText)) return renderLocalGlossaryEntries(dictionary, group.entries, { showIndex: false });
19735if (!group.meanings.length) return renderLocalGlossaryEntries(dictionary, group.entries);
19736return `<div class="jpdb-reader-local-senses" data-definition-translation-text>
19737        ${group.meanings.slice(0, 8).map((meaning, index) => `
19738<div class="jpdb-reader-local-sense">
19739${group.meanings.length > 1 ? `<span class="jpdb-reader-local-sense-index">${index + 1}</span>` : ""}
19740<span>${escapeHtml(meaning)}</span>
19741</div>
19742`).join("")}
19743    </div>`;
19744}
19745function renderLocalTermFrequency(group) {
19746return group.frequency !== void 0 ? `<span class="jpdb-reader-local-frequency">#${escapeHtml(String(group.frequency))}</span>` : "";
19747}
19748function renderLocalGlossaryEntries(dictionary, entries2, options = {}) {
19749const showIndex = options.showIndex ?? entries2.length > 1;
19750const entryHtml = entries2.map((entry, index) => {
19751const content = localGlossaryItemsForRender(entry.glossary).map((item) => glossaryToHtml(item, entry.dictionary, { internalSearchLinks: true })).filter((html) => html.replace(/<[^>]+>/g, "").trim() || /<(?:img|table|ruby|a|ul|ol|li)\b/i.test(html)).map((html) => `<div>${html}</div>`).join("");
19752if (!content) return "";
19753return `
19754            <div class="jpdb-reader-local-glossary-entry ${showIndex ? "" : "no-index"}">
19755                ${showIndex ? `<span class="jpdb-reader-local-sense-index">${index + 1}</span>` : ""}
19756                <div>${content}</div>
19757            </div>
19758        `;
19759}).filter(Boolean).join("");
19760if (!entryHtml) return "";
19761return `
19762        <div class="jpdb-reader-local-glossary jpdb-reader-parseable" data-dictionary="${escapeHtml(dictionary)}" data-definition-translation-text>
19763            ${entryHtml}
19764        </div>
19765    `;
19766}
19767function localGlossaryItemsForRender(glossary) {
19768const items = new Set();
19769glossary.slice(0, 4).forEach((item) => items.add(item));
19770glossary.filter((item) => hasRichStructuredGlossary(item) || HAS_JAPANESE.test(glossaryToText(item))).forEach((item) => items.add(item));
19771return Array.from(items);
19772}
19773function hasAdditionalLocalDictionaryText(entry) {
19774const summary = summarizeLearnerGlossary(entry);
19775return entry.glossary.some((item) => {
19776if (hasRichStructuredGlossary(item)) return true;
19777const text2 = glossaryToText(item).replace(/\s+/g, " ").trim();
19778if (!text2 || text2 === summary) return false;
19779return HAS_JAPANESE.test(text2);
19780});
19781}
19782function renderFrequencyPill(entry, dictionaryLabel) {
19783const label = dictionaryLabel(entry.dictionary);
19784const value = normalizeFrequencyChipValue(label, formatMetaFrequency(entry.data));
19785return value ? `<span class="jpdb-reader-pill jpdb-reader-frequency-pill" data-dictionary="${escapeHtml(entry.dictionary)}" data-frequency-source="local" style="${pillStyle(`frequency:${entry.dictionary}`)}" title="${escapeHtml(`${label} local frequency`)}">${escapeHtml(label)} ${escapeHtml(value)}</span>` : "";
19786}
19787async function requestHttp(url, options = {}) {
19788let userscriptRequest = getUserscriptHttpRequest();
19789if (userscriptRequest && isUserscriptEventBridgeRequest(userscriptRequest)) {
19790const bridgeIsAlive = await probeUserscriptEventBridge(userscriptRequest);
19791if (!bridgeIsAlive) userscriptRequest = void 0;
19792}
19793if (options.preferFetch && (!userscriptRequest || isSameOriginUrl(url) || window.__YOMU_READER_RUNTIME__ === "newtab" && options.responseType === "blob")) {
19794try {
19795return await requestViaFetch(url, options, userscriptRequest ?? null);
19796} catch (error) {
19797if (!userscriptRequest) throw error;
19798return await requestViaUserscript(url, options, userscriptRequest);
19799}
19800}
19801if (userscriptRequest) {
19802try {
19803return await requestViaUserscript(url, options, userscriptRequest);
19804} catch (error) {
19805if (!shouldRetryWithFetch(error) && !shouldRetryEventBridgeFailureWithFetch(userscriptRequest, error)) throw error;
19806userscriptRequest = void 0;
19807}
19808}
19809return requestViaFetch(url, browserFetchFallbackOptions(url, options, userscriptRequest), userscriptRequest ?? null);
19810}
19811function requestViaUserscript(url, options, userscriptRequest) {
19812return requestViaUserscriptManager(userscriptRequest, {
19813details: {
19814method: options.method ?? "GET",
19815url,
19816headers: recordHeaders(options.headers),
19817data: options.data,
19818responseType: options.responseType,
19819timeout: options.timeoutMs,
19820anonymous: options.anonymous,
19821withCredentials: options.withCredentials,
19822cookie: options.cookie
19823},
19824deadlineMs: options.timeoutMs,
19825signal: options.signal ?? void 0,
19826readResponse: (response) => {
19827if (response.status < 200 || response.status >= 300) throw new Error(formatStatusFailure(options, response.status));
19828return normalizeUserscriptResponse(response, options.responseType ?? "text");
19829},
19830onError: (error) => error instanceof Error ? error : new Error(formatFailure(options)),
19831onTimeout: () => new Error(options.timeoutLabel ?? `${options.failureLabel ?? "Request"} timed out.`)
19832});
19833}
19834function normalizeUserscriptResponse(response, responseType) {
19835return USERSCRIPT_RESPONSE_NORMALIZERS[responseType]?.(response) ?? userscriptTextResponse(response);
19836}
19837const USERSCRIPT_RESPONSE_NORMALIZERS = {
19838blob: (response) => response.response,
19839arraybuffer: (response) => response.response,
19840json: userscriptJsonResponse,
19841text: userscriptTextResponse
19842};
19843function userscriptJsonResponse(response) {
19844return response.response !== void 0 && typeof response.response !== "string" ? response.response : JSON.parse(String(response.responseText ?? response.response ?? "null"));
19845}
19846function userscriptTextResponse(response) {
19847return String(response.responseText ?? response.response ?? "");
19848}
19849function hostedFallbackProxyUrl(url, options = {}, userscriptRequest = getUserscriptHttpRequest() ?? null) {
19850if (userscriptRequest) return "";
19851if (!isSharedPublicProxySafeRequest(url, options)) return "";
19852return YOMU_SHARED_PUBLIC_PROXY_URL;
19853}
19854async function requestViaFetch(url, options, userscriptRequest = getUserscriptHttpRequest() ?? null) {
19855const response = await fetchWithCorsFallbacks(url, (options.proxyUrl ?? "").trim() || hostedFallbackProxyUrl(url, options, userscriptRequest), {
19856method: options.method ?? "GET",
19857headers: options.headers,
19858body: options.data,
19859credentials: options.credentials ?? "omit",
19860redirect: options.redirect ?? "follow",
19861referrerPolicy: options.referrerPolicy ?? "no-referrer",
19862timeoutMs: options.timeoutMs,
19863timeoutLabel: options.timeoutLabel,
19864allowConfiguredProxy: options.allowConfiguredProxy,
19865allowSensitiveConfiguredProxy: options.allowSensitiveConfiguredProxy,
19866allowPublicProxies: options.allowPublicProxies,
19867allowDirectCrossOrigin: options.allowDirectCrossOrigin,
19868signal: options.signal
19869});
19870if (!response.ok) throw new Error(formatStatusFailure(options, response.status));
19871return readFetchResponseBody(response, options.responseType);
19872}
19873function browserFetchFallbackOptions(url, options, userscriptRequest) {
19874if (userscriptRequest || options.allowDirectCrossOrigin !== void 0) return options;
19875const method = String(options.method ?? "GET").toUpperCase();
19876if (method !== "GET" && method !== "HEAD" || !isKnownDirectCorsTarget(url) || !isProxySafeRequest(url, options)) return options;
19877return { ...options, allowDirectCrossOrigin: true };
19878}
19879function readFetchResponseBody(response, responseType) {
19880return FETCH_RESPONSE_READERS[responseType ?? "text"]?.(response) ?? response.text();
19881}
19882const FETCH_RESPONSE_READERS = {
19883blob: (response) => response.blob(),
19884arraybuffer: (response) => response.arrayBuffer(),
19885json: (response) => response.json(),
19886text: (response) => response.text()
19887};
19888function formatFailure(options) {
19889return options.failureMessage ?? `${options.failureLabel ?? "Request"} failed.`;
19890}
19891function formatStatusFailure(options, status) {
19892return options.statusFailureMessage?.(status) ?? `${options.failureLabel ?? "Request"} failed (${status}).`;
19893}
19894function isSameOriginUrl(url) {
19895if (typeof location === "undefined") return false;
19896try {
19897return new URL(url, location.href).origin === location.origin;
19898} catch {
19899return false;
19900}
19901}
19902function shouldRetryEventBridgeFailureWithFetch(userscriptRequest, error) {
19903if (!isUserscriptEventBridgeRequest(userscriptRequest)) return false;
19904if (!(error instanceof Error)) return true;
19905return !/\(\d{3}\)/.test(error.message);
19906}
19907function shouldRetryWithFetch(error) {
19908if (!(error instanceof Error)) return true;
19909if (/\(\d{3}\)/.test(error.message)) return false;
19910if (/timed out|timeout/i.test(error.message)) return false;
19911return /network|cors|blocked|request failed/i.test(error.message);
19912}
19913function recordHeaders(headers) {
19914if (!headers) return void 0;
19915if (headers instanceof Headers) return Object.fromEntries(headers.entries());
19916if (Array.isArray(headers)) return Object.fromEntries(headers);
19917return headers;
19918}
19919async function requestJson(url, options = {}) {
19920const value = await requestHttp(url, { ...options, responseType: "json" });
19921return value;
19922}
19923const {
19924applyInterfaceLocaleToRoot,
19925formatIsolated,
19926isRtlInterface
19927} = aggregateRuntimeModules().interfaceDirection;
19928const GRAMMAR_UI_COPY = {
19929en: {
19930findingGrammar: "Finding grammar...",
19931grammarNoLocalMatch: "No built-in {language} grammar patterns matched this sentence.",
19932grammarDetectionPending: "Built-in {language} grammar detection is still being prepared.",
19933grammarReferenceOnly: "Built-in {language} grammar detection is still being prepared. Use the reference below.",
19934grammarCheckUnavailable: "Grammar could not be checked.",
19935grammarReference: "Open grammar reference",
19936grammarKnown: "Known",
19937grammarReview: "Review",
19938grammarDetails: "Details",
19939grammarFoundIn: "Found in",
19940grammarExample: "Example",
19941grammarGuide: "Guide",
19942grammarHideKnown: "Hide known",
19943grammarShowKnown: "Show known",
19944allDetectedGrammarKnown: "All detected grammar is marked known.",
19945grammarShown: "shown",
19946grammarKnownHidden: "known hidden",
19947grammarGenericShort: "Grammar point: {name}",
19948grammarGenericDetail: "Uses {name} in 「{match}」.",
19949grammarLevelCore: "Core"
19950},
19951ja: {
19952findingGrammar: "文法を検索中...",
19953grammarNoLocalMatch: "内蔵の{language}文法パターンはこの文に一致しませんでした。",
19954grammarDetectionPending: "内蔵の{language}文法検出は準備中です。",
19955grammarReferenceOnly: "内蔵の{language}文法検出は準備中です。下のリファレンスを利用できます。",
19956grammarCheckUnavailable: "文法を確認できませんでした。",
19957grammarReference: "文法リファレンスを開く",
19958grammarKnown: "既知",
19959grammarReview: "復習",
19960grammarDetails: "詳細",
19961grammarFoundIn: "検出箇所",
19962grammarExample: "例",
19963grammarGuide: "ガイド",
19964grammarHideKnown: "既知を隠す",
19965grammarShowKnown: "既知を表示",
19966allDetectedGrammarKnown: "検出文法はすべて既知です。",
19967grammarShown: "件表示",
19968grammarKnownHidden: "件の既知を非表示",
19969grammarGenericShort: "文法項目: {name}",
19970grammarGenericDetail: "「{match}」に「{name}」。",
19971grammarLevelCore: "基本"
19972}
19973};
19974const EN_SUBTITLE_SETTINGS_COPY = {
19975subtitlePlayerEnabled: "Enable video subtitle player",
19976subtitleAutoDetect: "Auto-detect page subtitles",
19977subtitleOverlayVisible: "Show subtitle overlay",
19978subtitleSecondaryVisible: "Show native subtitles",
19979subtitleNativeBlurred: "Blur native subtitles until hover",
19980subtitleNativeDisplay: "Translation",
19981subtitleNativeDisplayBlurred: "Blur until reveal (recommended)",
19982subtitleNativeDisplayShown: "Always show",
19983subtitleNativeDisplayHidden: "Hide completely",
19984subtitleNativeBlurStrength: "Blur strength",
19985subtitleKaraokeMode: "Karaoke word timing",
19986subtitleTranscriptVisible: "Open transcript panel by default",
19987subtitlePausePanel: "Open side panel when paused",
19988subtitleShadowAutoPause: "Auto-pause after each shadow line",
19989subtitleTranscriptPlacement: "Transcript panel position",
19990subtitleTranscriptAutoScroll: "Scroll transcript with playback",
19991subtitleTranscriptAutoScrollResumeSeconds: "Resume auto-scroll delay (s)",
19992subtitleAutoCopyLine: "Auto-copy subtitle lines",
19993subtitleMiningPause: "Pause video on subtitle click",
19994subtitleHoverPause: "Pause video on subtitle hover",
19995subtitleControlsMode: "Subtitle controls",
19996subtitleFontSize: "Subtitle font size (px)",
19997subtitleBottomOffset: "Subtitle bottom offset (%)",
19998subtitleTextColor: "Subtitle color",
19999subtitleOutlineColor: "Subtitle outline",
20000subtitleBackgroundColor: "Subtitle background",
20001subtitleBackgroundOpacity: "Subtitle background opacity",
20002subtitleFontFamily: "Subtitle font family",
20003subtitleFontWeight: "Subtitle font weight",
20004subtitleSeekPadding: "Subtitle seek padding (s)",
20005subtitlePreview: "Live subtitle preview"
20006};
20007const JA_SUBTITLE_SETTINGS_COPY = {
20008subtitlePlayerEnabled: "動画字幕プレイヤーを有効にする",
20009subtitleAutoDetect: "ページの字幕を自動検出",
20010subtitleOverlayVisible: "字幕オーバーレイを表示",
20011subtitleSecondaryVisible: "利用可能ならネイティブ字幕を表示",
20012subtitleNativeBlurred: "ホバーするまでネイティブ字幕をぼかす",
20013subtitleNativeDisplay: "母語訳",
20014subtitleNativeDisplayBlurred: "表示するまでぼかす(おすすめ)",
20015subtitleNativeDisplayShown: "常に表示",
20016subtitleNativeDisplayHidden: "完全に隠す",
20017subtitleNativeBlurStrength: "ぼかしの強さ",
20018subtitleKaraokeMode: "カラオケ風の単語タイミング",
20019subtitleTranscriptVisible: "文字起こしパネルを標準で開く",
20020subtitlePausePanel: "一時停止時にサイドパネルを開く",
20021subtitleShadowAutoPause: "シャドー中は各行の後で一時停止",
20022subtitleTranscriptPlacement: "文字起こしパネル位置",
20023subtitleTranscriptAutoScroll: "再生に合わせて文字起こしをスクロール",
20024subtitleTranscriptAutoScrollResumeSeconds: "手動スクロール後の再開 (秒)",
20025subtitleAutoCopyLine: "各字幕行を再生時に自動コピー",
20026subtitleMiningPause: "字幕クリック時に動画を一時停止",
20027subtitleHoverPause: "字幕ホバー時に動画を一時停止",
20028subtitleControlsMode: "字幕コントロール",
20029subtitleFontSize: "字幕フォントサイズ (px)",
20030subtitleBottomOffset: "字幕下端オフセット (%)",
20031subtitleTextColor: "字幕の色",
20032subtitleOutlineColor: "字幕の縁取り",
20033subtitleBackgroundColor: "字幕背景",
20034subtitleBackgroundOpacity: "字幕背景の不透明度",
20035subtitleFontFamily: "字幕フォントファミリー",
20036subtitleFontWeight: "字幕フォントの太さ",
20037subtitleSeekPadding: "字幕シーク余白 (s)",
20038subtitlePreview: "字幕ライブプレビュー"
20039};
20040const SUBTITLE_SETTINGS_COPY = {
20041en: EN_SUBTITLE_SETTINGS_COPY,
20042ja: JA_SUBTITLE_SETTINGS_COPY
20043};
20044const LOCAL_DICTIONARY_STORAGE_COPY = {
20045enSettings: {
20046extensionDictionaryUnavailable: "The extension dictionary service is unavailable. Retry, or reload the Yomu extension.",
20047extensionDictionaryConnectionLost: "The extension dictionary connection was lost. Check whether the operation completed before retrying.",
20048localDictionariesEnabled: "Show imported dictionary definitions",
20049localDictionarySiteStorageHelp: "Imported dictionaries are stored by the site where you import them. Other sites answer from Jiten and your online sources.",
20050clearLocalDictionarySiteStorage: "Disable and remove stored dictionaries",
20051clearLocalDictionarySiteStorageConfirm: "Disable imported dictionaries and delete this site's stored copy?\n\nSites that still hold a copy from earlier versions remove it the next time you visit them. You can re-import dictionaries at any time.",
20052clearLocalDictionarySiteStorageClearing: "Disabling imported dictionaries and clearing this site's copy...",
20053clearLocalDictionarySiteStorageDone: "Imported dictionaries are disabled. This site's copy was deleted; other sites clean up as you visit them."
20054},
20055enImport: {
20056dictionaryImportComplete: "Imported {records} from {sources} source{plural}.",
20057dictionaryImportResultWithFailures: "Imported {records} from {sources} source{plural}. {failed} file{failedPlural} failed: {files}."
20058},
20059jaImport: {
20060dictionaryImportComplete: "{sources}から{records}件インポートしました。",
20061dictionaryImportResultWithFailures: "{sources}から{records}件インポートしました。{failed}ファイルのインポートに失敗しました: {files}。"
20062},
20063jaSettings: {
20064extensionDictionaryUnavailable: "拡張機能の辞書サービスを利用できません。再試行するか、よむ拡張機能を再読み込みしてください。",
20065extensionDictionaryConnectionLost: "拡張機能の辞書サービスとの接続が切れました。再試行する前に、操作が完了していないか確認してください。",
20066localDictionariesEnabled: "インポート済み辞書の定義を表示",
20067localDictionarySiteStorageHelp: "インポート済み辞書は、インポートしたサイトに保存されます。他のサイトではJitenなどのオンラインソースが使われます。",
20068clearLocalDictionarySiteStorage: "無効にして保存済み辞書を削除",
20069clearLocalDictionarySiteStorageConfirm: "インポート済み辞書を無効にし、このサイトの保存コピーを削除しますか?\n\n以前のバージョンのコピーが残っているサイトは、次回訪問時に自動的に削除されます。辞書はいつでも再インポートできます。",
20070clearLocalDictionarySiteStorageClearing: "インポート済み辞書を無効にし、このサイトのコピーを削除中...",
20071clearLocalDictionarySiteStorageDone: "インポート済み辞書を無効にしました。このサイトのコピーは削除され、他のサイトも訪問時に順次削除されます。"
20072}
20073};
20074const TARGET_AWARE_UI_COPY = Object.freeze({
20075en: Object.freeze({
20076puckStudyTarget: "Study {language}",
20077puckLearningTarget: `${APP_NAME} — learning target: {language}`,
20078puckAutoDetectTargetSubtitles: "Auto-detect {language} subtitles",
20079puckFilterYoutubeTarget: "Filter YouTube for {language}",
20080popupLanguageAxes: "Reading {target} · Definitions/translation: {output}",
20081contextOccurrences: "In context ×{count}",
20082loadTargetSubtitles: "Load {language} subtitles",
20083loadOutputSubtitles: "Load {language} subtitles",
20084readingAnnotations: "Reading annotations",
20085hideReadingsFor: "Hide readings for"
20086}),
20087ja: Object.freeze({
20088puckStudyTarget: "{language}を学習",
20089puckLearningTarget: `${APP_NAME} — 学習対象:{language}`,
20090puckAutoDetectTargetSubtitles: "{language}の字幕を自動検出",
20091puckFilterYoutubeTarget: "YouTubeを{language}向けに絞る",
20092popupLanguageAxes: "学習対象:{target}・定義/翻訳:{output}",
20093contextOccurrences: "文脈内 ×{count}",
20094loadTargetSubtitles: "{language}字幕を読み込む",
20095loadOutputSubtitles: "{language}字幕を読み込む",
20096readingAnnotations: "読みの注釈",
20097hideReadingsFor: "読みを隠す対象"
20098})
20099});
20100const SETTINGS_RECOVERY_COPY = {
20101en: {
20102settingsImportUnsupportedFormat: "This settings backup format is not supported.",
20103settingsImportIncomplete: "The settings data in this backup is incomplete.",
20104extensionSettingsRecoveryTitle: "Could not load settings",
20105extensionSettingsRecoveryBody: "Your saved settings have not been changed.",
20106extensionSettingsRecoveryRetry: "Try again",
20107extensionSettingsRecoveryReload: "Reload Study",
20108extensionSettingsRecoveryRetrying: "Loading settings…",
20109extensionSettingsRecoveryStillBlocked: "Settings are still unavailable.",
20110saveAfterImport: "Save after import",
20111settingsImportSaveBlocked: "Settings import is running. Save unlocks when it finishes.",
20112settingsImportStaleSaveDiscarded: "Settings import replaced the earlier pending Save."
20113},
20114ja: {
20115settingsImportUnsupportedFormat: "このバックアップの設定形式には対応していません。",
20116settingsImportIncomplete: "このバックアップの設定データが不完全です。",
20117extensionSettingsRecoveryTitle: "設定を読み込めませんでした",
20118extensionSettingsRecoveryBody: "保存済みの設定は変更されていません。",
20119extensionSettingsRecoveryRetry: "再試行",
20120extensionSettingsRecoveryReload: "Studyを再読み込み",
20121extensionSettingsRecoveryRetrying: "設定を読み込み中…",
20122extensionSettingsRecoveryStillBlocked: "まだ設定を読み込めません。",
20123saveAfterImport: "インポート後に保存",
20124settingsImportSaveBlocked: "設定をインポート中です。完了後に保存できます。",
20125settingsImportStaleSaveDiscarded: "設定のインポートを優先し、先に待機していた保存は破棄しました。"
20126}
20127};
20128const PRACTICE_SESSION_COPY = {
20129en: {
20130practiceTitle: "Practice",
20131practicePurpose: "Session type",
20132practiceRecognition: "Read words",
20133practiceCloze: "Complete sentences",
20134practiceWriting: "Write words",
20135practiceListening: "Listen",
20136practiceSpeaking: "Speak",
20137practiceStart: "Start session",
20138practiceResume: "Resume",
20139practiceSaved: "Saved sessions",
20140practiceCurrentSelection: "Use current selection",
20141practiceScheduleUnchanged: "Your scheduled reviews stay unchanged.",
20142practicePreparing: "Preparing session…",
20143practiceUnavailable: "This session could not be opened.",
20144practiceNoMaterial: "No selected words are ready for this session type.",
20145practiceSaveFailed: "Progress could not be saved. Try again.",
20146practiceConflict: "This session changed in another window. Copy any unsaved answer before reopening.",
20147practiceReopen: "Reopen saved progress",
20148practiceEmptyAnswer: "Enter an answer.",
20149practiceCheck: "Check",
20150practiceCorrect: "Matches the word",
20151practiceAccepted: "Reading matches",
20152practiceDifferent: "Try again, or compare with the answer.",
20153practiceRemembered: "I remembered",
20154practiceNotYet: "Not yet",
20155practiceNext: "Next",
20156practiceSkip: "Skip",
20157practicePause: "Pause",
20158practiceComplete: "Session complete",
20159practiceBack: "Back to Study",
20160practiceNew: "New session",
20161practiceWordsCount: "{count} words",
20162practicePosition: "{current} of {total}",
20163practiceResponse: "Your answer",
20164practiceAudio: "Play question audio"
20165},
20166ja: {
20167practiceTitle: "練習",
20168practicePurpose: "練習方法",
20169practiceRecognition: "単語を読む",
20170practiceCloze: "文の空欄を埋める",
20171practiceWriting: "単語を書く",
20172practiceListening: "聞き取り",
20173practiceSpeaking: "発話",
20174practiceStart: "練習を開始",
20175practiceResume: "再開",
20176practiceSaved: "保存した練習",
20177practiceCurrentSelection: "現在の選択を使う",
20178practiceScheduleUnchanged: "復習予定には影響しません。",
20179practicePreparing: "練習を準備中…",
20180practiceUnavailable: "この練習を開けませんでした。",
20181practiceNoMaterial: "選択した単語では、この形式の練習を開始できません。",
20182practiceSaveFailed: "進捗を保存できませんでした。もう一度お試しください。",
20183practiceConflict: "別のウィンドウで進捗が変わりました。未保存の回答をコピーしてから開き直してください。",
20184practiceReopen: "保存済みの進捗を開く",
20185practiceEmptyAnswer: "回答を入力してください。",
20186practiceCheck: "確認",
20187practiceCorrect: "表記が一致",
20188practiceAccepted: "読みが一致",
20189practiceDifferent: "もう一度試すか、答えを確認してください。",
20190practiceRemembered: "思い出せた",
20191practiceNotYet: "まだ覚えていない",
20192practiceNext: "次へ",
20193practiceSkip: "スキップ",
20194practicePause: "中断",
20195practiceComplete: "練習完了",
20196practiceBack: "学習に戻る",
20197practiceNew: "新しい練習",
20198practiceWordsCount: "{count}語",
20199practicePosition: "{total}問中{current}問",
20200practiceResponse: "回答",
20201practiceAudio: "問題の音声を再生"
20202}
20203};
20204const SAVE_WAIT_COPY = {
20205en: {
20206saveWaitingForAnotherTab: `Waiting for another ${APP_NAME} tab to finish saving…`
20207},
20208ja: {
20209saveWaitingForAnotherTab: `ほかの${APP_NAME}タブの保存が終わるのを待っています…`
20210}
20211};
20212const GRADING_SERVICE_COPY = {
20213en: {
20214switchReviewTarget: "Switch review target",
20215switchGradingProvider: "Switch grading provider",
20216apiGradingProvider: "Preferred grading service",
20217apiGradingProviderHelp: "Where grades go when both Jiten and JPDB are connected; Automatic parsing follows it too. Study review cards grade to the service they came from, and the ⇄ toggle next to the grade buttons switches only that word.",
20218gradingServiceWordNotFound: "Not graded: this word was not found in your preferred grading service."
20219},
20220ja: {
20221switchReviewTarget: "採点先を切り替える",
20222switchGradingProvider: "採点サービスを切り替える",
20223apiGradingProvider: "優先採点サービス",
20224apiGradingProviderHelp: "JitenとJPDBの両方を接続しているときの採点先です。解析ソースが「自動」の場合も、この設定に従います。Studyの復習カードは取得元のサービスで採点され、採点ボタン横の⇄はその単語だけを切り替えます。",
20225gradingServiceWordNotFound: "優先採点サービスでこの単語が見つからなかったため、採点していません。"
20226}
20227};
20228const EN = {
20229collectNoDestination: "None of your decks can take this word. Turn one on in Settings.",
20230collectWordNotFound: "Not saved: this word was not found in your preferred grading service.",
20231collectAlreadySaved: "Already in one of your decks. Open Study to edit it.",
20232collectHandoffOpened: "Opened your deck app. Finish saving there.",
20233collectNotSaved: "This word was not saved. Try again, or open Study for details.",
20234jpdbAddApiKeyRequired: "Add a JPDB API key, or use Add to Anki.",
20235addedToJpdb: "Added to JPDB.",
20236jitenAddApiKeyRequired: "Add a Jiten API key, or use Add to Anki.",
20237chooseJitenStudyDeck: "Choose a Jiten study deck first.",
20238addedToJiten: "Added to Jiten.",
20239bunproAddApiKeyRequired: "Add a Bunpro frontend API token, or use Add to Anki.",
20240bunproNoMatchingWord: "Bunpro has no entry for this word.",
20241addedToBunpro: "Added to Bunpro.",
20242yomuLocalSrsDisabled: `Enable ${ACADEMY_SRS_LABEL} in Settings first.`,
20243yomuLocalSrsStorageFailed: "Your Academy deck could not be saved. Browser storage may be full. Free some site storage, then try again.",
20244yomuLocalSrsSaveInterrupted: "Your Academy deck was not saved because saving was interrupted. Try again.",
20245addedToYomuLocal: `Added to ${ACADEMY_SRS_LABEL}.`,
20246savedWord: "Saved"
20247};
20248const JA = {
20249collectNoDestination: "この単語を追加できるデッキがありません。設定でデッキを有効にしてください。",
20250collectWordNotFound: "優先採点サービスでこの単語が見つからなかったため、保存していません。",
20251collectAlreadySaved: "すでにデッキにあります。編集はStudyで行えます。",
20252collectHandoffOpened: "デッキのアプリを開きました。そちらで保存を完了してください。",
20253collectNotSaved: "この単語は保存されませんでした。もう一度お試しいただくか、Studyで詳細を確認してください。",
20254jpdbAddApiKeyRequired: "JPDB APIキーかAnki追加が必要です。",
20255addedToJpdb: "JPDBに追加しました。",
20256jitenAddApiKeyRequired: "Jiten APIキーかAnki追加が必要です。",
20257chooseJitenStudyDeck: "先にJiten学習デッキを選択してください。",
20258addedToJiten: "Jitenに追加しました。",
20259bunproAddApiKeyRequired: "Bunproのfrontend_api_tokenかAnki追加が必要です。",
20260bunproNoMatchingWord: "この単語はBunproに見つかりませんでした。",
20261addedToBunpro: "Bunproに追加しました。",
20262yomuLocalSrsDisabled: "先に設定でAcademyを有効にしてください。",
20263yomuLocalSrsStorageFailed: "Academyデッキを保存できませんでした。ブラウザーの保存容量が不足している可能性があります。サイトの保存容量を空けてから、もう一度お試しください。",
20264yomuLocalSrsSaveInterrupted: "保存が中断されたため、Academyデッキに保存さã
20264‚Œã¾ã›ã‚“でした。もう一度お試しください。",
20265addedToYomuLocal: "Academyに追加しました。",
20266savedWord: "保存済み"
20267};
20268const COLLECTION_COPY = { en: EN, ja: JA };
20269const EN_OCR_STATUS_COPY = {
20270ocrPlayVideo: "Play video",
20271ocrPausedFrameScanning: "Scanning...",
20272ocrPausedFrameReady: "Text ready",
20273ocrPausedFrameNoText: "No text found",
20274ocrPausedFrameFailed: "Could not read text",
20275ocrRetryScan: "Scan again",
20276ocrNoReadableImages: "No readable images nearby.",
20277ocrCanvasTapHint: "Tap or click the page to read it",
20278ocrCanvasTapHintDismiss: "Dismiss tip"
20279};
20280const JA_OCR_STATUS_COPY = {
20281ocrPlayVideo: "動画を再生",
20282ocrPausedFrameScanning: "スキャン中...",
20283ocrPausedFrameReady: "テキスト準備完了",
20284ocrPausedFrameNoText: "テキストが見つかりません",
20285ocrPausedFrameFailed: "テキストを読み取れませんでした",
20286ocrRetryScan: "再スキャン",
20287ocrNoReadableImages: "近くに読み取れる画像がありません。",
20288ocrCanvasTapHint: "ページをタップまたはクリックすると読めます",
20289ocrCanvasTapHintDismiss: "ヒントを閉じる"
20290};
20291const OCR_STATUS_COPY = {
20292en: EN_OCR_STATUS_COPY,
20293ja: JA_OCR_STATUS_COPY
20294};
20295const COPY = {
20296en: {
20297...PRACTICE_SESSION_COPY.en,
20298...COLLECTION_COPY.en,
20299settingsTitle: `${APP_NAME} Settings`,
20300welcomeLabel: `${APP_NAME} welcome`,
20301onboardingEyebrow: "{language}, wherever it appears",
20302onboardingCopy: "Make {language} text, subtitles, and images tappable.",
20303onboardingLanguage: "Settings language",
20304onboardingOutputLanguage: "Definition and translation language (output)",
20305onboardingTargetLanguage: "Language you are reading (target)",
20306onboardingChooseTarget: "Choose a learning language…",
20307onboardingTargetRequired: "Choose a learning language before continuing.",
20308onboardingUnselectedTargetName: "your learning language",
20309onboardingAccentColor: "Accent color",
20310customAccentColor: "Custom color",
20311onboardingImmersionOptions: "Immersion defaults",
20312onboardingInstallOfflineDictionaries: "Download starter dictionaries for this language",
20313studyTargetReadinessFull: "Full Yomu support",
20314studyTargetReadinessReadingOnly: "Read, mine and review",
20315studyTargetReadinessPlanned: "Planned",
20316studyTargetReadinessFullReason: "Everything, including pitch accent, kanji and grammar.",
20317studyTargetReadinessReadingOnlyReason: "Reading, lookup, mining and review are ready.",
20318studyTargetReadinessPlannedReason: "Support is planned.",
20319onboardingHoverShortcut: "Lookup hover modifier",
20320manualPageScanShortcut: "Manual page scan shortcut",
20321onboardingAddApiKey: "Add API key",
20322onboardingUseWithoutApiKey: "Use without API key",
20323closeOnboarding: "Close welcome",
20324featureText: "Text",
20325featureTextBody: "Hover or tap scanned {language}.",
20326featureImages: "Images",
20327featureImagesBody: "Read any image by tapping it.",
20328featureVideo: "Video",
20329featureVideoBody: "Make subtitle words tappable.",
20330featureControl: "Control",
20331featureControlBody: "Tune features, shortcuts, and color.",
20332featureStudy: "Study",
20333featureStudyBody: "Review words and characters on the study page.",
20334featureGame: "Game",
20335featureGameBody: "Install the Yomu app to use in games or anywhere on the PC.",
20336gamingChooseTargetTitle: "Choose the language you want to read",
20337gamingChooseTargetBody: "Yomu can read any supported language on your screen after you choose it.",
20338gamingChooseTargetAction: "Choose a language",
20339gamingTargetRequired: "Choose the language you want to read before capturing your screen.",
20340scanPage: "Scan page",
20341noUnscannedJapaneseText: "No unscanned {language} text found.",
20342jpdbScanFailed: "Page scan failed.",
20343pageCoverageSummary: "{percent}% known · {known}/{total} · {unknown} new · {iPlusOne} i+1",
20344settings: "Settings",
20345settingsSaved: "Settings saved.",
20346settingsSaveFailed: "Settings save failed.",
20347settingsCompanionUnavailable: "Settings could not be opened.",
20348...SETTINGS_RECOVERY_COPY.en,
20349...SAVE_WAIT_COPY.en,
20350firefoxAuthenticationInfoDenied: "Those account details were not saved because Firefox permission was not granted.",
20351firefoxAuthenticationInfoExtensionPageRequired: "Firefox can only ask for that permission on a Yomu page. Open Study, then add the account details in Settings.",
20352settingsSections: "Settings sections",
20353settingsSearch: "Search settings",
20354settingsSearchPlaceholder: "Search settings",
20355settingsSearchNoResults: "No matches.",
20356accountSettingsTrustedSurfaceTitle: "Open Settings in Study",
20357accountSettingsTrustedSurfaceHelp: "This page can read and change its own controls, so Yomu does not put settings, account details, imports, or recovery codes here. Open the Yomu-owned Study page to edit and save them safely.",
20358openAccountSettingsTrustedSurface: "Open Study settings",
20359onboardingTrustedSurfaceEyebrow: "Finish setup in Study",
20360onboardingTrustedSurfaceCopy: "This website can change anything shown here. Choose your learning language and preferences on the Yomu-owned Study page.",
20361openOnboardingTrustedSurface: "Continue setup in Study",
20362save: "Save",
20363cancel: "Cancel",
20364show: "Show",
20365hide: "Hide",
20366appearance: "Appearance",
20367reading: "Reading",
20368dictionaries: "Dictionaries",
20369sources: "Sources",
20370backupSync: "Backup & sync",
20371backupSyncHelp: "Save or move your Yomu setup: export and import settings as plain JSON, back up dictionaries, or sync through Google Drive.",
20372media: "Media",
20373mining: "Mining",
20374shortcuts: "Shortcuts",
20375help: "Help",
20376reader: "Reader",
20377kanji: "Kanji",
20378audio: "Audio",
20379images: "Image text (OCR)",
20380video: "Video",
20381youTube: "YouTube",
20382anki: "Anki",
20383jpdb: "JPDB",
20384api: "API",
20385apiCredential: "API key",
20386apiCredentialJpdb: "JPDB API key",
20387apiCredentialJiten: "Jiten API key",
20388apiCredentialBunpro: "Bunpro frontend API token",
20389apiCredentialBunproLegacy: "Bunpro API key",
20390apiCredentialWanikani: "WaniKani personal access token",
20391apiKey: "API key",
20392jitenApiKey: "Jiten API key",
20393apiAccess: "API access",
20394storedCredentialPlaceholder: "Saved — enter a replacement",
20395clearStoredCredential: "Remove saved credential",
20396apiAccessHelp: "Add each service credential here. Bunpro only needs the frontend token: import it from Bunpro settings, treat it like a password, and note that it is saved before it is verified. Academy reviews work locally without an account.",
20397wanikaniTokenHelp: "Create a read/write personal access token on WaniKani and paste it here. It is stored only in your browser, sent directly to api.wanikani.com (never through a proxy), and never logged.",
20398jpdbSettings: "JPDB settings",
20399jitenSettings: "Jiten settings",
20400bunproSettings: "Bunpro settings",
20401wanikaniSettings: "WaniKani settings",
20402jpdbApiKeyConfigured: "JPDB key set.",
20403jpdbAndJitenApiKeysConfigured: "Jiten and JPDB keys are set.",
20404jpdbConnected: "Connected to JPDB.",
20405jpdbAndJitenConnected: "Connected to Jiten and JPDB.",
20406jpdbConnectionFailed: "JPDB did not accept the key (network or invalid key).",
20407statusReady: "Ready",
20408statusAttention: "Needs setup",
20409statusError: "Error",
20410disabledControlDescription: "Controlled by another setting.",
20411jpdbMiningEnabled: "Allow API review/deck changes",
20412bunproMiningEnabled: "Allow Bunpro review/mining",
20413wanikaniReviewEnabled: "Allow WaniKani review (due assignments only)",
20414wanikaniGradeMappingHelp: "Yomu maps its grade to WaniKani’s pass/fail answer counts: Okay, Good, and Easy submit a clean pass. Anything below Okay submits one incorrect meaning answer and, unless the subject is a radical, one incorrect reading answer.",
20415yomuLocalSrsEnabled: `Enable ${ACADEMY_SRS_LABEL}`,
20416addToForq: "Also copy JPDB adds to forq",
20417enableReviews: "Show review buttons",
20418reviewRatingScale: "Review rating scale",
20419gradeTargetSelector: "Grade target",
20420gradeTargetBoth: "Both",
20421gradeTargetJpdb: "Grades JPDB",
20422gradeTargetJiten: "Grades Jiten",
20423gradeTargetBunpro: "Grades Bunpro",
20424gradeTargetWanikani: "Grades WaniKani",
20425gradeTargetYomuLocal: `Grades ${ACADEMY_SRS_LABEL}`,
20426gradeTargetAnki: "Grades Anki card: {target}",
20427gradeTargetJpdbAndAnki: "Grades JPDB + Anki card: {target}",
20428gradeTargetJitenAndAnki: "Grades Jiten + Anki card: {target}",
20429gradeTargetBunproAndAnki: "Grades Bunpro + Anki card: {target}",
20430gradeTargetYomuLocalAndAnki: `Grades ${ACADEMY_SRS_LABEL} + Anki card: {target}`,
20431missingAnkiCardId: "Missing Anki card id.",
20432jpdbPageEnhancements: "Dictionary site enhancements",
20433jpdbPageEnhancementsEnabled: "Enhance dictionary pages",
20434jpdbPageWordEnhancementsEnabled: "Add sources to word/search pages",
20435jpdbPageKanjiEnhancementsEnabled: "Add sources to kanji pages",
20436fivePoint: "Provider default",
20437fourGradeShortcutsHelp: "Four-grade reviews use the first four shortcuts: Again, Hard, Good, Easy.",
20438twoPoint: "Two point: FAIL / PASS",
20439settingsLanguage: "Settings language",
20440automatic: "Automatic",
20441english: "English",
20442japanese: "日本語",
20443theme: "Theme",
20444auto: "Auto",
20445dark: "Dark",
20446light: "Light",
20447switchToDarkTheme: "Switch to dark theme",
20448switchToLightTheme: "Switch to light theme",
20449popupMode: "Popup mode",
20450hoverPopupMode: "Hover popup mode",
20451bottomSheet: "Bottom sheet",
20452popover: "Popover",
20453stickyBottomSheet: "Keep sheet open after lookup",
20454popoverBackdropEnabled: "Dim page behind popover",
20455popoverWidth: "Popover width (px)",
20456popoverHeight: "Popover height (px)",
20457popoverHeightMode: "Popover height behavior",
20458popoverHeightAvailable: "Grow to available space",
20459popoverHeightFixed: "Use height setting",
20460readerFontFamily: "Reader interface font",
20461popupFontFamily: "Popup font",
20462fontPresetYomuDefault: "Built-in font",
20463fontPresetJapaneseSans: "Japanese sans",
20464fontPresetHiraginoYuGothic: "Hiragino / Yu Gothic",
20465fontPresetJapaneseRounded: "Japanese rounded",
20466fontPresetJapaneseSerif: "Japanese serif",
20467fontPresetSystemUi: "System UI",
20468fontPresetCustom: "Custom...",
20469customFontFamily: "Custom font stack",
20470popupFontWeight: "Popup font weight",
20471enableLogging: "Enable diagnostic logging",
20472diagnostics: "Diagnostics",
20473diagnosticsHelp: "Print diagnostics to the console.",
20474accentColor: "Accent color",
20475newTab: "Study",
20476newTabAnkiEnabled: "Use Anki cards in Study",
20477newTabAnkiReviewDecks: "Anki review decks",
20478newTabAnkiReviewDecksHelp: "Uncheck decks to skip.",
20479newTabSource: "Study review source",
20480newTabAuto: `Auto: ${ACADEMY_SRS_LABEL}, accounts, then study words`,
20481newTabApiSrs: "API SRS (Jiten / JPDB)",
20482newTabBunpro: "Bunpro",
20483newTabWanikani: "WaniKani",
20484newTabYomuLocal: ACADEMY_SRS_LABEL,
20485dictionaryFallback: "Dictionary fallback",
20486newTabJpdbReviewMode: "API review mode",
20487newTabJpdbReviewAuto: "Auto: live kanji + API vocabulary",
20488newTabLiveReview: "Live JPDB review session",
20489newTabApiVocabulary: "API vocabulary only",
20490corsProxyUrl: "Cross-origin proxy URL",
20491newTabKanjiKeywordSource: "Kanji keyword source",
20492newTabKanjiKeywordAuto: "Auto: RTK, then {service} kanji facts, then local",
20493newTabKanjiKeywordRtk: "RTK / Heisig",
20494newTabKanjiKeywordApiFacts: "{service} kanji facts (Jiten / JPDB)",
20495newTabKanjiKeywordLocal: "Local card meaning",
20496newTabParsingEnabled: "Enable sentence parsing on Study",
20497newTabFrontSentenceEnabled: "Show sentence on word fronts",
20498newTabKanjiAutogradeEnabled: "Auto-grade kanji drawing",
20499newTabOfflineEnabled: "Cache Study for offline use",
20500newTabOfflineLimit: "Offline review cache limit",
20501newTabDailyGoalMinutes: "Daily study goal (minutes, 0 = off)",
20502newTabKanjiUnlockEnabled: "Study kanji before unlocking words",
20503newTabStopAtBatchEnd: "Stop at the end of each batch",
20504newTabSwipeReviews: "Swipe cards to grade (left = fail, right = pass)",
20505newTabShortcutHintsEnabled: "Show Study keyboard shortcut hints",
20506newTabUrl: "Study address",
20507newTabOfflineHelp: "Caches due cards and queued grades.",
20508newTabAddressHelp: "Use as a start page or iPad shortcut.",
20509newTabJpdbDeck: "Study JPDB deck",
20510openNewTabPage: "Open Study",
20511copyAddress: "Copy address",
20512wordColors: "Word colors",
20513wordColorNew: "New and in deck",
20514wordColorLearning: "Learning",
20515wordColorKnown: "Known and never forget",
20516wordColorDue: "Due",
20517wordColorFailed: "Failed",
20518wordColorIgnored: "Ignored, suspended, and blacklisted",
20519pitchAccentColors: "Pitch accent colors",
20520pitchColorHeiban: "Heiban (flat)",
20521pitchColorAtamadaka: "Atamadaka (head-high)",
20522pitchColorNakadaka: "Nakadaka (middle-high)",
20523pitchColorOdaka: "Odaka (tail-high)",
20524pitchColorUnknown: "Unknown",
20525pronunciation: "Pronunciation",
20526noExactPitch: "Exact pitch unavailable",
20527colorChannels: "Color channels",
20528wordHighlightColorSource: "Word highlight color",
20529wordUnderlineColorSource: "Word underline color",
20530wordTextColorSource: "Word text color",
20531subtitleHighlightColorSource: "Subtitle highlight color",
20532subtitleUnderlineColorSource: "Subtitle underline color",
20533subtitleTextColorSource: "Subtitle text color",
20534colorSourceStatus: "All study statuses",
20535colorSourceJpdb: "Primary deck status",
20536colorSourceAnki: "Anki status",
20537colorSourcePitch: "Pitch accent",
20538colorSourceNone: "None",
20539popupLookup: "Popup lookup",
20540popupLookupEnabled: "Show Yomu lookup popup",
20541popupLookupHelp: "Off for another reader's popups. Yomu tools stay on.",
20542lookupOnClick: "Look up on tap or click",
20543lookupOnHover: "Look up on hover",
20544lookupOnMiddleMouse: "Look up with middle-mouse hold",
20545showFloatingButton: "Show settings puck",
20546pageScanMode: "{language} text on webpages",
20547pageScanModeOff: "Leave pages unchanged",
20548pageScanModeAuto: "Scan {language} automatically",
20549pageScanModeManual: "Scan only when I ask",
20550manualScanEnabled: "Manual page scanning",
20551ocrInteractionMode: "Image OCR scanning",
20552ocrInteractionModeAuto: "Auto",
20553ocrInteractionModeManual: "Tap or hover",
20554ocrInteractionModeOff: "Off",
20555puckMenuLabel: `${APP_NAME} menu`,
20556...TARGET_AWARE_UI_COPY.en,
20557puckPauseAnnotations: "Pause annotations",
20558puckResumeAnnotations: "Resume annotations",
20559puckOcrAuto: "OCR: Auto",
20560puckOcrManual: "OCR: Tap/Hover",
20561puckOcrOff: "OCR: Off",
20562annotationsPausedToast: "Annotations paused.",
20563annotationsResumedToast: "Annotations resumed.",
20564puckMuteAudio: "Mute auto-play audio",
20565puckUnmuteAudio: "Unmute auto-play audio",
20566autoplayAudioOnToast: "Auto-play audio on.",
20567autoplayAudioOffToast: "Auto-play audio muted.",
20568puckHideFurigana: "Hide furigana",
20569furiganaOffToast: "Furigana off. Lookups stay active.",
20570showFurigana: "Enable furigana annotations",
20571furiganaMode: "Furigana",
20572wordColorStates: "Color words",
20573appearancePreset: "Quick setup",
20574appearancePresetCustom: "Keep current custom settings",
20575appearancePresetBalanced: "Balanced reading",
20576appearancePresetNoColors: "Plain text",
20577appearancePresetNewOnly: "Focus on new words",
20578appearancePresetUnderlineNew: "Minimal highlights",
20579wordColorStatesAll: "Use all learning states",
20580wordColorStatesNewOnly: "Only new / not-in-deck words",
20581hideFuriganaFor: "Hide furigana for",
20582hideColorFor: "Hide color for",
20583furiganaDifficultKanji: "Hard kanji only",
20584furiganaDifficultKanjiHelp: `${APP_NAME} keeps a fixed beginner kanji list and shows readings on everything outside it. A bare kanji means that character sits on the list.`,
20585statusColorNoSourceHelp: `Status colors read from a deck. Enable ${ACADEMY_SRS_LABEL} in Study, or add a JPDB, Jiten, or Anki source, and words take the color of their learning state.`,
20586furiganaHideKnown: "Hide familiar words",
20587furiganaHoverOnly: "Show on hover",
20588furiganaAllParsed: "Show on every parsed word",
20589clampedRowReadings: "Readings on clamped rows",
20590clampedRowReadingsShow: "Show (row grows)",
20591clampedRowReadingsHover: "Hover only",
20592showPitchAccent: "Show pronunciation",
20593showLookupPillFrequency: "Show site frequency in pills",
20594suppressRedundantWordUi: "Hide JPDB-redundant styling",
20595sheetCloseButtonOnLeft: "Sheet close button on left",
20596hideKnownFurigana: "Hide furigana for known cards only",
20597readerHelp: "Set a hover key. Blank means plain hover.",
20598hoverLookupSettings: "Hover lookup",
20599kanjiOriginKanjiMapEnabled: "Show kanji facts and component graph",
20600kanjiOriginGraphEnabled: "Show component graph",
20601kanjiOriginRadicalImagesEnabled: "Show radical images",
20602similarKanjiWordLimit: "Similar word limit",
20603noSimilarWords: "No additional words found.",
20604audioEnabled: "Enable term audio",
20605autoPlayAudio: "Auto-play term audio",
20606suppressAutoAudioOnVideo: "Disable lookup audio on video pages",
20607audioAutoPlayMode: "Auto-play trigger",
20608audioEnableDefaultSources: "Enable built-in audio sources",
20609audioFallbackChimeEnabled: "Enable fallback chime",
20610audioSelectionMode: "When several sources or clips exist",
20611audioPlayback: "Audio playback",
20612firstAudio: "First audio",
20613randomAudio: "Shuffle audio",
20614audioTtsMode: "Text-to-speech handling",
20615audioTtsFallback: "Fallback after recorded audio",
20616audioTtsSourceOrder: "Follow source order / shuffle",
20617audioTimeoutMs: "Audio timeout (ms)",
20618previewAudio: "Preview audio",
20619audioHelp: "URL tokens: {term}, {reading}, {language}.",
20620audioSource: "Audio source",
20621urlVoice: "URL / voice",
20622addAudioSource: "Add audio source",
20623audioAutoPlayAll: "Hover and tap/click",
20624audioAutoPlayHover: "Hover only",
20625audioAutoPlayTap: "Tap/click only",
20626automaticBrowserVoice: "Automatic browser voice",
20627savedVoiceLabel: "Saved voice: {voice}",
20628audioSourceOrder: "Audio source order",
20629audioSourceNumber: "Audio source {number}",
20630enableAudioSourceNumber: "Enable audio source {number}",
20631enableLookupPillName: "Enable lookup pill: {name}",
20632enableSourceName: "Enable source: {name}",
20633textToSpeechVoiceNumber: "Text-to-speech voice {number}",
20634audioSourceJpod101: "JapanesePod101",
20635audioSourceLanguagePod101: "LanguagePod101",
20636audioSourceJisho: "Jisho.org",
20637audioSourceBunpro: "Bunpro",
20638audioSourceLinguaLibre: "(Commons) Lingua Libre",
20639audioSourceWiktionary: "(Commons) Wiktionary",
20640audioSourceJitenTts: "Jiten text-to-speech",
20641audioSourceJpdbTts: "JPDB text-to-speech",
20642audioSourceTextToSpeech: "Text-to-speech",
20643audioSourceTextToSpeechReading: "Text-to-speech (reading)",
20644audioSourceCustom: "Custom direct audio file URL",
20645audioSourceCustomJson: "Custom URL",
20646audioCustomJsonPlaceholder: "Yomitan or Ultimate audio source URL",
20647audioCustomUrlPlaceholder: "Direct audio file URL",
20648audioBuiltInPlaceholder: "Built-in source, no URL needed",
20649audioDetectingSubSources: "Checking included sources…",
20650audioNoSubSourcesDetected: "No named sources reported by this URL.",
20651audioSubSourcesHelp: "Sources offered by this URL — untick any you don’t want:",
20652audioSubSourceOverlapHint: "also listed as its own source",
20653defaultVoiceSuffix: "default",
20654audioGuideLinkLabel: "Yomitan audio guide",
20655audioProxyGuideSummary: "Make your own Cloudflare proxy",
20656audioProxyGuideIntro: "Use a Worker when you want a private proxy.",
20657audioProxyGuideCloudflare: "Open Cloudflare.",
20658audioProxyGuideWorkers: "Open Workers & Pages, then Create.",
20659audioProxyGuideCreateWorker: "Choose Worker, name it, deploy.",
20660audioProxyGuideEditCode: "Paste the Yomu Worker source.",
20661audioProxyGuideDeploy: "Deploy.",
20662audioProxyGuideCopyUrl: "Copy the Worker URL.",
20663audioProxyGuidePasteUrl: "Paste it into Cross-origin proxy URL.",
20664audioProxyGuideTest: "Save, then test lookup/import/audio.",
20665audioProxyGuideNote: "Limit hosts before sharing.",
20666audioProxyWorkerSource: "Worker source",
20667audioProxyDeployGuide: "Deploy guide",
20668immersionKit: "Immersion Kit",
20669immersionKitEnabled: "Show Immersion Kit examples",
20670immersionKitExampleSource: "Example provider",
20671immersionKitAndNadeshiko: "Immersion Kit + Nadeshiko",
20672nadeshikoApiKey: "Nadeshiko API key",
20673getNadeshikoKey: "Get a key",
20674immersionKitShowTranslation: "Show example translations",
20675immersionKitRevealTranslationOnClick: "Blur example translations until clicked",
20676immersionKitShowImages: "Show example thumbnails",
20677immersionKitAutoPlayAudio: "Play example audio after reveal or next/previous",
20678immersionKitPlayOnHover: "Play example audio when hovering thumbnails",
20679immersionKitPlayOnImageClick: "Play example audio when clicking thumbnails",
20680immersionKitCategory: "Immersion Kit category",
20681immersionKitSort: "Example order",
20682immersionKitLimitEnabled: "Examples per word limit",
20683allExamples: "All examples",
20684limitExamples: "Limit examples",
20685immersionKitLimit: "Examples per word",
20686immersionKitMinLength: "Minimum sentence length",
20687immersionKitMaxLength: "Maximum sentence length",
20688immersionKitPlaybackRate: "Example audio speed",
20689immersionKitExactMatch: "Prefer exact matches",
20690immersionKitHelp: "Examples appear in popups. Nadeshiko needs a key.",
20691loadingExamples: "Loading examples...",
20692noImmersionExamplesCompact: "No examples",
20693immersionKitRateLimited: "Immersion Kit rate-limited; retrying later.",
20694immersionKitRequest: "Immersion Kit request",
20695immersionKitRequestFailed: "Immersion Kit request failed.",
20696immersionKitRequestFailedWithStatus: "Immersion Kit request failed ({status}).",
20697immersionKitRequestTimedOut: "Immersion Kit request timed out.",
20698immersionKitSearchBlocked: "Immersion Kit blocked. Configure CORS.",
20699immersionKitMediaRequest: "Media request",
20700immersionKitMediaRequestFailed: "Media request failed.",
20701immersionKitMediaRequestFailedWithStatus: "Media request failed ({status}).",
20702immersionKitMediaRequestTimedOut: "Media request timed out.",
20703immersionKitMediaRequestReturnedNonMedia: "Media request returned an error page.",
20704immersionKitNoMediaCandidate: "No Immersion Kit media loaded.",
20705nadeshikoRequest: "Nadeshiko request",
20706nadeshikoRequestFailed: "Nadeshiko request failed.",
20707nadeshikoRequestFailedWithStatus: "Nadeshiko request failed ({status}).",
20708nadeshikoRequestTimedOut: "Nadeshiko request timed out.",
20709previousExample: "Previous example",
20710nextExample: "Next example",
20711playExampleAudio: "Play example audio",
20712allCategories: "All",
20713anime: "Anime",
20714drama: "Drama",
20715games: "Games",
20716shortestFirst: "Shortest first",
20717longestFirst: "Longest first",
20718ocrEnabled: "Read text in images",
20719ocrAutoScanImages: "Read images automatically",
20720ocrShowTextOverlay: "Show recognized text areas",
20721ocrVideoPauseFrames: "Auto-read paused video frames",
20722ocrInvertDarkPanels: "Read light text on dark panels",
20723ocrProvider: "Image reading",
20724ocrOverlayTheme: "OCR overlay theme",
20725ocrOverlayThemeAuto: "Match app theme",
20726ocrOverlayThemeLight: "Light overlay",
20727ocrOverlayThemeDark: "Dark overlay",
20728googleLens: "Google Lens (free, recommended)",
20729cloudVision: "Google Cloud Vision (API key)",
20730localOcr: "Local OCR server",
20731off: "Off",
20732ocrMaxImagesPerPage: "Images to read per page",
20733ocrMinImageArea: "Smallest image to read",
20734ocrMaxImagePixels: "Image detail",
20735lightWork: "Light",
20736normal: "Normal",
20737more: "More",
20738largeOnly: "Large images only",
20739includeSmall: "Include small images",
20740faster: "Faster",
20741balanced: "Balanced",
20742sharper: "Sharper",
20743ocrTextColor: "Image text color",
20744ocrOutlineColor: "Image text outline",
20745ocrBackgroundOpacity: "Image highlight opacity",
20746ocrFontScale: "Image text scale",
20747ocrEndpointUrl: "Local OCR server URL",
20748ocrEngine: "Local OCR engine",
20749ocrEngineMangaOcr: "MangaOCR (best for manga)",
20750ocrEngineAppleVision: "Apple Vision (macOS)",
20751cloudVisionApiKey: "Google Cloud Vision API key",
20752ocrHelp: "Reads nearby images. Google Lens needs no setup.",
20753ocrCloudHelp: "Paste a Google Cloud Vision API key.",
20754ocrLocalHelp: "Run MangaOCR/Apple Vision locally and enter its URL.",
20755...SUBTITLE_SETTINGS_COPY.en,
20756right: "Right",
20757left: "Left",
20758bottom: "Below",
20759showWhenNeeded: "Compact controls",
20760hideControls: "Hide controls",
20761alwaysVisible: "Always visible",
20762preview: "Preview",
20763youtubeImmersionEnabled: "{language} YouTube only",
20764preferJapaneseSiteLanguage: "Open {language} versions of sites",
20765youtubeShowChannelRecommendations: "Show Japanese channel suggestions",
20766youtubeShowFilterNotice: "Show hidden-video notice",
20767youtubeHelp: "Filter YouTube for {language} and open {language} versions of sites.",
20768youtubeShowHiddenVideos: "Show hidden videos",
20769youtubeHideHiddenVideos: "Hide hidden videos",
20770youtubeHideNotice: "Hide notice",
20771youtubeFilterShowing: "{appName} shows {count} hidden item{plural}",
20772youtubeFilterHid: "{appName} hid {count} other-language item{plural}",
20773youtubeFilterVisible: "{count} {language} items stayed visible.",
20774youtubeToggleToastOn: "YouTube immersion filter enabled.",
20775youtubeToggleToastOff: "YouTube immersion filter disabled.",
20776ankiEnabled: "Enable Anki mining",
20777ankiMineWithJpdb: "Also add to Anki when adding via API",
20778ankiCaptureScreenshot: "Attach context image when possible",
20779ankiConnectUrl: "AnkiConnect URL",
20780ankiDeck: "Anki deck",
20781ankiModel: "Anki note type",
20782mobileAnkiHandoff: "Mobile Anki add-note fallback",
20783ankiTemplateMode: "Anki card template",
20784ankiFrontReading: "Show reading on word-first front",
20785ankiFrontSentence: "Show sentence on word-first front",
20786ankiFrontImage: "Show image on front",
20787wordFirst: "Word first",
20788sentenceFirst: "Sentence first",
20789ankiTags: "Tags",
20790sentenceFirstPreset: "Sentence first preset",
20791wordFirstPreset: "Word first preset",
20792front: "Front",
20793back: "Back",
20794imageAbovePrompt: "Image appears above the prompt when available.",
20795recallHighlightedWord: "Recall the highlighted word from context.",
20796imageOnFront: "Image appears on the front when available.",
20797recallMeaning: "Recall the meaning first.",
20798ankiBackIncludes: "Includes dictionary, kanji, pitch, source, image.",
20799exampleMeaning: "to read",
20800scanAnkiFirst: "Connect Anki first",
20801notMapped: "Not mapped",
20802noScannedFields: "Check AnkiConnect to load this note type's fields.",
20803mappingForNoteType: "Mapping for {model}",
20804currentNoteType: "current note type",
20805ankiFieldMappingSelect: "{role} field",
20806ankiRoleExpression: "Expression",
20807ankiRoleReading: "Reading",
20808ankiRoleMeaning: "Meaning",
20809ankiRoleSentence: "Sentence",
20810ankiRoleAudio: "Word audio",
20811ankiRoleSentenceAudio: "Sentence audio",
20812ankiRoleImage: "Image",
20813testAnki: "Check AnkiConnect",
20814prepareAnki: "Set up Yomu note type",
20815updateAnkiModel: "Update note type",
20816ankiModelUpdateAvailable: 'New fields are ready for "{model}": {fields}.',
20817ankiModelUpdating: "Adding note type fields...",
20818ankiModelUpdated: "Note type updated. Added {fields}.",
20819ankiModelUpToDate: "Note type is up to date.",
20820ankiCheckingConnection: "Checking AnkiConnect at {url}.",
20821ankiMiningDisabledStatus: "Anki mining disabled.",
20822ankiTesting: "Checking AnkiConnect...",
20823ankiPreparing: "Setting up Yomu deck and note type...",
20824ankiScanning: "Reading decks, note types, fields...",
20825ankiScanSummary: "Decks {decks}, types {models}. Best: {model}. {fields}",
20826ankiScanNoModels: "Found {decks} decks. Note types unavailable.",
20827ankiScanFieldSummary: "Fields: {fields}",
20828ankiUnreachable: "Open desktop Anki and check again.",
20829ankiCorsBlocked: 'Add "{origin}" to webCorsOriginList; restart Anki.',
20830ankiSettingsUnreachable: "AnkiConnect not reached.",
20831ankiHostedBridgeMissing: `Enable ${APP_NAME}, refresh, then check again.`,
20832ankiStatusOpenDesktop: "Open desktop Anki",
20833ankiStatusInstallAddon: "Install/enable AnkiConnect",
20834ankiStatusMobileDocs: "Mobile setup docs",
20835ankiStatusUseDesktopUrl: "Use the LAN/Tailscale URL on mobile",
20836ankiStatusEnableUserscript: `Enable installed ${APP_NAME}`,
20837ankiStatusRefreshAndCheck: "Refresh and check",
20838ankiHostedCorsHint: "Add {origin} to webCorsOriginList.",
20839ankiLibraryAdapter: "Existing library adapter",
20840ankiLibraryAdapterStatus: "Scans decks/types and suggests mappings.",
20841ankiLibraryChoices: "Deck and note type",
20842ankiLibraryChoicesHelp: "Pick where mining saves notes.",
20843ankiTemplateSettings: "Yomu card template",
20844ankiTemplateSettingsHelp: "For Yomu note types. Templates stay in Anki.",
20845ankiMappingConfidenceHelp: "Based on fields/samples. Edit weak mappings.",
20846ankiMappingHighConfidence: "High",
20847ankiMappingMediumConfidence: "Medium",
20848ankiMappingLowConfidence: "Low",
20849ankiHelp: "Install AnkiConnect and keep desktop Anki open. If CORS appears, add this site to webCorsOriginList. Mobile handoff creates notes only.",
20850jpdbDefinitionsEnabled: "Show JPDB definitions",
20851...LOCAL_DICTIONARY_STORAGE_COPY.enSettings,
20852dictionarySourcesInitiallyExpanded: "Open sources by default",
20853localDictionaryMaxResults: "Dictionary result limit",
20854cloudSettingsSync: "Google Drive settings sync",
20855cloudSettingsSyncHelp: "Stores your Yomu settings and local SRS progress in Google Drive app data. Dictionaries stay local.",
20856academyAccountSync: "Academy account sync",
20857academyAccountSyncHelp: "Keep Academy SRS progress in sync across the Reader and your signed-in Yomu account. Create or manage your account on the website, then generate a one-time pairing code.",
20858academyAccountManage: "Manage account & pairing code",
20859academyPairingCode: "One-time pairing code",
20860academyPairingCodePlaceholder: "XXXX-XXXX-XXXX-XXXX-XXXX",
20861academyAccountConnect: "Connect",
20862academyAccountSyncNow: "Sync now",
20863academyRecoveryCodeCreate: "Create website recovery code",
20864academyRecoveryCodeCreating: "Creating a one-time website recovery code...",
20865academyRecoveryCodeReady: "Website recovery code: {code}. Enter it in Profile & sync within 10 minutes.",
20866academyRecoveryCodeDone: "Website recovery code created.",
20867academyAccountDisconnect: "Disconnect",
20868academyAccountChecking: "Checking Academy account connection...",
20869academyAccountDisconnected: "Not connected. Academy reviews stay on this device until you connect an account.",
20870academyAccountConnected: "Connected as {name}.",
20871academyAccountConnectedNoName: "Academy account connected.",
20872academyAccountLastSynced: "Last synced {time}.",
20873academyAccountNeverSynced: "Not synced yet.",
20874academyAccountConnectionProblem: "Could not refresh the account status: {message}",
20875academyAccountConnecting: "Connecting and syncing Academy progress...",
20876academyAccountSyncing: "Syncing Academy progress...",
20877academyAccountDisconnecting: "Disconnecting this Reader...",
20878academyPairingCodeRequired: "Enter the one-time pairing code from your Yomu account.",
20879academyAccountConnectedDone: "Academy account connected and progress synced.",
20880academyAccountSyncedDone: "Academy progress synced.",
20881academyAccountDisconnectedDone: "This Reader is disconnected. Local Academy progress is still available.",
20882importSettings: "Import settings JSON",
20883exportSettings: "Export settings JSON",
20884importDictionaries: "Import dictionaries",
20885exportDictionaries: "Export dictionaries",
20886lookupPills: "Lookup pills",
20887lookupPillsHelp: "External links and frequency badges in one order. Local frequency dictionaries replace matching live Jiten/JPDB badges. Tokens: {query}, {word}, {reading}.",
20888parserProvider: "Parsing source",
20889parserProviderLocal: "Local dictionaries (offline)",
20890parserProviderJiten: "Jiten API",
20891parserProviderJpdb: "JPDB API",
20892parserProviderAuto: "Automatic (Jiten/JPDB)",
20893parserProviderHelp: "Local parses with imported dictionaries, offline. Jiten and JPDB always use that API when its key is set. Automatic uses your preferred grading service when both keys are set, otherwise Jiten, then JPDB.",
20894offlineDictionarySetupComplete: "Offline dictionaries installed.",
20895offlineDictionarySetupFailed: "Offline dictionary setup failed. Retry from Settings → Sources.",
20896copiesCurrentWord: "Copies the current word",
20897plaintextHttpLink: "Opens over plaintext HTTP.",
20898lookupPillLabelNumber: "Lookup pill {number} label",
20899lookupUrlTemplate: "Lookup URL template",
20900lookupUrlTemplateNumber: "Pill {number} URL",
20901lookupPillOrder: "Lookup pill order",
20902builtInAction: "Built-in action",
20903recommendedDownloads: "Dictionaries",
20904termDictionaries: "Term dictionaries",
20905kanjiDictionaries: "Kanji dictionaries",
20906pitchDictionaries: "Pitch dictionaries",
20907pronunciationDictionaries: "Pronunciation dictionaries",
20908frequencyDictionaries: "Frequency dictionaries",
20909nameDictionaries: "Name dictionaries",
20910grammarDictionaries: "Grammar dictionaries",
20911exampleDictionaries: "Example sentence dictionaries",
20912thesaurusDictionaries: "Thesauruses",
20913encyclopediaDictionaries: "Encyclopedias",
20914utilityDictionaries: "Utility dictionaries",
20915mirroredDictionaries: "All mirrored dictionaries",
20916mirroredDictionariesSummary: "{count} more dictionaries · {size} total",
20917mirroredDictionarySearch: "Search dictionaries",
20918mirroredDictionarySearchNoResults: "No dictionaries match your search.",
20919mirroredDictionaryLanguageNote: "Dictionaries for reading {language}.",
20920install: "Install",
20921installing: "Installing",
20922queued: "Queued",
20923dictionaryGuide: "Guide",
20924saveAfterInstall: "Save after install",
20925download: "Download",
20926update: "Update",
20927checkingDictionaries: "Checking imported dictionaries...",
20928targetDictionaryUnavailable: "Dictionaries for {language} are not available yet.",
20929targetDictionaryAvailabilityUnavailable: "Dictionary availability could not be checked.",
20930dictionaryDownloading: "Downloading",
20931dictionaryReadingZip: "Reading dictionary ZIP...",
20932dictionaryCheckingIndex: "Checking index...",
20933dictionaryBanksFound: "{count} bank{plural} found.",
20934dictionaryRemovingExisting: "removing old entries",
20935dictionaryReadingBank: "Reading",
20936dictionaryParsingBank: "Parsing",
20937dictionarySavingBank: "Saving",
20938dictionaryImporting: "Importing",
20939importingBundledDictionaries: "Importing bundled dictionaries...",
20940dictionaryImported: "Imported",
20941dictionaryPreparingImport: "Preparing import",
20942dictionaryRecords: "dictionary records",
20943dictionaryEntries: "entries",
20944dictionaryTotal: "total",
20945dictionaryDownloadProgress: "Downloading",
20946dictionaryStatusSummary: "Dicts {dictionaries}, terms {terms}, kanji {kanji}, meta {metadata}",
20947dictionaryStatusUnavailable: "Unavailable.",
20948noLocalDictionariesImported: "No dictionaries imported yet. Start with a term dictionary for definitions.",
20949dictionaryDownloadFailed: "Dictionary download failed.",
20950storageRuntimeUnavailable: "よむ storage is unavailable. Reload the page; if this continues, reinstall よむ.",
20951dictionaryDownloadTimedOut: "Dictionary download timed out.",
20952dictionaryDownloadNotZip: "Download was not a ZIP.",
20953dictionaryDownloadNeedsBridge: "Download needs bridge; else import ZIP.",
20954dictionaryDownloadBlocked: "Download blocked. Import the ZIP.",
20955dictionaryManualDownloadHint: "Enable userscript or import the ZIP.",
20956dictionaryInstallQueueHelp: "Install a term dictionary first for definitions. Pronunciation (IPA), Japanese pitch, and frequency dictionaries add pronunciations, pitch accents, and badges, not normal definition text.",
20957dictionaryInstallQueued: "{dictionary} queued.",
20958dictionaryInstallSaveBlocked: "Import running. Save unlocks when done.",
20959dictionaryImportQueueStatus: "{count} install{plural} running.",
20960dictionaryRemoveConfirm: 'Remove "{dictionary}"?',
20961dictionaryRemoving: "Removing {dictionary}...",
20962dictionaryRemoved: "Removed {dictionary}.",
20963...LOCAL_DICTIONARY_STORAGE_COPY.enImport,
20964dictionaryRecordsImported: "{dictionary}: {records} records.",
20965settingsImported: "Settings imported.",
20966settingsImportedWithDetails: "Settings imported; {details}.",
20967settingsExported: "Settings exported.",
20968restoredStoredChoices: "restored {count} stored choice{plural}",
20969importedDictionaryRecordCount: "imported {count} dictionary record{plural}",
20970dictionaryNoSupportedBanks: "No supported banks found.",
20971dictionaryUnsupportedJson: "Use Dexie, ZIP, or export.",
20972dictionaryZipMissingIndex: "ZIP missing index.json.",
20973localWordSingular: "entry",
20974localWordPlural: "entries",
20975decksLoaded: "Decks are loaded from your JPDB account.",
20976decksUnavailable: "Could not load decks; saved IDs kept.",
20977addApiKeyChooseDecks: "Add your JPDB API key to choose decks.",
20978miningDeck: "Mining deck",
20979neverForgetDeck: "Never forget deck",
20980blacklistDeck: "Blacklist deck",
20981allStudyDecks: "All study decks",
20982savedValue: "Saved: {value}",
20983holdWhileHovering: "Hold while hovering",
20984hoverOpenDelayMs: "Hover open delay (ms)",
20985hoverCloseDelayMs: "Hover close delay (ms)",
20986pressKeys: "Press keys",
20987blankPlainHover: "Blank = hover, no key",
20988openSettings: "Open settings",
20989resizeSettings: "Resize settings",
20990playAudio: "Play audio",
20991playingAudioPreview: `Playing ${APP_NAME}...`,
20992audioPreviewFailed: "Audio preview failed.",
20993audioPlaybackDisabled: "Audio playback is disabled",
20994audioPlaybackDisabledToast: "Audio playback is disabled.",
20995audioPlaybackFailed: "Audio playback failed.",
20996noSentenceToRead: "No sentence to read aloud.",
20997noTextToRead: "No text to read aloud.",
20998jpdbExampleAudioUnavailable: "No JPDB audio is available for this example.",
20999jpdbAudioPlayableFileMissing: "JPDB audio returned no playable file.",
21000jpdbAudioResponseNotPlayable: "JPDB audio was not playable.",
21001audioSourceReturnedNoAudio: "Audio source did not return audio.",
21002audioJsonMissingPlayableUrl: "Audio JSON had no playable URL.",
21003textToSpeechUnavailable: "Text-to-speech is unavailable.",
21004textToSpeechFailed: "Text-to-speech failed.",
21005audioRequest: "Audio request",
21006audioRequestTimedOut: "Audio request timed out.",
21007audioRequestReturnedNonAudioWithType: "Audio request returned non-audio: {type}.",
21008audioUnknownContentType: "an unknown content type",
21009japanesePod101NoAudio: "JapanesePod101 has no audio for this term.",
21010invalidJpdbAudioId: "Invalid JPDB audio id.",
21011couldNotReadAudio: "Could not read audio.",
21012couldNotReadAudioBlob: "Could not read audio blob.",
21013closeDrawer: "Close drawer",
21014closePopup: "Close popup",
21015previousLookupWord: "Previous word",
21016nextLookupWord: "Next word",
21017previousSubtitle: "Previous subtitle",
21018nextSubtitle: "Next subtitle",
21019jumpToCurrentSubtitle: "Jump to current subtitle",
21020pauseVideo: "Pause video",
21021readVideoFrame: "Read video frame (OCR)",
21022readVideoFrameStop: "Stop reading video frames (OCR)",
21023copySubtitle: "Copy subtitle",
21024subtitleFallbackLabel: "Subtitle",
21025subtitlesTitle: "Subtitles",
21026openSubtitlePanel: "Open subtitle panel",
21027closeSubtitlePanel: "Close subtitle panel",
21028subtitleStyle: "Subtitle style",
21029subtitleResetDefaults: "Reset defaults",
21030enableSubtitleAutoHide: "Auto-hide panel while playing",
21031disableSubtitleAutoHide: "Keep panel open while playing",
21032subtitlePanelOptions: "Panel options",
21033searchAnimeSubtitles: "Search anime subtitles",
21034toggleNativeSubtitleBlur: "Toggle native subtitle blur",
21035subtitleTrackDetectedSingular: "1 subtitle track detected",
21036subtitleTracksDetected: "subtitle tracks detected",
21037noSubtitleTracksDetected: "No subtitle tracks detected yet.",
21038resizeTranscriptPanel: "Resize transcript panel",
21039resizeSubtitleTracksPanel: "Resize subtitle tracks panel",
21040subtitlePanelMode: "Mode",
21041subtitleLines: "Lines",
21042shadow: "Shadow",
21043subtitleTracks: "Tracks",
21044subtitleTrackTiming: "Subtitle timing",
21045subtitleOffsetPrevious: "Align previous subtitle to current time",
21046subtitleOffsetNext: "Align next subtitle to current time",
21047subtitleOffsetPreviousShort: "Prev",
21048subtitleOffsetNextShort: "Next",
21049subtitleOffsetEarlier: "Show subtitles 100 ms earlier",
21050subtitleOffsetLater: "Show subtitles 100 ms later",
21051resetSubtitleOffset: "Reset subtitle timing",
21052copySubtitleLine: "Copy subtitle line",
21053subtitleCopyIncludeTranslation: "Copy line translation too",
21054peekSubtitleTranslation: "Show translation",
21055hideSubtitleTranslation: "Hide translation",
21056loadingSubtitleLines: "Loading subtitle lines",
21057waitingForCaptionLines: "Waiting for caption lines",
21058subtitleCurrentLineWillAppear: "Current line appears when captions load.",
21059seekSubtitleLine: "Seek subtitle line",
21060subtitleTracksHint: "Choose a primary track. Use Lines to jump.",
21061autoDetectedTracksWillAppear: "Subtitle tracks appear here.",
21062autoDetectedOptionSingular: "1 subtitle option",
21063autoDetectedOptions: "subtitle options",
21064detected: "Detected",
21065primaryOverlay: "primary overlay",
21066nativeOverlay: "native overlay",
21067unsetPrimarySubtitles: "Unset primary",
21068primarySubtitles: "Primary",
21069unsetNativeSubtitles: "Unset native",
21070nativeSubtitles: "Native",
21071choosePrimarySubtitles: "Choose primary subtitles",
21072transcript: "Transcript",
21073subtitleOptionSingular: "option",
21074subtitleOptionPlural: "options",
21075subtitleLineSingular: "line",
21076subtitleLinePlural: "lines",
21077trackKindPageTrack: "page track",
21078trackKindPageFile: "page file",
21079trackKindYouTubeCaptions: "YouTube captions",
21080youTubeSubtitles: "YouTube subtitles",
21081autoGeneratedSubtitle: "auto-generated",
21082trackKindLoadedFile: "loaded file",
21083trackStatusLoading: "loading",
21084trackStatusWaiting: "waiting for captions",
21085trackStatusFailed: "failed",
21086moveSubtitles: "Move subtitles",
21087moveSubtitlesAccessible: "Move subtitles. Drag, or use the arrow and Page Up/Page Down keys. Press Home or 0 to reset.",
21088moveSubtitleControls: "Subtitle controls. Tap to expand or collapse. Drag, or use the arrow keys, to move. Press Home or 0 to reset.",
21089toggleImageReading: "Toggle image reading",
21090toggleSubtitleOverlay: "Toggle subtitle overlay",
21091toggleYoutubeImmersion: "Toggle YouTube filter",
21092readImagesNow: "Read images now",
21093massReviewVisible: "Mass review visible words (Jiten)",
21094studyReveal: "Study: reveal card",
21095studyRevealAlternate: "Study: reveal card (alternate)",
21096studyUndo: "Study: undo last review",
21097studyPrevious: "Study: previous card",
21098studyPreviousAlternate: "Study: previous card (alternate)",
21099studyNext: "Study: next card",
21100studyNextAlternate: "Study: next card (alternate)",
21101massReviewNoWords: "No due Jiten words on screen.",
21102massReviewNoKey: "Add a Jiten API key to mass review.",
21103massReviewDone: "Reviewed {count} words as Good.",
21104massReviewFailed: "Mass review failed.",
21105adapterStateDisabled: "Off",
21106adapterStateProbing: "Probing",
21107adapterStateUnreachable: "Unreachable",
21108adapterStateConnected: "Connected",
21109adapterStateScanning: "Scanning",
21110adapterStateSuggested: "Mapped",
21111adapterStateStale: "Needs review",
21112adapterStateReady: "Ready",
21113ankiMappingConfidenceHigh: "high match",
21114ankiMappingConfidenceMedium: "fuzzy match",
21115ankiMappingConfidenceLow: "unmapped",
21116ankiMappingStaleField: "saved field missing",
21117...OCR_STATUS_COPY.en,
21118gradeNothing: "Grade NOTHING",
21119gradeSomething: "Grade SOMETHING",
21120gradeHard: "Grade HARD",
21121gradeOkay: "Grade OKAY",
21122gradeEasy: "Grade EASY",
21123gradeFail: "Pass/fail: FAIL",
21124gradePass: "Pass/fail: PASS",
21125helpLinksTitle: "Useful pages",
21126versionAndUpdates: "Version",
21127currentYomuVersion: "Yomu",
21128updateStatusIdle: "Current {current}. Latest check pending.",
21129updateStatusChecking: "Current {current}. Checking latest...",
21130updateStatusCurrent: "Current {current}. Latest {latest}. Up to date.",
21131updateStatusAvailable: "Current {current}. Latest {latest}. Update available.",
21132updateStatusUnknown: "Current {current}. Latest check failed; reinstall if needed.",
21133updateStatusIncomparable: "Current {current}. Latest {latest}. Cannot compare versions; use Update if this install is old.",
21134updateHelpNotesManager: 'Keep one Yomu script enabled. Update opens your userscript manager’s install screen. If the browser shows a blocked-install banner instead, open your extensions page, open the manager’s details, and turn on "Allow user scripts" (or Developer mode), then retry.',
21135updateHelpNotesManagerDashboard: "On Chrome or Edge, Update opens the Tampermonkey dashboard instructions: Utilities → Check for userscript updates. This avoids the browser’s blocked website-install banner.",
21136updateHelpNotesExternalManager: "Keep one Yomu script enabled. Update opens the script source; your userscript app reads it from the open tab to update. If updates stall on iPhone/iPad, open this link in Safari and leave the tab open.",
21137updateHelpNotesNoManager: "No userscript manager was detected here, and browsers block direct script installs — Update opens the install guide with per-browser steps.",
21138updateHelpNotesExtensionStore: "You are running the Yomu browser extension. Update opens your browser’s extension store, where installs update automatically and you can trigger a manual update check.",
21139updateUserscript: "Update",
21140duplicateStatusSingle: "One Yomu runtime active ({kind}).",
21141duplicateStatusUnknown: "Duplicate check unavailable. If Yomu appears twice, disable the older script.",
21142ankiConnectSetupTitle: "AnkiConnect setup",
21143ankiConnectSetupCopy: "Keep desktop Anki open with AnkiConnect enabled. Hosted Study needs AnkiConnect to allow the Yomu origin.",
21144ankiConnectSetupConfig: "Add these origins to AnkiConnect's webCorsOriginList, keeping any existing entries:",
21145ankiConnectSetupMobile: "For phone or iPad, use the desktop computer's LAN or Tailscale URL; localhost on a phone means the phone itself.",
21146ankiConnectSetupBrave: "In Brave, disable Shields for the Study page if local Anki checks are blocked.",
21147helpSupportTitle: "Support よむ",
21148helpSupportCopy: SUPPORT_COPY,
21149helpSupportCopyExtra: SUPPORT_COPY_EXTRA,
21150videoPlayer: "Video Player",
21151pdfReader: "PDF Reader",
21152newTabPage: "Study",
21153github: "GitHub",
21154word: "Word",
21155search: "Search",
21156newTabAddressCopied: "Study address copied.",
21157loading: "Loading...",
21158reveal: "Reveal",
21159revealTranslation: "Reveal translation",
21160immersionExampleControls: "Immersion Kit example controls",
21161exampleSearchLinks: "Example searches",
21162loadingKanjiDetails: "Loading kanji details...",
21163lookupDialog: `${APP_NAME} lookup`,
21164resizeLookupSheet: "Drag to resize lookup sheet, or tap to close",
21165showMiningActions: "Show mining actions",
21166hideMiningActions: "Hide mining actions",
21167...GRADING_SERVICE_COPY.en,
21168jpdbKanjiUpdated: "JPDB kanji updated.",
21169jpdbKanjiUpdateFailedRuntime: "Could not update JPDB kanji. Check kanji reviews.",
21170apiSrsActionsDisabled: "API mining actions are disabled in settings.",
21171addJpdbApiKeyReview: "Add a JPDB API key to review JPDB cards.",
21172addJitenApiKeyReview: "Add a Jiten API key to review Jiten cards.",
21173addBunproApiKeyReview: "Add a Bunpro frontend API token to review Bunpro cards.",
21174addWanikaniApiKeyReview: "Add a WaniKani personal access token to review due WaniKani assignments.",
21175actionFailed: "Action failed.",
21176dictionary: "Dictionary",
21177dictionariesExported: "Dictionaries exported.",
21178local: "Local",
21179dict: "dict",
21180filterStudy: "Study",
21181filterAll: "All",
21182sortFrequency: "Frequency",
21183stateNew: "New",
21184stateLearning: "Learning",
21185stateYoung: "Young",
21186stateMature: "Mature",
21187stateDue: "Due",
21188stateFailed: "Failed",
21189stateKnown: "Known",
21190stateMastered: "Mastered",
21191stateNeverForget: "Never forget",
21192stateSuspended: "Suspended",
21193stateLocked: "Locked",
21194stateBlacklisted: "Blacklisted",
21195stateRedundant: "Redundant",
21196stateFrequent: "Frequent",
21197stateUnparsed: "Unparsed",
21198stateInDeck: "In deck",
21199stateNotInDeck: "Not in deck",
21200ankiReviewSingular: "review",
21201ankiReviewPlural: "reviews",
21202ankiLapseSingular: "lapse",
21203ankiLapsePlural: "lapses",
21204gradeNothingLabel: "Nothing",
21205gradeAgainLabel: "Again",
21206gradeGoodLabel: "Good",
21207gradeSomethingLabel: "Something",
21208gradeHardLabel: "Hard",
21209gradeOkayLabel: "Okay",
21210gradeEasyLabel: "Easy",
21211gradeFailLabel: "Fail",
21212gradePassLabel: "Pass",
21213factKeyword: "Keyword",
21214factType: "Type",
21215factFrequency: "Frequency",
21216factMeaning: "Meaning",
21217factGrade: "Grade",
21218factOldForms: "Old forms",
21219docs: "Docs",
21220factoryReset: "Factory Reset",
21221factoryResetConfirm: "Reset all {appName} data?\n\nDeletes settings, keys, cache, dicts.",
21222factoryResetFailed: "Reset failed.",
21223factoryResetStorageIncomplete: "Reset stopped because not every saved item could be found or deleted. Close other よむ tabs and retry. If it still fails, clear よむ storage in your userscript manager.",
21224factoryResetOtherTabReloading: "よむ reset elsewhere. Reloading...",
21225issues: "Issues",
21226donate: "Donate",
21227discord: "Discord",
21228openOnJpdb: "Open on JPDB",
21229openOnLookup: "Open on {label}",
21230viewOnLookup: "View on {label}",
21231copyWord: "Copy",
21232copyWordTitle: "Copy word",
21233copiedWord: "Copied word.",
21234backToWord: "Back to word",
21235backToKanji: "Back to kanji",
21236previousKanji: "Previous kanji",
21237nextKanji: "Next kanji",
21238openKanjiOnJpdb: "Open kanji on JPDB",
21239strokePractice: "Stroke order + practice",
21240practiceDrawing: "Practice drawing",
21241strokes: "strokes",
21242textTrace: "text trace",
21243hideTrace: "Hide trace",
21244showTrace: "Show trace",
21245clear: "Clear",
21246originStructure: "Component graph",
21247originMapLabel: "2D kanji origin and component map",
21248originShowSubcomponents: "Subcomponents",
21249originShowOutbound: "Outbounds",
21250kanjiAlive: "Kanji Alive",
21251wiktionary: "Wiktionary",
21252radical: "Radical",
21253readingsComponents: "Readings and components",
21254showKanji: "Show kanji",
21255jpdbMnemonic: "JPDB mnemonic",
21256rtkComponentKeywords: "RTK component keywords",
21257onReading: "On",
21258kunReading: "Kun",
21259heisigStory: "Heisig story",
21260heisigComment: "Heisig comment",
21261koohiiStories: "Koohii stories",
21262add: "Add",
21263addToDeck: "Add to deck",
21264deck: "Deck",
21265deckActions: "Deck actions",
21266reviewAddsToDeck: "Reviewing will add new words to",
21267reviewBlockedBlacklisted: "Blacklisted. Unlist before reviewing.",
21268reviewBlockedNeverForget: "Never-forget. Remove before reviewing.",
21269reviewBlockedRedundant: "JPDB marks this redundant.",
21270ankiCardsSuspended: "Suspended in Anki (works like a blacklist).",
21271ankiCardsUnsuspended: "Unsuspended in Anki.",
21272ankiNeverForgetTagAdded: "Tagged yomu-never-forget.",
21273ankiNeverForgetTagRemoved: "Removed yomu-never-forget.",
21274forget: "Forget",
21275never: "Never forget",
21276unlist: "Unlist",
21277blacklist: "Blacklist",
21278vocabularyStatusUpdated: "Vocabulary status updated.",
21279addToAnki: "Add to Anki",
21280sendToMobileAnki: "Send to {app}",
21281ankiAudioFileNotFound: "Anki audio file not found.",
21282ankiAudioPlaybackUnavailable: "Anki audio playback is not available here.",
21283ankiAudioUnavailablePreview: "Audio not available in preview",
21284ankiAudioFilenameLabel: "Anki audio {filename}",
21285ankiStoredFields: "Stored fields",
21286ankiCardDetailsPending: "Matched in Anki. Loading details...",
21287ankiCardDetailsUnavailable: "Matched in Anki. showing cached status.",
21288ankiNewCard: "New card",
21289ankiMatches: "Anki matches",
21290gradeAnkiCardTarget: "Grades Anki card: {target}",
21291gradeJpdbCardTarget: "Grades API SRS card",
21292ankiNoteNotFound: "Anki note not found.",
21293mergeYomu: "Merge Yomu",
21294mergeYomuTitle: "Update matching fields and add Yomu media to this note",
21295editInAnki: "Edit in Anki",
21296keepBothAudio: "Keep both",
21297keepAnkiAudio: "Keep Anki",
21298useYomuAudio: "Use Yomu",
21299lastSeen: "Last seen",
21300unavailable: "Unavailable",
21301openedInAnki: "Opened in Anki.",
21302addedToDeckAndReviewed: "Added to deck and reviewed.",
21303sentToAnki: "Sent to Anki.",
21304openedMobileAnkiHandoff: "Opened Anki handoff. Continue in Anki.",
21305alreadyInAnki: "Already in Anki. Use Edit in Anki instead.",
21306removedFromDeck: "Removed from deck.",
21307addedToDeckToast: "Added to deck.",
21308apiDeckMediaNotSupported: "Media stays in Yomu; no media API.",
21309sentToAnkiWithContextImageAndAudio: "Sent to Anki with image and audio.",
21310sentToAnkiWithContextImage: "Sent to Anki with image.",
21311sentToAnkiWithAudio: "Sent to Anki with audio.",
21312ankiMergeNoNewData: "Anki note already has the Yomu data.",
21313ankiMergeFieldSingular: "field",
21314ankiMergeFieldPlural: "fields",
21315ankiMergeAudio: "audio",
21316ankiMergeImage: "image",
21317ankiMergeComplete: "Merged Yomu data into Anki ({parts}).",
21318ankiHandoffCancelled: "Anki handoff cancelled.",
21319ankiConnectActionFailed: "AnkiConnect action failed.",
21320ankiConnectRequestFailed: "AnkiConnect request failed.",
21321ankiConnectTimedOut: "AnkiConnect timed out.",
21322mobileAnkiReady: "Anki offline. Handoff can create notes.",
21323ankiConnectionReady: "Connected. AnkiConnect is reachable.",
21324ankiConnectedReady: 'Connected. "{deck}" / "{model}" ready.',
21325ankiPromptRecallWord: "Recall the highlighted word.",
21326ankiMeaningHeading: "Meaning",
21327ankiPitchHeading: "Pitch",
21328ankiPartOfSpeechHeading: "Part of speech",
21329ankiLinksHeading: "Links",
21330ankiSourceHeading: "Source",
21331ankiLocalDictionaryStatus: "local dictionary",
21332composedOf: "Composed of",
21333ocrModeAutoToast: "Image OCR automatic.",
21334ocrModeManualToast: "Image OCR on tap or hover.",
21335ocrModeOffToast: "Image OCR off.",
21336subtitleOverlayEnabled: "Subtitle overlay enabled.",
21337subtitleOverlayHidden: "Subtitle overlay hidden.",
21338reviewFailed: "Review failed.",
21339reviewActionsDisabled: "Review actions are disabled in settings.",
21340jpdbLookupFailed: "JPDB lookup failed.",
21341jpdbApiKeyMissingError: "Add a JPDB API key in Settings.",
21342jpdbApiKeyRejectedError: "JPDB rejected the API key. Check it in Settings.",
21343jpdbRateLimitedError: "JPDB is busy. Try again in a moment.",
21344jpdbConnectionCoolingDownError: "JPDB is temporarily unreachable. Try again in a moment.",
21345jpdbRequestTimedOutError: "JPDB took too long to respond. Try again.",
21346jpdbRequestFailedError: "JPDB request failed. Try again.",
21347jpdbDeckStateApiKeyRequired: "Add a JPDB API key to change JPDB deck state.",
21348jitenDeckStateApiKeyRequired: "Add a Jiten API key to change Jiten vocabulary state.",
21349wanikaniAddApiKeyRequired: "Add a WaniKani personal access token to review due assignments.",
21350addedToWanikani: "Recorded on WaniKani.",
21351kanjiDetailsUnavailable: "Kanji details are not available yet.",
21352loadingDictionaryDetails: "Loading dictionary details...",
21353jitenCompositeWords: "Composite words",
21354usedInVocabulary: "Used in vocabulary",
21355exampleSentences: "Example sentences",
21356exampleSourceEmpty: "No examples for this word yet.",
21357exampleSourceEmptyShort: "None yet",
21358exampleSourceLimitedCorpus: "This corpus is small, so many words have no example yet.",
21359exampleSourceUnsupported: "This source has no {language} sentences.",
21360exampleSourceUnsupportedShort: "Other languages",
21361exampleSourceFailed: "Examples did not load.",
21362exampleSourceFailedShort: "Not loaded",
21363exampleSourceRetry: "Try again",
21364exampleSourceAudioPerItem: "Audio plays where the recording is openly licensed.",
21365exampleSourceNoSentenceAudio: "Open {language} sentence audio is not available yet.",
21366exampleSourceNoLicensedAudio: "These sentences came without openly licensed audio.",
21367exampleSourceNoImage: "Scene images are Japanese only for now.",
21368exampleSourceNoTranslation: "No {language} translation yet.",
21369exampleSourceMachineTranslation: "Machine translation",
21370exampleSourceIndirectTranslation: "Translated via another language",
21371exampleSourcePlayAudio: "Play sentence audio",
21372acceptedInputs: "Accepted inputs",
21373relatedWords: "Related words",
21374bunproUsedInVocab: "Used in",
21375relatedGrammar: "Related grammar",
21376antonymWord: "Antonym",
21377bunproCaution: "Caution",
21378bunproStructure: "Structure",
21379playJpdbExampleAudio: "Play JPDB example audio",
21380contextVideo: "Video",
21381contextImage: "Image",
21382contextCurrentPage: "Current page",
21383jpdbKanjiActionMine: "Add",
21384jpdbKanjiActionKnown: "Known",
21385jpdbKanjiActionNeverForget: "Never forget",
21386jpdbKanjiActionForget: "Forget",
21387jpdbKanjiActionBlacklist: "Blacklist",
21388jpdbKanjiActionReview: "Review",
21389noDefinitions: "No enabled definition source returned results.",
21390enabledHeader: "On",
21391labelHeader: "Label",
21392displayName: "Display name",
21393orderHeader: "Order",
21394removeHeader: "Remove",
21395definitionSource: "Definition source",
21396kanjiSection: "Kanji section",
21397dragToReorder: "Drag to reorder",
21398moveUp: "Move up",
21399moveDown: "Move down",
21400remove: "Remove",
21401removeImportedDictionary: "Remove imported dictionary",
21402customAdvanced: "{label} (advanced)",
21403importLocalDefinitionsHelp: "Import Yomitan for local definitions.",
21404metadataDictionariesHelp: "Metadata dictionaries appear as badges or kanji data.",
21405sourceHelpJpdb: "JPDB meanings from the current card.",
21406sourceHelpJiten: "Jiten meanings, examples, and related words.",
21407sourceHelpBunpro: "Bunpro vocabulary and grammar meanings, nuance, and examples.",
21408sourceHelpWanikani: "WaniKani vocabulary meanings, mnemonics, and SRS status for subjects on your account.",
21409sourceHelpAnki: "Matching Anki card content and status.",
21410sourceHelpTranslation: "Sentence translation.",
21411sourceHelpGrammar: "Local grammar hints.",
21412sourceHelpImmersionKit: "Example sentences, images, and audio.",
21413sourceNameImmersionKit: "Immersion Kit",
21414sourceNameAnki: "Anki",
21415sourceNameTranslation: "Translation",
21416sourceNameGrammar: "Grammar",
21417sourceNameStrokePractice: "Stroke practice",
21418sourceNameImportedKanjiDictionaries: "Imported kanji dictionaries",
21419sourceNameWordsUsingKanji: "Related vocabulary",
21420sourceNameJitenKanjiFacts: "Jiten kanji facts",
21421sourceHelpImportedKanjiDictionary: "Imported Yomitan kanji dictionary.",
21422sourceHelpStrokePractice: "Stroke order preview and drawing pad.",
21423sourceHelpReadingsComponents: "JPDB readings, components, and mnemonic.",
21424sourceHelpJitenKanjiFacts: "Jiten kanji facts, frequency, readings, words.",
21425sourceHelpRtk: "RTK keywords, elements, and stories.",
21426sourceHelpWanikaniKanji: "WaniKani kanji meaning/reading mnemonics, level, and SRS status.",
21427sourceHelpImportedKanjiDictionaries: "Imported Yomitan kanji entries.",
21428sourceHelpWordsUsingKanji: "Related vocabulary.",
21429sourceHelpComponentGraph: "Kanji facts, components, radical images.",
21430recommendedJitendex: "Term definitions with examples.",
21431recommendedJmdict: "Core term definitions.",
21432recommendedJmnedict: "Proper names.",
21433recommendedWtyJapaneseJapanese: "Japanese-to-Japanese term definitions.",
21434recommendedPixivLight: "Pixiv terms.",
21435recommendedKanjidic: "Kanji facts.",
21436recommendedJpdbKanji: "JPDB kanji.",
21437recommendedKanjiumPitch: "Pitch accents only; add a term dictionary for definitions.",
21438recommendedJpdbv2Kana: "Recommended frequency badges from JPDB.",
21439recommendedBccwj: "Frequency badges from BCCWJ.",
21440recommendedJiten: "Frequency badges from Jiten.",
21441lines: "Lines",
21442tracks: "Tracks",
21443native: "Native",
21444options: "options",
21445option: "option",
21446line: "line",
21447translation: "Translation",
21448grammar: "Grammar",
21449meaning: "Meaning",
21450readSentenceAloud: "Read sentence aloud",
21451openSectionToTranslate: "Open this section to translate.",
21452translationUnavailable: "Translation unavailable.",
21453translating: "Translating...",
21454...GRAMMAR_UI_COPY.en,
21455interfaceLocalesReady: "Ready now",
21456interfaceLocalesInProgress: "On the way",
21457interfaceLocaleRtlPending: "Right-to-left layout checks are still running",
21458interfaceLocaleTranslationPending: "Translation is still in progress",
21459interfaceLocaleBlockedNote: "These are coming. Each one shows what it is waiting on.",
21460interfaceLocaleReadyCount: "{ready} of {total} interface languages are ready."
21461}
21462};
21463const CARD_STATE_LABEL_KEYS = {
21464new: "stateNew",
21465learning: "stateLearning",
21466young: "stateYoung",
21467mature: "stateMature",
21468known: "stateKnown",
21469mastered: "stateMastered",
21470due: "stateDue",
21471failed: "stateFailed",
21472locked: "stateLocked",
21473"never-forget": "stateNeverForget",
21474blacklisted: "stateBlacklisted",
21475suspended: "stateSuspended",
21476"in-deck": "stateInDeck",
21477"not-in-deck": "stateNotInDeck",
21478redundant: "stateRedundant",
21479frequent: "stateFrequent",
21480unparsed: "stateUnparsed"
21481};
21482function parseUiCopyTable(rows) {
21483const copy = {};
21484rows.trim().split("\n").forEach((row) => {
21485const tab = row.indexOf("	");
21486if (tab < 0) {
21487const key = row.trim();
21488if (key) copy[key] = "";
21489return;
21490}
21491if (tab === 0) return;
21492copy[row.slice(0, tab)] = row.slice(tab + 1).replaceAll("{APP_NAME}", APP_NAME);
21493});
21494return copy;
21495}
21496const JA_COPY = {
21497gamingChooseTargetTitle: "読みたい言語を選んでください",
21498gamingChooseTargetBody: "言語を選ぶと、画面上の対応言語を読み取れるようになります。",
21499gamingChooseTargetAction: "言語を選ぶ",
21500gamingTargetRequired: "画面をキャプチャする前に、読みたい言語を選んでください。",
21501...parseUiCopyTable(String.raw`
21502interfaceLocalesReady	今すぐ使えます
21503interfaceLocalesInProgress	準備中
21504interfaceLocaleRtlPending	右から左へのレイアウト確認が進行中です
21505interfaceLocaleTranslationPending	翻訳が進行中です
21506interfaceLocaleBlockedNote	これらの言語も準備中です。それぞれ何を待っているか表示します。
21507interfaceLocaleReadyCount	表示言語{total}件のうち{ready}件が使えます。
21508settingsTitle	{APP_NAME} 設定
21509welcomeLabel	{APP_NAME} ようこそ
21510onboardingEyebrow	{language}がある場所ならどこでも
21511onboardingCopy	本文、字幕、画像の{language}をタップ可能にします。
21512onboardingLanguage	表示言語
21513onboardingOutputLanguage	定義・翻訳の言語(出力)
21514onboardingTargetLanguage	ページで読む言語(対象)
21515onboardingChooseTarget	学習する言語を選ぶ…
21516onboardingTargetRequired	続ける前に学習する言語を選んでください。
21517onboardingUnselectedTargetName	学習中の言語
21518onboardingAccentColor	アクセントカラー
21519customAccentColor	カスタムカラー
21520onboardingImmersionOptions	没入設定の初期値
21521onboardingInstallOfflineDictionaries	この言語のスターター辞書をダウンロード
21522studyTargetReadinessFull	よむの全機能
21523studyTargetReadinessReadingOnly	読んで、集めて、復習
21524studyTargetReadinessPlanned	準備中
21525studyTargetReadinessFullReason	ピッチアクセント、漢字、文法まですべて使えます。
21526studyTargetReadinessReadingOnlyReason	読解、検索、マイニング、復習が使えます。
21527studyTargetReadinessPlannedReason	対応を準備中です。
21528offlineDictionarySetupComplete	オフライン辞書をインストールしました。
21529offlineDictionarySetupFailed	オフライン辞書のセットアップに失敗しました。設定→ソースから再試行してください。
21530onboardingHoverShortcut	ホバー検索の修飾キー
21531onboardingAddApiKey	APIキーを追加
21532onboardingUseWithoutApiKey	APIキーなしで使う
21533closeOnboarding	ようこそ画面を閉じる
21534featureText	テキスト
21535featureTextBody	スキャンした{language}をホバー/タップできます。
21536featureImages	画像
21537featureImagesBody	画像をタップして読み取れます。
21538featureVideo	動画
21539featureVideoBody	字幕内の語もタップできます。
21540featureControl	調整
21541featureControlBody	機能、キー、色を調整できます。
21542featureStudy	学習
21543featureStudyBody	学習ページで単語と文字を復習。
21544featureGame	ゲーム
21545featureGameBody	Yomuアプリをインストールすると、ゲームやPC上のどこでも使えます。
21546automatic	自動
21547english	英語
21548japanese	日本語
21549settings	設定
21550settingsSaved	設定を保存しました。
21551settingsSaveFailed	設定を保存できませんでした。
21552settingsCompanionUnavailable	設定を開けませんでした。
21553firefoxAuthenticationInfoDenied	Firefoxの許可がなかったため、アカウント情報は保存しませんでした。
21554firefoxAuthenticationInfoExtensionPageRequired	Firefoxでこの許可を求めるにはYomuのページが必要です。学習ページを開き、設定からアカウント情報を追加してください。
21555dictionaries	辞書
21556sources	ソース
21557localWordSingular	項目
21558localWordPlural	項目
21559kanji	漢字
21560audio	音声
21561front	表面
21562back	裏面
21563newTabPage	学習
21564word	単語
21565search	検索
21566switchToLightTheme	ライトテーマに切り替え
21567switchToDarkTheme	ダークテーマに切り替え
21568newTabAddressCopied	学習ページのアドレスをコピーしました。
21569loading	読み込み中...
21570reveal	表示
21571revealTranslation	翻訳を表示
21572immersionExampleControls	イマージョンキット例文の操作
21573exampleSearchLinks	例文検索リンク
21574loadingKanjiDetails	漢字情報を読み込み中...
21575lookupDialog	{APP_NAME}検索
21576resizeLookupSheet	検索シートをリサイズ。タップで閉じる
21577showMiningActions	マイニング操作を表示
21578hideMiningActions	マイニング操作を隠す
21579closeDrawer	ドロワーを閉じる
21580copiedWord	単語をコピーしました。
21581jpdbKanjiUpdated	JPDB漢字を更新しました。
21582jpdbKanjiUpdateFailedRuntime	JPDB漢字を更新できません。
21583apiSrsActionsDisabled	設定でAPI採掘操作が無効です。
21584addJpdbApiKeyReview	JPDBレビューにはAPIキーが必要です。
21585addJitenApiKeyReview	JitenレビューにはAPIキーが必要です。
21586addBunproApiKeyReview	Bunproレビューにはfrontend_api_tokenが必要です。
21587addWanikaniApiKeyReview	期限が来たWaniKaniの課題を復習するには、パーソナルアクセストークンを追加してください。
21588actionFailed	操作に失敗しました。
21589noDefinitions	有効な定義ソースから結果が返りませんでした。
21590dictionary	辞書
21591dictionariesExported	辞書をエクスポートしました。
21592saveAfterInstall	インストール後に保存
21593dictionaryDownloading	ダウンロード中
21594dictionaryReadingZip	辞書ZIPを読み取り中...
21595dictionaryCheckingIndex	インデックス確認中...
21596dictionaryBanksFound	{count}件のバンクを検出
21597dictionaryRemovingExisting	既存項目を削除中
21598dictionaryReadingBank	読み取り中
21599dictionaryParsingBank	解析中
21600dictionarySavingBank	保存中
21601dictionaryImporting	インポート中
21602importingBundledDictionaries	同梱辞書をインポート中...
21603dictionaryImported	インポート済み
21604dictionaryPreparingImport	インポート準備中
21605dictionaryRecords	辞書レコード
21606dictionaryEntries	ä»¶
21607dictionaryTotal	合計
21608dictionaryDownloadProgress	辞書をダウンロード中
21609dictionaryStatusSummary	辞書{dictionaries}、語{terms}、漢字{kanji}、メタ{metadata}
21610dictionaryStatusUnavailable	辞書状態を取得不可。
21611targetDictionaryUnavailable	{language}の辞書はまだ利用できません。
21612targetDictionaryAvailabilityUnavailable	辞書の提供状況を確認できませんでした。
21613noLocalDictionariesImported	辞書は未追加です。まず定義用の語句辞書を追加してください。
21614dictionaryDownloadFailed	辞書のダウンロードに失敗しました。
21615storageRuntimeUnavailable	よむの保存機能を利用できません。ページを再読み込みし、解決しない場合はよむを再インストールしてください。
21616dictionaryDownloadTimedOut	辞書のダウンロードがタイムアウトしました。
21617dictionaryDownloadNotZip	ダウンロード結果がZIPではありません。
21618dictionaryDownloadNeedsBridge	ブリッジが必要です。失敗時はZIPを追加。
21619dictionaryDownloadBlocked	ダウンロード不可。ZIPを追加。
21620dictionaryManualDownloadHint	ユーザースクリプト有効化かZIP追加。
21621dictionaryInstallQueueHelp	まず定義用の語句辞書をインストールしてください。発音(IPA)/日本語ピッチ/頻度辞書は発音、ピッチアクセント、バッジを追加しますが、通常の定義文は追加しません。
21622dictionaryInstallQueued	{dictionary}待機中。
21623dictionaryInstallSaveBlocked	インポート中。完了後に保存できます。
21624dictionaryImportQueueStatus	{count}件インストール中。完了後に保存。
21625dictionaryRemoveConfirm	「{dictionary}」を削除?
21626dictionaryRemoving	{dictionary}を削除中...
21627dictionaryRemoved	{dictionary}を削除しました。
21628${Object.entries(LOCAL_DICTIONARY_STORAGE_COPY.jaImport).map(([key, value]) => `${key}	${value}`).join("\n")}
21629dictionaryRecordsImported	{dictionary}: {records}ä»¶
21630settingsImported	設定をインポートしました。
21631settingsImportedWithDetails	設定をインポートしました。{details}
21632settingsExported	設定をエクスポートしました。
21633restoredStoredChoices	保存済み選択肢を{count}件復元
21634importedDictionaryRecordCount	辞書レコードを{count}件インポート
21635dictionaryNoSupportedBanks	対応辞書バンクがありません。
21636dictionaryUnsupportedJson	Dexie、ZIP、出力を使ってください。
21637dictionaryZipMissingIndex	ZIPにindex.jsonがありません。
21638local	ローカル
21639dict	辞書
21640scanPage	ページをスキャン
21641noUnscannedJapaneseText	未スキャンの{language}テキストはありません。
21642jpdbScanFailed	ページスキャンに失敗しました。
21643pageCoverageSummary	{percent}%・{known}/{total}・新{unknown}・i+1 {iPlusOne}
21644noImmersionExamplesCompact	例文なし
21645kanjiAlive	カンジアライブ
21646wiktionary	ウィクショナリー
21647lines	行
21648tracks	トラック
21649native	母語
21650options	ä»¶
21651option	ä»¶
21652line	行
21653filterStudy	学習
21654filterAll	すべて
21655sortFrequency	頻度
21656stateNew	新規
21657stateLearning	学習中
21658stateYoung	若い
21659stateMature	成熟
21660stateDue	復習予定
21661stateFailed	失敗
21662stateKnown	既知
21663stateMastered	習得済み
21664stateNeverForget	忘れない
21665jpdbAndJitenApiKeysConfigured	JitenとJPDBキーあり。
21666stateSuspended	停止中
21667stateLocked	ロック中
21668stateBlacklisted	ブラックリスト
21669stateRedundant	重複
21670stateFrequent	頻出
21671stateUnparsed	未解析
21672stateInDeck	デッキ内
21673stateNotInDeck	デッキ外
21674gradeAnkiCardTarget	Ankiカードを採点: {target}
21675gradeJpdbCardTarget	API SRSカードを採点
21676ankiReviewSingular	回復習
21677ankiReviewPlural	回復習
21678ankiLapseSingular	回失敗
21679ankiLapsePlural	回失敗
21680gradeNothingLabel	全然
21681gradeAgainLabel	もう一度
21682gradeGoodLabel	良い
21683gradeSomethingLabel	少し
21684gradeHardLabel	難しい
21685gradeOkayLabel	OK
21686gradeEasyLabel	簡単
21687gradeFailLabel	失敗
21688gradePassLabel	合格
21689gradeNothing	採点: 全然
21690gradeSomething	採点: 少し
21691gradeHard	採点: 難しい
21692gradeOkay	採点: OK
21693gradeEasy	採点: 簡単
21694gradeFail	合否: 失敗
21695gradePass	合否: 合格
21696studyReveal	学習: カードを表示
21697studyRevealAlternate	学習: カードを表示(代替)
21698studyUndo	学習: 直前のレビューを取り消す
21699studyPrevious	学習: 前のカード
21700studyPreviousAlternate	学習: 前のカード(代替)
21701studyNext	学習: 次のカード
21702studyNextAlternate	学習: 次のカード(代替)
21703factKeyword	キーワード
21704factType	種類
21705factFrequency	頻度
21706factMeaning	意味
21707factGrade	学年
21708factOldForms	旧字体
21709noSimilarWords	追加の単語は見つかりませんでした。
21710loadingExamples	例文を読み込み中...
21711immersionKitRateLimited	Immersion Kit制限中。あとで再試行。
21712immersionKitRequest	Immersion Kitリクエスト
21713immersionKitRequestFailed	Immersion Kitリクエストに失敗しました。
21714immersionKitRequestFailedWithStatus	Immersion Kitリクエストに失敗しました({status})。
21715immersionKitRequestTimedOut	Immersion Kitリクエストがタイムアウトしました。
21716immersionKitSearchBlocked	Immersion Kit検索がブロック中です。CORSを設定してください。
21717immersionKitMediaRequest	メディアリクエスト
21718immersionKitMediaRequestFailed	メディアリクエストに失敗しました。
21719immersionKitMediaRequestFailedWithStatus	メディアリクエストに失敗しました({status})。
21720immersionKitMediaRequestTimedOut	メディアリクエストがタイムアウトしました。
21721immersionKitMediaRequestReturnedNonMedia	メディアリクエストがエラードキュメントを返しました。
21722immersionKitNoMediaCandidate	読み込めるメディア候補なし。
21723nadeshikoRequest	Nadeshikoリクエスト
21724nadeshikoRequestFailed	Nadeshikoリクエストに失敗しました。
21725nadeshikoRequestFailedWithStatus	Nadeshikoリクエストに失敗しました({status})。
21726nadeshikoRequestTimedOut	Nadeshikoリクエストがタイムアウトしました。
21727previousExample	前の例文
21728nextExample	次の例文
21729playExampleAudio	例文音声を再生
21730openOnJpdb	JPDBで開く
21731openOnLookup	{label}で開く
21732viewOnLookup	{label}で見る
21733copyWord	コピー
21734copyWordTitle	単語をコピー
21735backToWord	単語に戻る
21736backToKanji	漢字に戻る
21737previousKanji	前の漢字
21738nextKanji	次の漢字
21739openKanjiOnJpdb	JPDBで漢字を開く
21740playAudio	音声を再生
21741audioPlaybackDisabled	音声再生は無効です
21742audioPlaybackDisabledToast	音声再生は無効です。
21743audioPlaybackFailed	音声の再生に失敗しました。
21744noSentenceToRead	読み上げる例文がありません。
21745noTextToRead	読み上げるテキストがありません。
21746jpdbExampleAudioUnavailable	この例文にJPDB音声なし。
21747jpdbAudioPlayableFileMissing	JPDB音声に再生ファイルなし。
21748jpdbAudioResponseNotPlayable	JPDB音声は再生不可。
21749audioSourceReturnedNoAudio	音声ソースに音声なし。
21750audioJsonMissingPlayableUrl	音声JSONに再生URLなし。
21751textToSpeechUnavailable	読み上げを利用できません。
21752textToSpeechFailed	読み上げに失敗しました。
21753audioRequest	音声リクエスト
21754audioRequestTimedOut	音声リクエストがタイムアウトしました。
21755audioRequestReturnedNonAudioWithType	音声ではない応答です: {type}。
21756audioUnknownContentType	不明なコンテンツ種別
21757japanesePod101NoAudio	JapanesePod101に音声なし。
21758invalidJpdbAudioId	JPDB音声IDが無効です。
21759couldNotReadAudio	音声を読み取れませんでした。
21760couldNotReadAudioBlob	音声データを読み取れませんでした。
21761previousSubtitle	前の字幕
21762nextSubtitle	次の字幕
21763jumpToCurrentSubtitle	現在の字幕へ移動
21764pauseVideo	動画を一時停止
21765readVideoFrame	動画フレームを読み取る(OCR)
21766readVideoFrameStop	動画フレームの読み取りを停止(OCR)
21767copySubtitle	字幕をコピー
21768subtitleFallbackLabel	字幕
21769subtitlesTitle	字幕
21770openSubtitlePanel	字幕パネルを開く
21771closeSubtitlePanel	字幕パネルを閉じる
21772subtitleStyle	字幕スタイル
21773subtitleResetDefaults	標準に戻す
21774enableSubtitleAutoHide	再生中はパネルを自動で隠す
21775disableSubtitleAutoHide	再生中もパネルを開いたままにする
21776subtitlePanelOptions	パネル設定
21777searchAnimeSubtitles	アニメ字幕を検索
21778toggleNativeSubtitleBlur	母語字幕のぼかしを切り替え
21779subtitleTrackDetectedSingular	字幕トラックを1件検出
21780subtitleTracksDetected	件の字幕トラックを検出
21781noSubtitleTracksDetected	字幕トラックは未検出です。
21782resizeTranscriptPanel	文字起こしパネルのサイズ変更
21783resizeSubtitleTracksPanel	字幕トラックパネルのサイズ変更
21784subtitlePanelMode	表示
21785subtitleLines	行
21786shadow	シャドー
21787subtitleTracks	トラック
21788subtitleTrackTiming	字幕タイミング
21789subtitleOffsetPrevious	前の字幕を現在時刻に合わせる
21790subtitleOffsetNext	次の字幕を現在時刻に合わせる
21791subtitleOffsetPreviousShort	前
21792subtitleOffsetNextShort	次
21793subtitleOffsetEarlier	字幕を100ミリ秒早く表示
21794subtitleOffsetLater	字幕を100ミリ秒遅く表示
21795resetSubtitleOffset	字幕タイミングをリセット
21796copySubtitleLine	字幕行をコピー
21797subtitleCopyIncludeTranslation	行コピー時に翻訳も含める
21798peekSubtitleTranslation	翻訳を表示
21799hideSubtitleTranslation	翻訳を隠す
21800loadingSubtitleLines	字幕行を読み込み中
21801waitingForCaptionLines	字幕行を待機中
21802subtitleCurrentLineWillAppear	字幕が来ると現在行を表示します。
21803seekSubtitleLine	字幕行へ移動
21804subtitleTracksHint	主字幕を選び、「行」で移動。
21805autoDetectedTracksWillAppear	字幕トラックはここに出ます。
21806autoDetectedOptionSingular	字幕オプション1件
21807autoDetectedOptions	件の字幕オプション
21808detected	検出済み
21809primaryOverlay	主字幕オーバーレイ
21810nativeOverlay	母語オーバーレイ
21811unsetPrimarySubtitles	主字幕を解除
21812primarySubtitles	主字幕
21813unsetNativeSubtitles	母語を解除
21814nativeSubtitles	母語
21815choosePrimarySubtitles	主字幕を選択
21816transcript	文字起こし
21817subtitleOptionSingular	ä»¶
21818subtitleOptionPlural	ä»¶
21819subtitleLineSingular	行
21820subtitleLinePlural	行
21821trackKindPageTrack	ページ内トラック
21822trackKindPageFile	ページ内ファイル
21823trackKindYouTubeCaptions	YouTube字幕
21824youTubeSubtitles	YouTube字幕
21825autoGeneratedSubtitle	自動生成
21826trackKindLoadedFile	読み込んだファイル
21827trackStatusLoading	読み込み中
21828trackStatusWaiting	字幕待機中
21829trackStatusFailed	失敗
21830showKanji	漢字を表示
21831strokePractice	筆順と練習
21832practiceDrawing	手書き練習
21833strokes	画
21834textTrace	筆順ガイド
21835hideTrace	ガイドを隠す
21836showTrace	ガイドを表示
21837clear	クリア
21838originStructure	部品グラフ
21839originMapLabel	2D漢字由来・部品マップ
21840originShowSubcomponents	下位部品
21841originShowOutbound	派生先
21842radical	部首
21843readingsComponents	読みと部品
21844jpdbMnemonic	JPDBの覚え方
21845rtkComponentKeywords	RTK部品キーワード
21846onReading	音
21847kunReading	訓
21848heisigStory	Heisigストーリー
21849heisigComment	Heisigコメント
21850koohiiStories	Koohiiストーリー
21851add	追加
21852addToDeck	デッキに追加
21853deck	デッキ
21854deckActions	デッキ操作
21855reviewAddsToDeck	レビューすると新しい単語を追加します:
21856reviewBlockedBlacklisted	ブラックリスト入りです。解除するとレビューできます。
21857reviewBlockedNeverForget	「忘れない」設定です。解除するとレビューできます。
21858reviewBlockedRedundant	JPDBで冗長のためレビューできません。
21859ankiCardsSuspended	Ankiで保留にしました。
21860ankiCardsUnsuspended	Ankiの保留を解除しました。
21861ankiNeverForgetTagAdded	Ankiにyomu-never-forgetタグを付けました。
21862ankiNeverForgetTagRemoved	Ankiのyomu-never-forgetタグを外しました。
21863forget	忘れる
21864never	忘れない
21865unlist	解除
21866blacklist	ブラックリスト
21867vocabularyStatusUpdated	語彙状態を更新しました。
21868addToAnki	Ankiに追加
21869sendToMobileAnki	{app}へ送る
21870ankiAudioFileNotFound	Anki音声ファイルが見つかりません。
21871ankiAudioPlaybackUnavailable	ここではAnki音声を再生できません。
21872ankiAudioUnavailablePreview	プレビューで音声を利用できません
21873ankiAudioFilenameLabel	Anki 音声 {filename}
21874ankiStoredFields	保存フィールド
21875ankiCardDetailsPending	Ankiで一致。カード詳細を読み込み中...
21876ankiCardDetailsUnavailable	Ankiで一致。キャッシュ状態を表示します。
21877ankiNewCard	新規カード
21878ankiMatches	Ankiの一致
21879ankiNoteNotFound	Ankiノートが見つかりません。
21880ankiHandoffCancelled	Ankiへの受け渡しがキャンセルされました。
21881ankiConnectActionFailed	AnkiConnectの操作に失敗しました。
21882ankiConnectRequestFailed	AnkiConnectリクエストに失敗しました。
21883ankiConnectTimedOut	AnkiConnectがタイムアウトしました。
21884ankiHostedCorsHint	webCorsOriginListに{origin}を追加してください。
21885mobileAnkiReady	Anki未接続。受け渡しでカード作成できます。
21886ankiConnectionReady	接続しました。AnkiConnectに到達できます。
21887ankiConnectedReady	接続済み。「{deck}」/「{model}」準備完了。
21888ankiPromptRecallWord	ハイライトされた単語を思い出してください。
21889ankiMeaningHeading	意味
21890ankiPitchHeading	ピッチ
21891ankiPartOfSpeechHeading	品詞
21892ankiLinksHeading	リンク
21893ankiSourceHeading	出典
21894ankiLocalDictionaryStatus	ローカル辞書
21895mergeYomu	Yomuを統合
21896mergeYomuTitle	一致フィールドを更新し、Yomuメディアを追加
21897editInAnki	Ankiで編集
21898keepBothAudio	両方残す
21899keepAnkiAudio	Ankiを残す
21900useYomuAudio	Yomuを使う
21901lastSeen	最後に見た場所
21902unavailable	利用不可
21903openedInAnki	Ankiで開きました。
21904addedToDeckAndReviewed	デッキに追加してレビューしました。
21905sentToAnki	Ankiに送信しました。
21906openedMobileAnkiHandoff	モバイルAnki受け渡しを開きました。
21907alreadyInAnki	すでにAnkiにあります。
21908removedFromDeck	デッキから削除しました。
21909addedToDeckToast	デッキに追加しました。
21910apiDeckMediaNotSupported	メディアはYomuに残ります。
21911sentToAnkiWithContextImageAndAudio	画像と音声付きでAnkiに送信しました。
21912sentToAnkiWithContextImage	画像付きでAnkiに送信しました。
21913sentToAnkiWithAudio	音声付きでAnkiに送信しました。
21914ankiMergeNoNewData	Yomuデータは反映済みです。
21915ankiMergeFieldSingular	フィールド
21916ankiMergeFieldPlural	フィールド
21917ankiMergeAudio	音声
21918ankiMergeImage	画像
21919ankiMergeComplete	YomuデータをAnkiに統合しました ({parts})。
21920composedOf	構成語
21921ocrModeAutoToast	画像OCRを自動にしました。
21922ocrModeManualToast	画像OCRをタップ/ホバーにしました。
21923ocrModeOffToast	画像OCRをオフにしました。
21924subtitleOverlayEnabled	字幕オーバーレイを有効にしました。
21925subtitleOverlayHidden	字幕オーバーレイを非表示にしました。
21926reviewFailed	レビューに失敗しました。
21927reviewActionsDisabled	設定でレビュー操作が無効です。
21928jpdbLookupFailed	JPDB検索に失敗しました。
21929jpdbApiKeyMissingError	設定でJPDB APIキーを追加してください。
21930jpdbApiKeyRejectedError	JPDBがAPIキーを拒否しました。設定でキーを確認してください。
21931jpdbRateLimitedError	JPDBへのリクエストが多すぎます。しばらくしてからもう一度お試しください。
21932jpdbConnectionCoolingDownError	JPDBに一時的に接続できません。しばらくしてからもう一度お試しください。
21933jpdbRequestTimedOutError	JPDBからの応答に時間がかかりすぎました。もう一度お試しください。
21934jpdbRequestFailedError	JPDBへのリクエストに失敗しました。もう一度お試しください。
21935jpdbDeckStateApiKeyRequired	JPDBデッキ変更にはAPIキーが必要です。
21936jitenDeckStateApiKeyRequired	Jiten状態変更にはAPIキーが必要です。
21937wanikaniAddApiKeyRequired	期限が来た課題を復習するには、WaniKaniのパーソナルアクセストークンを追加してください。
21938addedToWanikani	WaniKaniに記録しました。
21939kanjiDetailsUnavailable	漢字情報はまだ利用できません。
21940loadingDictionaryDetails	辞書詳細を読み込み中...
21941jitenCompositeWords	複合語
21942usedInVocabulary	使われる単語
21943exampleSentences	例文
21944exampleSourceEmpty	この語の例文はまだありません。
21945exampleSourceEmptyShort	例文なし
21946exampleSourceLimitedCorpus	コーパスが小さいため、例文がまだない語もあります。
21947exampleSourceUnsupported	この情報源に{language}の例文はありません。
21948exampleSourceUnsupportedShort	他言語のみ
21949exampleSourceFailed	例文を読み込めませんでした。
21950exampleSourceFailedShort	読み込み失敗
21951exampleSourceRetry	もう一度試す
21952exampleSourceAudioPerItem	公開ライセンスの録音がある例文では音声を再生できます。
21953exampleSourceNoSentenceAudio	{language}の文音声は公開ライセンスのものがまだありません。
21954exampleSourceNoLicensedAudio	公開ライセンスの音声が付いていない例文です。
21955exampleSourceNoImage	場面画像は今のところ日本語のみです。
21956exampleSourceNoTranslation	{language}の訳はまだありません。
21957exampleSourceMachineTranslation	機械翻訳
21958exampleSourceIndirectTranslation	別の言語を経由した訳
21959exampleSourcePlayAudio	例文の音声を再生
21960acceptedInputs	入力として認められる表現
21961relatedWords	関連語
21962bunproUsedInVocab	使われている単語
21963relatedGrammar	関連文法
21964antonymWord	対義語
21965bunproCaution	注意
21966bunproStructure	構造
21967playJpdbExampleAudio	JPDB例文音声を再生
21968kanjiDictionaries	漢字辞書
21969sourceNameWordsUsingKanji	関連語彙
21970contextVideo	動画
21971contextImage	画像
21972contextCurrentPage	現在のページ
21973jpdbKanjiActionMine	追加
21974jpdbKanjiActionKnown	既知
21975jpdbKanjiActionNeverForget	忘れない
21976jpdbKanjiActionForget	忘れる
21977jpdbKanjiActionBlacklist	ブラックリスト
21978jpdbKanjiActionReview	レビュー
21979immersionKit	イマージョンキット
21980translation	翻訳
21981grammar	文法
21982meaning	意味
21983readSentenceAloud	文を読み上げ
21984openSectionToTranslate	開くと翻訳します。
21985translationUnavailable	翻訳を利用できません。
21986translating	翻訳中...
21987`),
21988...GRAMMAR_UI_COPY.ja,
21989...SETTINGS_RECOVERY_COPY.ja,
21990...PRACTICE_SESSION_COPY.ja,
21991...SAVE_WAIT_COPY.ja,
21992...GRADING_SERVICE_COPY.ja,
21993...COLLECTION_COPY.ja,
21994...OCR_STATUS_COPY.ja
21995};
21996const JA_SETTINGS_COPY = {
21997accountSettingsTrustedSurfaceTitle: "Studyで設定を開く",
21998accountSettingsTrustedSurfaceHelp: "このページは自身の入力欄を読み書きできるため、よむは設定、アカウント情報、インポート、復旧コードをここに表示しません。よむが管理するStudyページで安全に編集・保存してください。",
21999openAccountSettingsTrustedSurface: "Studyの設定を開く",
22000onboardingTrustedSurfaceEyebrow: "Studyで初期設定を完了",
22001onboardingTrustedSurfaceCopy: "このウェブサイトは、ここに表示された内容を変更できます。よむが管理するStudyページで学習言語と設定を安全に選んでください。",
22002openOnboardingTrustedSurface: "Studyで初期設定を続ける",
22003...parseUiCopyTable(String.raw`
22004settingsTitle	{APP_NAME} 設定
22005settingsSections	設定セクション
22006settingsSearch	設定を検索
22007settingsSearchPlaceholder	設定を検索
22008settingsSearchNoResults	一致なし。
22009save	保存
22010cancel	キャンセル
22011show	表示
22012hide	隠す
22013appearance	外観
22014reading	読解
22015sources	ソース
22016backupSync	バックアップと同期
22017backupSyncHelp	Yomuの設定を保存・移行できます。設定をJSONでエクスポート/インポート、辞書のバックアップ、Google Drive同期に対応しています。
22018media	メディア
22019mining	採掘
22020shortcuts	ショートカット
22021help	ヘルプ
22022reader	リーダー
22023images	画像テキスト (OCR)
22024video	動画
22025youTube	YouTube
22026anki	Anki
22027jpdb	JPDB
22028api	API
22029apiCredential	APIキー
22030apiCredentialJpdb	JPDB APIキー
22031apiCredentialJiten	Jiten APIキー
22032apiCredentialBunpro	Bunpro frontend API token
22033apiCredentialWanikani	WaniKaniパーソナルアクセストークン
22034wanikaniTokenHelp	WaniKaniでread/write権限のパーソナルアクセストークンを作成し、ここに貼り付けてください。ブラウザ内にのみ保存され、プロキシを経由せずapi.wanikani.comへ直接送信され、ログに残ることはありません。
22035apiCredentialBunproLegacy	Bunpro APIキー
22036apiKey	APIキー
22037jitenApiKey	Jiten APIキー
22038apiAccess	APIアクセス
22039storedCredentialPlaceholder	保存済み — 変更する場合のみ入力
22040clearStoredCredential	保存済みの認証情報を削除
22041apiAccessHelp	各サービスの認証情報を設定します。Bunproに必要なのはフロントエンドトークンだけです。Bunpro設定から取り込み、パスワードと同様に扱ってください。保存時点では未確認です。Academyの復習はアカウントなしでも使えます。
22042jpdbSettings	JPDB設定
22043jitenSettings	Jiten設定
22044bunproSettings	Bunpro設定
22045wanikaniSettings	WaniKani設定
22046jpdbApiKeyConfigured	JPDBキーあり。
22047jpdbConnected	JPDBに接続しました。
22048jpdbAndJitenConnected	JitenとJPDBに接続しました。
22049jpdbConnectionFailed	JPDBキーが無効か接続不可です。
22050statusReady	準備完了
22051statusAttention	設定が必要
22052statusError	エラー
22053disabledControlDescription	別設定で制御中。
22054jpdbMiningEnabled	APIの復習・デッキ変更を許可
22055bunproMiningEnabled	Bunproの復習・採掘を許可
22056wanikaniReviewEnabled	WaniKaniの復習を許可(期限が来た課題のã
22056¿)
22057wanikaniGradeMappingHelp	よむの採点結果はWaniKaniの正誤カウントに変換されます。Okay、Good、Easyは正解として送信します。Okay未満は意味を1回不正解として送信し、ラジカル以外では読みも1回不正解として送信します。
22058yomuLocalSrsEnabled	Academyを有効化
22059addToForq	JPDB追加時にforqにもコピー
22060enableReviews	復習ボタンを表示
22061reviewRatingScale	復習評価の段階
22062gradeTargetSelector	採点先
22063gradeTargetBoth	両方
22064gradeTargetJpdb	JPDBを採点
22065gradeTargetJiten	Jitenを採点
22066gradeTargetBunpro	Bunproを採点
22067gradeTargetWanikani	WaniKaniを採点
22068gradeTargetYomuLocal	Academyに記録
22069gradeTargetAnki	Ankiカードを採点: {target}
22070gradeTargetJpdbAndAnki	JPDB + Ankiカードを採点: {target}
22071gradeTargetJitenAndAnki	Jiten + Ankiカードを採点: {target}
22072gradeTargetBunproAndAnki	Bunpro + Ankiカードを採点: {target}
22073gradeTargetYomuLocalAndAnki	Academy + Ankiカードに記録: {target}
22074missingAnkiCardId	AnkiカードIDがありません。
22075jpdbPageEnhancements	辞書サイト拡張
22076jpdbPageEnhancementsEnabled	辞書ページを拡張
22077jpdbPageWordEnhancementsEnabled	単語・検索ページにソースを追加
22078jpdbPageKanjiEnhancementsEnabled	漢字ページにソースを追加
22079fivePoint	サービスの標準評価
22080fourGradeShortcutsHelp	4段階の復習では、最初の4つのショートカットを「もう一度・難しい・良い・簡単」に使います。
22081twoPoint	2段階: 失敗 / 合格
22082settingsLanguage	設定の表示言語
22083theme	テーマ
22084auto	自動
22085dark	ダーク
22086light	ライト
22087popupMode	ポップアップ表示
22088hoverPopupMode	ホバー時の表示
22089bottomSheet	下部シート
22090popover	ポップオーバー
22091stickyBottomSheet	検索後も開く
22092popoverBackdropEnabled	背後を暗くする
22093popoverWidth	ポップオーバー幅 (px)
22094popoverHeight	ポップオーバー高さ (px)
22095popoverHeightMode	ポップオーバー高さの動作
22096popoverHeightAvailable	空き領域まで
22097popoverHeightFixed	高さ設定を使う
22098readerFontFamily	リーダーUIフォント
22099popupFontFamily	ポップアップのフォント
22100fontPresetYomuDefault	内蔵フォント
22101fontPresetJapaneseSans	日本語サンセリフ
22102fontPresetHiraginoYuGothic	ヒラギノ / 游ゴシック
22103fontPresetJapaneseRounded	日本語丸ゴシック
22104fontPresetJapaneseSerif	日本語明朝
22105fontPresetSystemUi	システムUI
22106fontPresetCustom	カスタム...
22107customFontFamily	カスタムフォント
22108popupFontWeight	ポップアップのフォントの太さ
22109enableLogging	診断ログを有効にする
22110diagnostics	診断
22111diagnosticsHelp	診断をコンソールへ出力します。
22112accentColor	アクセントカラー
22113newTab	学習
22114newTabAnkiEnabled	学習でAnkiカードを使う
22115newTabAnkiReviewDecks	Anki復習デッキ
22116newTabAnkiReviewDecksHelp	不要なデッキを外します。
22117newTabSource	学習の復習ソース
22118newTabAuto	自動: Academy・アカウント後に学習語
22119newTabApiSrs	API SRS(Jiten / JPDB)
22120newTabBunpro	Bunpro
22121newTabWanikani	WaniKani
22122newTabYomuLocal	Academy
22123dictionaryFallback	辞書フォールバック
22124newTabJpdbReviewMode	API復習モード
22125newTabJpdbReviewAuto	自動: ライブ漢字+API語彙
22126newTabLiveReview	ライブJPDB復習セッション
22127newTabApiVocabulary	API語彙のみ(デッキ順)
22128corsProxyUrl	クロスオリジンプロキシURL
22129newTabKanjiKeywordSource	漢字キーワードのソース
22130newTabKanjiKeywordAuto	自動: RTK、{service}、ローカル
22131newTabKanjiKeywordRtk	RTK / Heisig
22132newTabKanjiKeywordApiFacts	{service}漢字情報(Jiten / JPDB)
22133newTabKanjiKeywordLocal	ローカルカードの意味
22134newTabParsingEnabled	学習の文解析を有効にする
22135newTabFrontSentenceEnabled	単語カード表面に文を表示
22136newTabKanjiAutogradeEnabled	漢字書き取りを自動採点
22137newTabOfflineEnabled	学習をオフライン用にキャッシュ
22138newTabOfflineLimit	オフライン復習キャッシュ上限
22139newTabDailyGoalMinutes	1日の学習目標(分・0で無効)
22140newTabKanjiUnlockEnabled	漢字後に単語を解放
22141newTabStopAtBatchEnd	バッチの終わりで停止
22142newTabSwipeReviews	スワイプ採点(左=失敗、右=合格)
22143newTabShortcutHintsEnabled	学習のキーボードショートカットヒントを表示
22144newTabUrl	学習ページのアドレス
22145newTabOfflineHelp	カードと未送信採点を保存。
22146newTabAddressHelp	新規タブやiPadホーム画面用。
22147newTabJpdbDeck	学習のJPDBデッキ
22148openNewTabPage	学習を開く
22149copyAddress	アドレスをコピー
22150wordColors	単語の色
22151wordColorNew	新規・デッキ内
22152wordColorLearning	学習中
22153wordColorKnown	既知・忘れない
22154wordColorDue	期限到来
22155wordColorFailed	失敗
22156wordColorIgnored	無視・保留・ブラックリスト中
22157pitchAccentColors	ピッチアクセントの色
22158pitchColorHeiban	平板
22159pitchColorAtamadaka	頭高
22160pitchColorNakadaka	中高
22161pitchColorOdaka	尾高
22162pitchColorUnknown	不明
22163pronunciation	発音
22164noExactPitch	完全一致のピッチは利用不可
22165colorChannels	色チャンネル
22166wordHighlightColorSource	単語ハイライトの色
22167wordUnderlineColorSource	単語下線の色
22168wordTextColorSource	単語テキストの色
22169subtitleHighlightColorSource	字幕ハイライトの色
22170subtitleUnderlineColorSource	字幕下線の色
22171subtitleTextColorSource	字幕テキストの色
22172colorSourceStatus	すべての学習状態
22173colorSourceJpdb	メインデッキの学習状態
22174colorSourceAnki	Ankiの学習状態
22175colorSourcePitch	ピッチアクセント
22176colorSourceNone	なし
22177popupLookup	ポップアップ検索
22178popupLookupEnabled	よむの検索ポップアップを表示
22179popupLookupHelp	他リーダーのポップアップ用。オフでも他機能は有効。
22180lookupOnClick	タップまたはクリックで検索
22181lookupOnHover	ホバーで検索
22182lookupOnMiddleMouse	中央ボタン長押しで検索
22183showFloatingButton	設定ボタンを表示
22184pageScanMode	ウェブページの{language}
22185pageScanModeOff	ページを変更しない
22186pageScanModeAuto	{language}を自動で検出
22187pageScanModeManual	指示したときだけ{language}を検出
22188manualPageScanShortcut	手動ページスキャンのショートカット
22189manualScanEnabled	手動ページスキャン
22190ocrInteractionMode	画像OCRスキャン
22191ocrInteractionModeAuto	自動
22192ocrInteractionModeManual	タップ/ホバー
22193ocrInteractionModeOff	オフ
22194puckMenuLabel	よむ メニュー
22195puckPauseAnnotations	注釈を一時停止
22196puckResumeAnnotations	注釈を再開
22197puckOcrAuto	OCR: 自動
22198puckOcrManual	OCR: タップ/ホバー
22199puckOcrOff	OCR: オフ
22200annotationsPausedToast	注釈を一時停止しました。
22201annotationsResumedToast	注釈を再開しました。
22202puckMuteAudio	音声の自動再生をミュート
22203puckUnmuteAudio	音声の自動再生のミュートを解除
22204puckHideFurigana	ふりがなを隠す
22205furiganaOffToast	ふりがなを非表示にしました。単語の検索は引き続き使えます。
22206autoplayAudioOnToast	音声の自動再生をオンにしました。
22207autoplayAudioOffToast	音声の自動再生をミュートしました。
22208showFurigana	ふりがな注釈を有効にする
22209furiganaMode	ふりがな
22210wordColorStates	色を付ける単語
22211appearancePreset	かんたん設定
22212appearancePresetCustom	現在のカスタム設定を保持
22213appearancePresetBalanced	読みやすいバランス
22214appearancePresetNoColors	プレーンテキスト
22215appearancePresetNewOnly	新規単語に集中
22216appearancePresetUnderlineNew	控えめなハイライト
22217wordColorStatesAll	すべての学習状態
22218wordColorStatesNewOnly	新規・未追加のみ
22219hideFuriganaFor	ふりがなを隠す対象
22220hideColorFor	色を隠す対象
22221furiganaDifficultKanji	難しい漢字のみ
22222furiganaDifficultKanjiHelp	Yomuは初級漢字の固定リストを持ち、その外側の漢字にふりがなを表示します。ふりがなのない漢字は、そのリストに載っています。
22223statusColorNoSourceHelp	学習状態の色はデッキから読み取ります。StudyでAcademyを有効にするか、JPDB・Jiten・Ankiのいずれかを追加すると、単語が学習状態の色になります。
22224furiganaHideKnown	なじみのある語を非表示
22225furiganaHoverOnly	ホバー時に表示
22226furiganaAllParsed	解析済みの全単語に表示
22227clampedRowReadings	省略行の読み
22228clampedRowReadingsShow	表示(行が広がる)
22229clampedRowReadingsHover	ホバー時のみ
22230showPitchAccent	発音を表示
22231showLookupPillFrequency	サイトの頻度をピルに表示
22232suppressRedundantWordUi	JPDBの冗長語のスタイルを非表示
22233sheetCloseButtonOnLeft	閉じるボタンを左に
22234hideKnownFurigana	既知カードのふりがなを非表示
22235readerHelp	ホバーキーを設定。空欄なら通常ホバー。
22236hoverLookupSettings	ホバー検索
22237kanjiOriginKanjiMapEnabled	漢字情報と部品グラフを表示
22238kanjiOriginGraphEnabled	部品グラフを表示
22239kanjiOriginRadicalImagesEnabled	部首画像を表示
22240similarKanjiWordLimit	類似語の上限
22241audioEnabled	語句の音声を有効にする
22242autoPlayAudio	語句の音声を自動再生
22243suppressAutoAudioOnVideo	動画では検索音声オフ
22244audioAutoPlayMode	自動再生のきっかけ
22245audioEnableDefaultSources	内蔵音声ソースを有効
22246audioFallbackChimeEnabled	フォールバック音を有効
22247audioSelectionMode	複数音声があるとき
22248audioPlayback	音声再生
22249firstAudio	最初の音声
22250randomAudio	シャッフル音声
22251audioTtsMode	読み上げの扱い
22252audioTtsFallback	録音音声の後のフォールバック
22253audioTtsSourceOrder	ソース順/シャッフルに含める
22254audioTimeoutMs	音声タイムアウト (ms)
22255previewAudio	音声を試聴
22256audioHelp	URL: {term}、{reading}、{language}。
22257audioSource	音声ソース
22258urlVoice	URL / 音声
22259addAudioSource	音声ソースを追加
22260audioAutoPlayAll	ホバーとタップ/クリック
22261audioAutoPlayHover	ホバーのみ
22262audioAutoPlayTap	タップ/クリックのみ
22263automaticBrowserVoice	ブラウザの自動音声
22264savedVoiceLabel	保存済み音声: {voice}
22265audioSourceOrder	音声ソースの順序
22266audioSourceNumber	音声ソース {number}
22267enableAudioSourceNumber	音声ソース {number} を有効にする
22268enableLookupPillName	検索ピル「{name}」を有効にする
22269enableSourceName	ソース「{name}」を有効にする
22270textToSpeechVoiceNumber	読み上げ音声 {number}
22271audioSourceJpod101	JapanesePod101
22272audioSourceLanguagePod101	LanguagePod101
22273audioSourceJisho	Jisho.org
22274audioSourceBunpro	Bunpro
22275audioSourceLinguaLibre	(Commons) Lingua Libre
22276audioSourceWiktionary	(Commons) Wiktionary
22277audioSourceJitenTts	Jiten読み上げ
22278audioSourceJpdbTts	JPDB読み上げ
22279audioSourceTextToSpeech	ブラウザ読み上げ
22280audioSourceTextToSpeechReading	ブラウザ読み上げ (読み)
22281audioSourceCustom	直接音声ファイルURL
22282audioSourceCustomJson	カスタムURL
22283audioCustomJsonPlaceholder	Yomitan/Ultimate音声URL
22284audioCustomUrlPlaceholder	直接音声ファイルURL
22285audioBuiltInPlaceholder	内蔵ソースはURL不要
22286audioDetectingSubSources	内部ソースを確認中…
22287audioNoSubSourcesDetected	このURLは名前付きソースを返しませんでした。
22288audioSubSourcesHelp	このURLが提供するソース。不要なものはオフに:
22289audioSubSourceOverlapHint	下の単独ソースと重複
22290defaultVoiceSuffix	標準
22291audioGuideLinkLabel	Yomitan音声ガイド
22292audioProxyGuideSummary	Cloudflareプロキシ
22293audioProxyGuideIntro	専用プロキシにはWorkerを使います。
22294audioProxyGuideCloudflare	Cloudflareを開きます。
22295audioProxyGuideWorkers	Workers & PagesでCreateします。
22296audioProxyGuideCreateWorker	Workerを選び、名前を付けてDeploy。
22297audioProxyGuideEditCode	Yomu Workerソースを貼ります。
22298audioProxyGuideDeploy	Deployします。
22299audioProxyGuideCopyUrl	Worker URLをコピーします。
22300audioProxyGuidePasteUrl	Cross-origin proxy URLに貼ります。
22301audioProxyGuideTest	保存後、検索・インポート・音声で確認。
22302audioProxyGuideNote	共有前にホストを絞ります。
22303audioProxyWorkerSource	Workerソース
22304audioProxyDeployGuide	デプロイガイド
22305immersionKitEnabled	イマージョンキット例文を表示
22306immersionKitExampleSource	例文プロバイダー
22307immersionKitAndNadeshiko	イマージョンキット + なでしこ
22308nadeshikoApiKey	なでしこAPIキー
22309getNadeshikoKey	キーを取得
22310immersionKitShowTranslation	例文の翻訳を表示
22311immersionKitRevealTranslationOnClick	クリックまで翻訳をぼかす
22312immersionKitShowImages	例文サムネイルを表示
22313immersionKitAutoPlayAudio	表示後や移動時に音声再生
22314immersionKitPlayOnHover	ホバーで例文音声を再生
22315immersionKitPlayOnImageClick	クリックで例文音声を再生
22316immersionKitCategory	例文ソース
22317immersionKitSort	例文の並び順
22318immersionKitLimitEnabled	単語ごとの例文数制限
22319allExamples	すべての例文
22320limitExamples	例文数を制限
22321immersionKitLimit	単語ごとの例文数
22322immersionKitMinLength	最小文長
22323immersionKitMaxLength	最大文長
22324immersionKitPlaybackRate	例文音声速度
22325immersionKitExactMatch	完全一致を優先
22326immersionKitHelp	例文を表示。Nadeshikoはキー必須。
22327allCategories	すべて
22328anime	アニメ
22329drama	ドラマ
22330games	ゲーム
22331shortestFirst	短い順
22332longestFirst	長い順
22333ocrEnabled	画像内テキストを読む
22334ocrAutoScanImages	画像を自動で読む
22335ocrShowTextOverlay	認識した画像テキスト領域を表示
22336ocrVideoPauseFrames	一時停止した動画フレームを自動で読む
22337ocrInvertDarkPanels	暗いコマの白い文字を読む
22338ocrProvider	画像読み取り
22339ocrOverlayTheme	OCRオーバーレイテーマ
22340ocrOverlayThemeAuto	アプリのテーマに合わせる
22341ocrOverlayThemeLight	ライトオーバーレイ
22342ocrOverlayThemeDark	ダークオーバーレイ
22343googleLens	Google Lens — 無料・設定不要(おすすめ)
22344cloudVision	Google Cloud Vision — APIキーが必要
22345localOcr	ローカルOCRサーバー — 上級者向け
22346off	オフ
22347ocrMaxImagesPerPage	ページごとに読む画像数
22348ocrMinImageArea	読む画像の最小サイズ
22349ocrMaxImagePixels	画像の精細さ
22350lightWork	軽め
22351normal	標準
22352more	多め
22353largeOnly	大きい画像のみ
22354includeSmall	小さい画像も含める
22355faster	高速
22356balanced	バランス
22357sharper	高精細
22358ocrTextColor	画像テキストの色
22359ocrOutlineColor	画像テキストの縁取り
22360ocrBackgroundOpacity	画像ハイライト不透明度
22361ocrFontScale	画像テキスト倍率
22362ocrEndpointUrl	ローカルOCRサーバーURL
22363ocrEngine	ローカルOCRエンジン
22364ocrEngineMangaOcr	MangaOCR(マンガに最適)
22365ocrEngineAppleVision	Apple Vision(macOS)
22366cloudVisionApiKey	Google Cloud Vision APIキー
22367ocrHelp	近くの画像を読み取ります。Google Lensは設定不要です。
22368ocrCloudHelp	Google Cloud Vision APIキーを貼ります。
22369ocrLocalHelp	MangaOCR/Apple VisionのローカルURLを入力します。
22370subtitleStyle	字幕スタイル
22371subtitleResetDefaults	標準に戻す
22372moveSubtitles	字幕を移動
22373moveSubtitlesAccessible	字幕を移動します。ドラッグするか、矢印キーまたはPage Up/Page Downキーを使います。Homeまたは0でリセットします。
22374moveSubtitleControls	字幕コントロール。タップで展開・折りたたみ。ドラッグまたは矢印キーで移動します。Homeまたは0でリセットします。
22375right	右
22376left	å·¦
22377bottom	下
22378showWhenNeeded	コンパクト表示
22379hideControls	コントロールを隠す
22380alwaysVisible	常に表示
22381preview	プレビュー
22382youtubeImmersionEnabled	{language}のYouTubeのみ
22383preferJapaneseSiteLanguage	{language}版のサイトを開く
22384youtubeShowChannelRecommendations	日本語チャンネル候補を表示
22385youtubeShowFilterNotice	非表示動画の通知を表示
22386youtubeHelp	YouTubeを{language}向けに絞り、{language}版のサイトを開きます。
22387youtubeShowHiddenVideos	非表示動画を表示
22388youtubeHideHiddenVideos	非表示動画を隠す
22389youtubeHideNotice	通知を隠す
22390youtubeFilterShowing	{appName}は非表示のYouTube項目{count}件を表示中
22391youtubeFilterHid	{appName}は他の言語のYouTube項目{count}件を非表示
22392youtubeFilterVisible	{language}らしい項目{count}件は表示したままです。
22393youtubeToggleToastOn	YouTube没入フィルターをオンにしました。
22394youtubeToggleToastOff	YouTube没入フィルターをオフにしました。
22395ankiEnabled	Anki採掘を有効にする
22396ankiMineWithJpdb	API経由で追加するときAnkiにも追加
22397ankiCaptureScreenshot	可能なら文脈画像を添付
22398ankiConnectUrl	AnkiConnect URL
22399ankiDeck	Ankiデッキ
22400ankiModel	Ankiノートタイプ
22401mobileAnkiHandoff	モバイルAnki新規ノート作成
22402ankiTemplateMode	Ankiカードテンプレート
22403ankiFrontReading	単語優先の表面に読みを表示
22404ankiFrontSentence	単語優先の表面に文を表示
22405ankiFrontImage	表面に画像を表示
22406wordFirst	単語を先に表示
22407sentenceFirst	文を先に表示
22408ankiTags	タグ
22409sentenceFirstPreset	文を先に表示するプリセット
22410wordFirstPreset	単語を先に表示するプリセット
22411imageAbovePrompt	画像があれば問題文の上に表示します。
22412recallHighlightedWord	文脈からハイライト語を思い出します。
22413imageOnFront	利用可能な場合、画像は表面に表示されます。
22414recallMeaning	まず意味を思い出します。
22415ankiBackIncludes	辞書、漢字、ピッチ、頻度、出典、画像を含みます。
22416exampleMeaning	読む
22417scanAnkiFirst	先にAnkiConnectに接続
22418notMapped	対応付けなし
22419noScannedFields	読み取れるフィールドがありません。
22420mappingForNoteType	{model} の対応付け
22421currentNoteType	現在のノートタイプ
22422ankiFieldMappingSelect	{role}フィールド
22423ankiRoleExpression	表記
22424ankiRoleReading	読み
22425ankiRoleMeaning	意味
22426ankiRoleSentence	文
22427ankiRoleAudio	単語音声
22428ankiRoleSentenceAudio	文音声
22429ankiRoleImage	画像
22430testAnki	AnkiConnectを確認
22431prepareAnki	よむノートタイプを準備
22432updateAnkiModel	ノートタイプを更新
22433ankiModelUpdateAvailable	「{model}」に追加できる新しいフィールドがあります: {fields}
22434ankiModelUpdating	ノートタイプにフィールドを追加中...
22435ankiModelUpdated	ノートタイプを更新しました。{fields} を追加しました。
22436ankiModelUpToDate	ノートタイプは最新です。
22437ankiCheckingConnection	{url} のAnkiConnectを確認中。
22438ankiMiningDisabledStatus	Ankiマイニングは無効です。
22439ankiTesting	AnkiConnectを確認中...
22440ankiPreparing	よむデッキとノートタイプを作成または更新中...
22441ankiScanning	Ankiデッキ、ノートタイプ、フィールドを読み込み中...
22442ankiScanSummary	デッキ{decks}、ノート{models}。候補: {model}。{fields}
22443ankiScanNoModels	デッキ{decks}件を検出。ノートタイプは未取得です。
22444ankiScanFieldSummary	フィールド: {fields}
22445ankiUnreachable	デスクトップAnkiとAnkiConnectを確認してください。
22446ankiCorsBlocked	webCorsOriginListに「{origin}」を追加し再起動してください。
22447ankiSettingsUnreachable	AnkiConnectに接続できません。
22448ankiHostedBridgeMissing	よむを有効化し、更新してください。
22449ankiStatusOpenDesktop	デスクトップAnkiを開く
22450ankiStatusInstallAddon	AnkiConnectをインストール/有効化
22451ankiStatusMobileDocs	モバイル設定ドキュメント
22452ankiStatusUseDesktopUrl	モバイルではLAN/Tailscale URLを使う
22453ankiStatusEnableUserscript	よむを有効化
22454ankiStatusRefreshAndCheck	更新して再確認
22455ankiLibraryAdapter	既存ライブラリアダプター
22456ankiLibraryAdapterStatus	既存デッキから対応付けを提案します。
22457ankiLibraryChoices	デッキとノートタイプ
22458ankiLibraryChoicesHelp	作成・更新先を選びます。
22459ankiTemplateSettings	よむカードテンプレート
22460ankiTemplateSettingsHelp	よむノートタイプ用。テンプレートはAnkiに残ります。
22461ankiMappingConfidenceHelp	フィールド名とサンプルで判断します。
22462ankiMappingHighConfidence	高
22463ankiMappingMediumConfidence	中
22464ankiMappingLowConfidence	低
22465ankiHelp	AnkiConnectを入れてデスクトップ版Ankiを開きます。CORS表示が出る場合はこのサイトをwebCorsOriginListに追加してください。モバイル受け渡しは新規ノート作成のみです。
22466jpdbDefinitionsEnabled	JPDB定義を表示
22467${Object.entries(LOCAL_DICTIONARY_STORAGE_COPY.jaSettings).map(([key, value]) => `${key}	${value}`).join("\n")}
22468dictionarySourcesInitiallyExpanded	ポップアップのソースを標準で開く
22469localDictionaryMaxResults	辞書結果の上限
22470cloudSettingsSync	Google Drive設定同期
22471cloudSettingsSyncHelp	Yomuの設定をGoogle Driveのアプリデータに保存します。辞書は端末内に残ります。
22472academyAccountSync	Academyアカウント同期
22473academyAccountSyncHelp	ReaderのAcademy SRS進捗を、ログイン中のYomuアカウントと端末間で同期します。Webサイトでアカウントを作成または管理し、1回限りのペアリングコードを発行してください。
22474academyAccountManage	アカウントとペアリングコードを管理
22475academyPairingCode	1回限りのペアリングコード
22476academyPairingCodePlaceholder	XXXX-XXXX-XXXX-XXXX-XXXX
22477academyAccountConnect	接続
22478academyAccountSyncNow	今すぐ同期
22479academyRecoveryCodeCreate	Webサイト復旧コードを作成
22480academyRecoveryCodeCreating	1回限りのWebサイト復旧コードを作成中...
22481academyRecoveryCodeReady	Webサイト復旧コード: {code}。10分以内に「プロフィールと同期」で入力してください。
22482academyRecoveryCodeDone	Webサイト復旧コードを作成しました。
22483academyAccountDisconnect	接続解除
22484academyAccountChecking	Academyアカウントの接続を確認中...
22485academyAccountDisconnected	未接続です。アカウントに接続するまで、Academyの復習データはこの端末に保存されます。
22486academyAccountConnected	{name}として接続中です。
22487academyAccountConnectedNoName	Academyアカウントに接続中です。
22488academyAccountLastSynced	最終同期: {time}。
22489academyAccountNeverSynced	まだ同期していません。
22490academyAccountConnectionProblem	アカウント状態を更新できませんでした: {message}
22491academyAccountConnecting	接続してAcademyの進捗を同期中...
22492academyAccountSyncing	Academyの進捗を同期中...
22493academyAccountDisconnecting	このReaderの接続を解除中...
22494academyPairingCodeRequired	Yomuアカウントで1回限りのペアリングコードを発行し、入力してください。
22495academyAccountConnectedDone	Academyアカウントに接続し、進捗を同期しました。
22496academyAccountSyncedDone	Academyの進捗を同期しました。
22497academyAccountDisconnectedDone	このReaderの接続を解除しました。Academyの進捗は端末に残ります。
22498importSettings	設定JSONをインポート
22499exportSettings	設定JSONをエクスポート
22500importDictionaries	辞書をインポート
22501exportDictionaries	辞書をエクスポート
22502lookupPills	検索ピル
22503parserProvider	解析ソース
22504parserProviderLocal	ローカル辞書(オフライン)
22505parserProviderJiten	Jiten API
22506parserProviderJpdb	JPDB API
22507parserProviderAuto	自動(Jiten/JPDB)
22508parserProviderHelp	ローカルはインポート済み辞書でオフライン解析します。JitenとJPDBはキー設定時に必ずそのAPIを使います。自動は両方のキーがあれば優先採点サービスを使い、それ以外はJiten、次にJPDBを優先します。
22509lookupPillsHelp	外部リンクと頻度バッジを同じ順序で表示します。ローカル頻度辞書は一致するJiten/JPDBライブバッジを置ã
22509æ›ãˆã¾ã™ã€‚トークン: {query}、{word}、{reading}。
22510copiesCurrentWord	現在の単語をコピーします
22511plaintextHttpLink	プレーンテキストHTTPで開きます。
22512lookupPillLabelNumber	検索ピル{number}のラベル
22513lookupUrlTemplate	検索URLテンプレート
22514lookupUrlTemplateNumber	ピル{number} URL
22515lookupPillOrder	検索ピルの順序
22516builtInAction	内蔵アクション
22517recommendedDownloads	辞書
22518termDictionaries	語句辞書
22519kanjiDictionaries	漢字辞書
22520pitchDictionaries	ピッチ辞書
22521pronunciationDictionaries	発音辞書
22522frequencyDictionaries	頻度辞書
22523nameDictionaries	固有名詞辞書
22524grammarDictionaries	文法辞書
22525exampleDictionaries	例文辞書
22526thesaurusDictionaries	類語辞書
22527encyclopediaDictionaries	百科事典
22528utilityDictionaries	補助辞書
22529mirroredDictionaries	配信中のすべての辞書
22530mirroredDictionariesSummary	他{count}件の辞書 · 合計{size}
22531mirroredDictionarySearch	辞書を検索
22532mirroredDictionarySearchNoResults	検索に一致する辞書がありません。
22533mirroredDictionaryLanguageNote	{language}を読むための辞書です。
22534install	インストール
22535installing	インストール中
22536queued	待機中
22537dictionaryGuide	ガイド
22538download	ダウンロード
22539update	更新
22540checkingDictionaries	インポート済み辞書を確認中...
22541decksLoaded	JPDBアカウントからデッキを読み込みました。
22542decksUnavailable	デッキを読み込めません。保存IDは保持します。
22543addApiKeyChooseDecks	デッキを選ぶにはJPDB APIキーを追加してください。
22544miningDeck	採掘デッキ
22545neverForgetDeck	忘れないデッキ
22546blacklistDeck	ブラックリストデッキ
22547allStudyDecks	すべての学習デッキ
22548savedValue	保存済み: {value}
22549holdWhileHovering	ホバー中に押すキー
22550hoverOpenDelayMs	ホバーで開く遅延 (ms)
22551hoverCloseDelayMs	ホバーを閉じる遅延 (ms)
22552pressKeys	キーを押してください
22553blankPlainHover	空欄ならキーなしホバー
22554openSettings	設定を開く
22555resizeSettings	設定パネルのサイズ変更
22556closePopup	ポップアップを閉じる
22557previousLookupWord	前の単語
22558nextLookupWord	次の単語
22559playingAudioPreview	{APP_NAME}を再生中...
22560audioPreviewFailed	音声プレビューに失敗しました。
22561previousSubtitle	前の字幕
22562nextSubtitle	次の字幕
22563pauseVideo	動画を一時停止
22564readVideoFrame	動画フレームを読み取る(OCR)
22565readVideoFrameStop	動画フレームの読み取りを停止(OCR)
22566copySubtitle	字幕をコピー
22567toggleImageReading	画像読み取りを切り替え
22568toggleSubtitleOverlay	字幕オーバーレイを切り替え
22569toggleYoutubeImmersion	YouTubeフィルターを切り替え
22570readImagesNow	今すぐ画像を読む
22571massReviewVisible	画面内の単語を一括レビュー(Jiten)
22572massReviewNoWords	画面内に復習対象のJiten単語がありません。
22573massReviewNoKey	一括レビューにはJiten APIキーが必要です。
22574massReviewDone	{count}語を「Good」でレビューしました。
22575massReviewFailed	一括レビューに失敗しました。
22576adapterStateDisabled	オフ
22577adapterStateProbing	接続確認中
22578adapterStateUnreachable	接続不可
22579adapterStateConnected	接続済み
22580adapterStateScanning	スキャン中
22581adapterStateSuggested	対応付け済み
22582adapterStateStale	要確認
22583adapterStateReady	準備完了
22584ankiMappingConfidenceHigh	完全一致
22585ankiMappingConfidenceMedium	曖昧一致
22586ankiMappingConfidenceLow	未対応
22587ankiMappingStaleField	保存済みフィールドなし
22588helpLinksTitle	便利なページ
22589versionAndUpdates	バージョン
22590currentYomuVersion	Yomu
22591updateStatusIdle	現在 {current}。確認待ち。
22592updateStatusChecking	現在 {current}。確認中...
22593updateStatusCurrent	現在 {current}。最新 {latest}。最新です。
22594updateStatusAvailable	現在 {current}。最新 {latest}。更新できます。
22595updateStatusUnknown	現在 {current}。確認できません。必要なら再インストールしてください。
22596updateStatusIncomparable	現在 {current}。最新 {latest}。バージョンを比較できません。古い場合は「更新」を使ってください。
22597updateHelpNotesManager	よむスクリプトは1つだけ有効にしてください。「更新」でユーザースクリプトマネージャーのインストール画面が開きます。ブラウザにインストールブロックの警告が出る場合は、拡張機能ページでマネージャーの詳細を開き、「ユーザースクリプトを許可」(または開発者モード)を有効にしてから再試行してください。
22598updateHelpNotesManagerDashboard	Chrome または Edge では、「更新」を押すと Tampermonkey の更新手順が開きます。ダッシュボードの「ユーティリティ」→「ユーザースクリプトの更新を確認」を使うため、ウェブサイトからのインストールをブロックする警告を回避できます。
22599updateHelpNotesExternalManager	よむスクリプトは1つだけ有効にしてください。「更新」でスクリプトのソースが開き、ユーザースクリプトアプリが開いたタブから読み取って更新します。iPhone/iPadで更新が止まる場合は、このリンクをSafariで開いてタブを開いたままにしてください。
22600updateHelpNotesNoManager	この環境ではユーザースクリプトマネージャーが検出されませんでした。ブラウザはスクリプトの直接インストールをブロックするため、「更新」ではブラウザ別の手順があるインストールガイドを開きます。
22601updateHelpNotesExtensionStore	よむのブラウザ拡張機能版を実行中です。「更新」を押すとブラウザの拡張機能ストアが開きます。ストア版は自動的に更新され、手動での更新確認も行えます。
22602updateUserscript	更新
22603duplicateStatusSingle	有効なYomuランタイムは1つです({kind})。
22604duplicateStatusUnknown	重複確認はできません。よむが2つ表示される場合は古いスクリプトを無効にしてください。
22605ankiConnectSetupTitle	AnkiConnect設定
22606ankiConnectSetupCopy	デスクトップAnkiを開き、AnkiConnectを有効にしてください。ホスト版StudyではAnkiConnect側でYomuのオリジンを許可する必要があります。
22607ankiConnectSetupConfig	AnkiConnectのwebCorsOriginListに次のオリジンを追加してください。既存の項目は残します:
22608ankiConnectSetupMobile	スマホやiPadでは、デスクトップPCのLANまたはTailscale URLを使います。スマホ上のlocalhostはPCではなくスマホ自身を指します。
22609ankiConnectSetupBrave	BraveでローカルAnki確認がブロックされる場合は、StudyページのShieldsをオフにしてください。
22610helpSupportTitle	よむをサポート
22611helpSupportCopy	よむは検索、OCR、字幕、辞書、学習、Ankiをまとめた無料ユーザースクリプトです。
22612helpSupportCopyExtra	寄付は開発とサービス費用を支えます。
22613videoPlayer	動画プレイヤー
22614pdfReader	PDFリーダー
22615newTabPage	学習
22616github	GitHub
22617docs	ドキュメント
22618factoryReset	初期状態に戻す
22619factoryResetConfirm	{appName}の全データをリセットしますか?\n\n設定、キー、キャッシュ、辞書を削除。
22620factoryResetFailed	リセットに失敗しました。
22621factoryResetStorageIncomplete	保存データをすべて検出または削除できなかったため、リセットを中止しました。ほかのよむタブを閉じて再試
22621行してください。解決しない場合は、ユーザースクリプトマネージャーでよむのストレージを消去してください。
22622factoryResetOtherTabReloading	別タブでリセット。再読み込み...
22623issues	Issue
22624donate	寄付
22625discord	Discord
22626enabledHeader	有効
22627labelHeader	ラベル
22628displayName	表示名
22629orderHeader	順序
22630removeHeader	削除
22631definitionSource	定義ソース
22632kanjiSection	漢字セクション
22633dragToReorder	ドラッグして並べ替え
22634moveUp	上へ移動
22635moveDown	下へ移動
22636remove	削除
22637removeImportedDictionary	インポート済み辞書を削除
22638customAdvanced	{label} (詳細)
22639importLocalDefinitionsHelp	ローカル定義にはYomitan辞書を使います。
22640metadataDictionariesHelp	メタデータ辞書は、バッジや漢字データとして表示されます。
22641sourceHelpJpdb	現在のカードのJPDB定義です。
22642sourceHelpJiten	Jiten定義、例文、関連語です。
22643sourceHelpBunpro	Bunproの語彙・文法の意味、ニュアンス、例文です。
22644sourceHelpWanikani	あなたのアカウントのWaniKani語彙の意味、覚え方、SRS状態です。
22645sourceHelpAnki	一致するAnkiカード内容と状態です。
22646sourceHelpTranslation	文の自動翻訳です。
22647sourceHelpGrammar	ローカル文法ヒントです。
22648sourceHelpImmersionKit	例文、画像、音声です。
22649sourceNameImmersionKit	イマージョンキット
22650sourceNameAnki	Anki
22651sourceNameTranslation	翻訳
22652sourceNameGrammar	文法
22653sourceNameStrokePractice	筆順練習
22654sourceNameImportedKanjiDictionaries	インポート済み漢字辞書
22655sourceNameWordsUsingKanji	相关词汇
22656sourceNameJitenKanjiFacts	Jiten漢字情報
22657sourceHelpImportedKanjiDictionary	インポート済みYomitan漢字辞書です。
22658sourceHelpStrokePractice	筆順プレビューと書き取りパッドです。
22659sourceHelpReadingsComponents	JPDBの読み、部品、語呂合わせです。
22660sourceHelpJitenKanjiFacts	Jitenの漢字情報、頻度、読み、使用語です。
22661sourceHelpRtk	RTKキーワード、要素、ストーリーです。
22662sourceHelpWanikaniKanji	WaniKaniの漢字の意味・読みの覚え方、レベル、SRS状態です。
22663sourceHelpImportedKanjiDictionaries	インポート済み漢字項目です。
22664sourceHelpWordsUsingKanji	関連語彙です。
22665sourceHelpComponentGraph	漢字情報、部品、部首画像です。
22666recommendedJitendex	例文付きの語句定義です。
22667recommendedJmdict	基本語句定義です。
22668recommendedJmnedict	固有名詞辞書です。
22669recommendedWtyJapaneseJapanese	日本語で読む語句定義です。
22670recommendedPixivLight	Pixiv用語辞書です。
22671recommendedKanjidic	漢字情報です。
22672recommendedJpdbKanji	JPDB漢字情報です。
22673recommendedKanjiumPitch	ピッチアクセント専用です。定義には語句辞書も追加してください。
22674recommendedJpdbv2Kana	JPDB由来のおすすめ頻度バッジです。
22675recommendedBccwj	BCCWJ由来の頻度バッジです。
22676recommendedJiten	Jiten由来の頻度バッジです。
22677`),
22678...SUBTITLE_SETTINGS_COPY.ja,
22679...TARGET_AWARE_UI_COPY.ja
22680};
22681function resolveUiLanguage(language) {
22682if (language === "ja" || language === "en") return language;
22683return browserPrefersJapanese() ? "ja" : "en";
22684}
22685function browserPrefersJapanese() {
22686const navigatorLanguages = typeof navigator === "undefined" ? [] : [
22687...Array.isArray(navigator.languages) ? navigator.languages : [],
22688navigator.language
22689];
22690return navigatorLanguages.some(isJapaneseLocale);
22691}
22692function isJapaneseLocale(value) {
22693return typeof value === "string" && value.toLowerCase().startsWith("ja");
22694}
22695function uiText(language, key) {
22696return resolveUiLanguage(language) === "ja" ? JA_SETTINGS_COPY[key] ?? JA_COPY[key] ?? COPY.en[key] : COPY.en[key];
22697}
22698function cardStateLabel(state, language, fallback = state) {
22699const key = CARD_STATE_LABEL_KEYS[state];
22700return key ? uiText(language, key) : fallback;
22701}
22702function providerCardStateLabel(providerId, state, language) {
22703return providerId === "yomu-local" && state === "in-deck" ? uiText(language, "savedWord") : cardStateLabel(state, language);
22704}
22705function externalLinkIcon() {
22706return `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
22707        <path d="M7 17 17 7"></path>
22708        <path d="M9 7h8v8"></path>
22709    </svg>`;
22710}
22711function copyIcon() {
22712return `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
22713        <rect x="9" y="9" width="10" height="10" rx="2"></rect>
22714        <path d="M5 15V7a2 2 0 0 1 2-2h8"></path>
22715    </svg>`;
22716}
22717function ankiIcon() {
22718return `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
22719        <rect x="5" y="4" width="14" height="16" rx="2"></rect>
22720        <path d="M12 8v8"></path>
22721        <path d="M8 12h8"></path>
22722    </svg>`;
22723}
22724function speakerIcon() {
22725return `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
22726        <path d="M11 5 6.8 8.4H4.5v7.2h2.3L11 19V5Z"></path>
22727        <path d="M15.2 8.2a5 5 0 0 1 0 7.6"></path>
22728        <path d="M17.8 5.7a8.4 8.4 0 0 1 0 12.6"></path>
22729    </svg>`;
22730}
22731const normalizeMiningSentence = (sentence) => yomuKanjiStudyCompanion()?.normalizeMiningSentence?.(sentence) ?? (sentence ?? "").replace(/\s+/g, " ").trim();
22732const inferMiningSourceKind = (hints = {}) => yomuKanjiStudyCompanion()?.inferMiningSourceKind?.(hints) ?? (hints.isImageSource ? "image" : hints.hasVideo ? "video" : (hints.hostname ?? location.hostname) === "jpdb.io" ? "jpdb" : "page");
22733const resolveMiningContext = async (options) => yomuKanjiStudyCompanion()?.resolveMiningContext?.(options) ?? {
22734...fallbackStored(options.term, fallbackDraft(
22735normalizeMiningSentence(options.sentence) || options.term.trim(),
22736options.imageDataUrl ? "image" : options.videoImageDataUrl ? "video" : options.sourceKind ?? inferMiningSourceKind()
22737), Date.now()),
22738imageDataUrl: options.imageDataUrl ?? options.videoImageDataUrl
22739};
22740const loadMiningContext = (term) => yomuKanjiStudyCompanion()?.loadMiningContext?.(term) ?? null;
22741function fallbackDraft(sentence, sourceKind) {
22742return {
22743sentence,
22744sourceKind,
22745sourceTitle: document.title || location.hostname,
22746sourceUrl: location.href
22747};
22748}
22749function fallbackStored(term, context, updatedAt) {
22750return {
22751...context,
22752term: term.trim(),
22753sentence: context.sentence.trim() || term.trim(),
22754updatedAt
22755};
22756}
22757const POS_LABELS = {
22758abbr: "abbreviation",
22759adj: "adjective",
22760"adj-f": "pre-noun adjective",
22761"adj-i": "i-adjective",
22762"adj-ix": "yoi/ii adjective",
22763"adj-na": "na-adjective",
22764"adj-no": "no-adjective",
22765"adj-pn": "pre-noun adjectival",
22766"adj-t": "taru adjective",
22767adv: "adverb",
22768"adv-to": "adverb taking to",
22769arch: "archaic",
22770ateji: "phonetic kanji spelling",
22771aux: "auxiliary",
22772"aux-adj": "auxiliary adjective",
22773"aux-v": "auxiliary verb",
22774col: "colloquial",
22775conj: "conjunction",
22776cop: "copula",
22777ctr: "counter",
22778dated: "dated term",
22779derog: "derogatory",
22780exp: "expression",
22781fam: "familiar language",
22782fem: "female language",
22783form: "formal language",
22784gikun: "special kanji reading",
22785hon: "honorific language",
22786hum: "humble language",
22787id: "idiomatic expression",
22788ik: "irregular kana form",
22789io: "irregular okurigana",
22790int: "interjection",
22791joc: "jocular language",
22792male: "male language",
22793n: "noun",
22794"n-adv": "adverbial noun",
22795"n-pr": "proper noun",
22796"n-pref": "noun used as a prefix",
22797"n-suf": "noun used as a suffix",
22798"n-t": "temporal noun",
22799"net-sl": "internet slang",
22800num: "number",
22801obs: "obsolete term",
22802obsc: "obscure term",
22803ok: "outdated kana form",
22804"on-mim": "onomatopoeic or mimetic word",
22805pn: "pronoun",
22806poet: "poetic term",
22807pol: "polite language",
22808pref: "prefix",
22809prt: "particle",
22810proverb: "proverb",
22811rare: "rare term",
22812rk: "rare kana form",
22813sens: "sensitive term",
22814sl: "slang",
22815suf: "suffix",
22816unc: "unclassified",
22817uk: "usually written using kana alone",
22818vi: "intransitive verb",
22819vt: "transitive verb",
22820v1: "ichidan verb",
22821v5: "godan verb",
22822v5aru: "aru ending",
22823v5b: "bu ending",
22824v5g: "gu ending",
22825v5k: "ku ending",
22826v5m: "mu ending",
22827v5n: "nu ending",
22828v5r: "ru ending",
22829v5s: "su ending",
22830v5t: "tsu ending",
22831v5u: "u ending",
22832vk: "kuru verb",
22833vs: "suru verb",
22834"vs-c": "su verb",
22835"vs-i": "suru verb",
22836"vs-s": "suru verb (special class)",
22837vulgar: "vulgar expression",
22838vz: "zuru verb"
22839};
22840function formatPartOfSpeech(tags = []) {
22841const labels = tags.map((tag) => POS_LABELS[tag.toLowerCase()] ?? tag).filter(Boolean);
22842return [...new Set(labels)].join(", ");
22843}
22844function formatPartOfSpeechDetails(tags = []) {
22845return tags.length ? tags.join(", ").toUpperCase() : "";
22846}
22847Logger.scope("KanjiOrigin");
22848const KANJI_RE = /[\p{Script=Han}\u2e80-\u2eff\u2f00-\u2fdf\u31c0-\u31ef\u3005\u3006\u3007々〆ヶ]/u;
22849const JAPANESE_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]/u;
22850function cleanText(value) {
22851return value.replace(/\s+/g, " ").trim();
22852}
22853function firstReviewGlyph(text2) {
22854const direct = text2.match(KANJI_RE);
22855if (direct) return direct[0];
22856try {
22857return decodeURIComponent(text2).match(KANJI_RE)?.[0] ?? null;
22858} catch {
22859return null;
22860}
22861}
22862Logger.scope("JpdbKanji");
22863new Map(
22864"heart=心=心|fishhook=乙=乙|fishguts=乙=乙|fish guts=乙=乙|stick=丨|walking stick=丨|drop=丶|drops=丶|a drop of=丶|hook right=⺃|hook (right)=⺃|state of mind=⺖|valentine=⺗|animal legs=ハ|human legs=儿|wind=几|bound up=勹|bound up small=⺈|bound up (small)=⺈|horns=丷|saber=⺉|little=⺌|cliff=厂|water=⺡|fire=⺣|hood=冂|house=宀|flower=艹|pack of wild dogs=⺨|cow left=牜|cow top=⺧|umbrella=𠆢|road=⻌|walking legs=夂|crown=冖|top hat=亠|taskmaster=攵|fiesta=戈|stretch=廴|zoo=疋|zoo left=⺪|cloak=⻂|ice left=冫|ice bottom=⺀|reclining=𠂉|wings=羽=羽|feathers=羽=羽|person=⺅|finger=扌|two hands bottom=廾|elbow=厶|going=彳|altar=⺭|broom=彐|broom old=⺔|rake=⺺|shovel=凵|old man=耂|cocoon=幺|stamp=卩|chop seal=ㄗ|chop seal small=マ|silver=艮|sheaf=㐅|cornucopia=丩|key=ユ|sickness=疒|box=匚|shape=彡|row=业|city walls right=⻏".split("|").map((value) => {
22865const [key, glyph, kanji] = value.split("=");
22866return [key, kanji ? { glyph, kanji } : { glyph }];
22867})
22868);
22869async function localPitchResolutionFromMetaLookup(spelling, reading, lookupMeta, options = {}) {
22870const expression = spelling.trim();
22871const pronunciation = reading.trim();
22872if (!expression || !pronunciation) return { patterns: [] };
22873const initialEntries = options.initialEntries ?? await lookupMeta(expression);
22874const patterns = localPitchPatternsFromMeta(expression, pronunciation, initialEntries);
22875if (patterns.length) return { patterns };
22876return { patterns: await deconjugatedHeibanPitchPatterns(expression, pronunciation, lookupMeta) };
22877}
22878const DECONJUGATION_PITCH_CANDIDATE_LIMIT = 4;
22879const KANA_SUFFIX_RE = new RegExp(`^[${KANA}${COMBINING_KANA_MARKS}]*$`, "u");
22880async function deconjugatedHeibanPitchPatterns(expression, reading, lookupMeta) {
22881const candidates = activeLearningTarget().lookupCandidates(expression).filter((candidate) => candidate.term !== expression).slice(0, DECONJUGATION_PITCH_CANDIDATE_LIMIT);
22882for (const candidate of candidates) {
22883const baseReading = deconjugatedReading(expression, candidate.term, reading);
22884if (!baseReading) continue;
22885const basePatterns = localPitchPatternsFromMeta(candidate.term, baseReading, await lookupMeta(candidate.term));
22886if (!basePatterns.length) continue;
22887const heiban = basePatterns.some((pattern) => pitchNumberForReading([pattern], baseReading) === 0);
22888return heiban ? [pitchPatternFromPosition(reading, 0)].filter(Boolean) : [];
22889}
22890return [];
22891}
22892function deconjugatedReading(expression, baseTerm, reading) {
22893const expressionChars = Array.from(expression);
22894const baseChars = Array.from(baseTerm);
22895let shared = 0;
22896while (shared < expressionChars.length && shared < baseChars.length && expressionChars[shared] === baseChars[shared]) shared++;
22897const removed = expressionChars.slice(shared).join("");
22898const added = baseChars.slice(shared).join("");
22899if (!KANA_SUFFIX_RE.test(removed) || !KANA_SUFFIX_RE.test(added)) return "";
22900if (removed && !reading.endsWith(removed)) return "";
22901const stem = removed ? reading.slice(0, reading.length - removed.length) : reading;
22902if (!stem && !added) return "";
22903return stem + added;
22904}
22905function localPitchPatternFromMeta(expression, reading, entries2) {
22906return localPitchPatternsFromMeta(expression, reading, entries2)[0] ?? "";
22907}
22908function localPitchPatternsFromMeta(expression, reading, entries2) {
22909const normalizedExpression = expressionIdentity(expression);
22910const normalizedReading = readingIdentity(reading);
22911if (!normalizedExpression || !normalizedReading) return [];
22912return collectPitchPatterns(normalizedExpression, normalizedReading, reading, entries2);
22913}
22914function collectPitchPatterns(normalizedExpression, normalizedReading, reading, entries2) {
22915const patterns = [];
22916for (const entry of entries2) {
22917if (entry.mode !== "pitch") continue;
22918if (expressionIdentity(entry.expression ?? "") !== normalizedExpression) continue;
22919for (const candidate of readPitchCandidates(entry.data, normalizedReading)) {
22920const pattern = pitchPatternFromCandidate(reading, candidate);
22921if (pattern && !patterns.includes(pattern)) patterns.push(pattern);
22922}
22923}
22924return patterns;
22925}
22926function readPitchCandidates(value, normalizedReading) {
22927const record2 = objectRecord$1(value);
22928if (!record2 || !pitchMetadataReadingMatches(record2, normalizedReading)) return [];
22929const candidates = pitchPositionCandidates(record2).map((candidate) => pitchCandidateFromValue(candidate)).filter((candidate) => candidate != null);
22930if (candidates.length) return candidates;
22931const direct = pitchCandidateFromValue(record2.position);
22932return direct == null ? [] : [direct];
22933}
22934function pitchPatternFromCandidate(reading, candidate) {
22935return typeof candidate === "number" ? pitchPatternFromPosition(reading, candidate) : normalizePitchPatterns
22935ForReading([candidate], reading)[0] ?? "";
22936}
22937function pitchMetadataReadingMatches(record2, normalizedReading) {
22938const metadataReading = typeof record2.reading === "string" ? record2.reading : "";
22939return readingIdentity(metadataReading) === normalizedReading;
22940}
22941function expressionIdentity(value) {
22942return value.trim().normalize("NFKC");
22943}
22944function readingIdentity(value) {
22945return expressionIdentity(value).replace(/[ァ-ヶ]/gu, (character) => String.fromCharCode(character.charCodeAt(0) - 96));
22946}
22947function pitchPositionCandidates(record2) {
22948if (Array.isArray(record2.pitches)) return record2.pitches;
22949return Array.isArray(record2.positions) ? record2.positions : [];
22950}
22951function pitchCandidateFromValue(value) {
22952const direct = directPitchCandidateValue(value);
22953if (direct !== null) return direct;
22954if (!value || typeof value !== "object") return null;
22955const record2 = value;
22956return pitchCandidateFromValue(record2.position);
22957}
22958function directPitchCandidateValue(value) {
22959if (typeof value === "number") return validPitchPosition(value);
22960if (typeof value === "string" && value.trim()) {
22961const trimmed = value.trim();
22962if (/^[HL]+$/i.test(trimmed)) return trimmed.toUpperCase();
22963return validPitchPosition(Number(trimmed));
22964}
22965return null;
22966}
22967function validPitchPosition(value) {
22968return Number.isInteger(value) && value >= 0 ? value : null;
22969}
22970function objectRecord$1(value) {
22971return value && typeof value === "object" ? value : null;
22972}
22973const MAX_PITCH_VARIANTS = 3;
22974function renderPitch(card, metaEntries = []) {
22975const reading = cardPronunciationReading(card);
22976if (!reading) return "";
22977const variants = collectPitchVariants(reading, [
22978...card.pitchAccent ?? [],
22979...localPitchPatternsFromMeta(card.spelling, reading, metaEntries)
22980], MAX_PITCH_VARIANTS);
22981return renderPitchVariantGraphs(reading, variants);
22982}
22983function renderPitchVariantGraphs(reading, variants) {
22984const graphs = variants.map((variant) => ({
22985variant,
22986svg: renderPitchGraphSvg(reading, variant.pattern, { centerContent: true })
22987})).filter((entry) => entry.svg);
22988if (!graphs.length) return "";
22989if (graphs.length === 1) return `<div class="jpdb-reader-pitch">${graphs[0].svg}</div>`;
22990const percentages = pitchVariantDisplayPercentages(graphs.map((entry) => entry.variant));
22991const fit = pitchVariantBlockFit(reading, graphs.length);
22992return `<div class="jpdb-reader-pitch jpdb-reader-pitch-variants" data-pitch-fit="${fit}">${graphs.map((entry, index) => `<span class="jpdb-reader-pitch-component jpdb-reader-pitch-variant${index === 0 ? " jpdb-reader-pitch-variant-primary" : ""}">${entry.svg}<span class="jpdb-reader-pitch-variant-badge">${percentages[index]}%</span></span>`).join("")}</div>`;
22993}
22994const PITCH_VARIANT_COMPACT_MAX_WIDTH = 300;
22995function pitchVariantBlockFit(reading, graphCount) {
22996const graphWidth = splitMorae(reading).length * 24 + 18;
22997const chipWidth = graphWidth + 14;
22998const blockWidth = graphCount * chipWidth + Math.max(0, graphCount - 1) * 8;
22999return blockWidth <= PITCH_VARIANT_COMPACT_MAX_WIDTH ? "compact" : "wide";
23000}
23001function pitchVariantDisplayPercentages(variants) {
23002if (!variants.length) return [];
23003const supplied = variants.map((variant) => variant.commonality);
23004const hasCompleteCommonality = supplied.every((value) => Number.isFinite(value) && (value ?? 0) >= 0) && supplied.some((value) => (value ?? 0) > 0);
23005const weights = hasCompleteCommonality ? supplied.map((value) => value ?? 0) : variants.map((_, index) => variants.length - index);
23006const total = weights.reduce((sum, value) => sum + value, 0);
23007const exact = weights.map((value) => value * 100 / total);
23008const percentages = exact.map(Math.floor);
23009const remainder = 100 - percentages.reduce((sum, value) => sum + value, 0);
23010const remainderOrder = exact.map((value, index) => ({ index, remainder: value - percentages[index] })).sort((a, b) => b.remainder - a.remainder || a.index - b.index);
23011for (let index = 0; index < remainder; index++) percentages[remainderOrder[index].index]++;
23012return percentages;
23013}
23014const EXPRESSION_CONNECTIVE_KANA = new Set(["を", "が", "に", "で", "と", "は", "も", "へ", "や", "の", "お", "ご"]);
23015function matchesAt(characters, cursor, value) {
23016return Array.from(value).every((part, index) => characters[cursor + index] === part);
23017}
23018function isConnectiveKanaOnly(component) {
23019const characters = Array.from(component.text);
23020return characters.length > 0 && component.text === component.reading && characters.every((character) => EXPRESSION_CONNECTIVE_KANA.has(character));
23021}
23022function alignExpressionComponentSegments(card, components, componentPitches, mode = "strict") {
23023if (!components.length) return null;
23024const spellingChars = Array.from(card.spelling.trim());
23025const readingChars = Array.from(cardPronunciationReading(card));
23026const segments = [];
23027let spellingCursor = 0;
23028let readingCursor = 0;
23029let hadSkippedConnective = false;
23030const skipConnectives = (next) => {
23031let run = "";
23032while (spellingCursor < spellingChars.length && spellingChars[spellingCursor] === readingChars[readingCursor] && EXPRESSION_CONNECTIVE_KANA.has(spellingChars[spellingCursor]) && !(next && matchesAt(spellingChars, spellingCursor, next.text) && matchesAt(readingChars, readingCursor, next.reading))) {
23033run += spellingChars[spellingCursor];
23034spellingCursor += 1;
23035readingCursor += 1;
23036hadSkippedConnective = true;
23037}
23038if (run) segments.push({ text: run, pitch: null });
23039};
23040for (const component of components) {
23041skipConnectives(component);
23042if (!matchesAt(spellingChars, spellingCursor, component.text) || !matchesAt(readingChars, readingCursor, component.reading)) return null;
23043const text2 = spellingChars.slice(spellingCursor, spellingCursor + Array.from(component.text).length).join("");
23044spellingCursor += Array.from(component.text).length;
23045readingCursor += Array.from(component.reading).length;
23046const pitch = componentPitches.find((candidate) => candidate.text === component.text && candidate.reading === component.reading);
23047if (pitch) {
23048segments.push({ text: text2, pitch });
23049continue;
23050}
23051if (isConnectiveKanaOnly(component)) {
23052hadSkippedConnective = true;
23053segments.push({ text: text2, pitch: null });
23054continue;
23055}
23056if (mode === "permissive") {
23057segments.push({ text: text2, pitch: null });
23058continue;
23059}
23060return null;
23061}
23062skipConnectives();
23063if (spellingCursor !== spellingChars.length || readingCursor !== readingChars.length) return null;
23064const aligned = segments.filter((segment) => segment.pitch);
23065if (!aligned.length) return null;
23066const meaningfulSpan = mode === "permissive" ? components.length : aligned.length;
23067if (meaningfulSpan < 2 && !hadSkippedConnective) return null;
23068return segments;
23069}
23070function alignedExpressionComponentPitches(card, components, componentPitches) {
23071const segments = alignExpressionComponentSegments(card, components, componentPitches, "permissive");
23072if (!segments) return [];
23073return segments.map((segment) => segment.pitch).filter((pitch) => Boolean(pitch));
23074}
23075function headwordComponentPitchSegments(card, components, componentPitches) {
23076return alignExpressionComponentSegments(card, components, componentPitches) ?? [];
23077}
23078function renderExpressionComponentPitches(components) {
23079const graphs = components.map((component) => ({
23080component,
23081svg: renderPitchGraphSvg(component.reading, component.pitch, { centerContent: true })
23082})).filter((entry) => entry.svg).map((entry) => `<span class="jpdb-reader-pitch-component">
23083            ${entry.svg}
23084            <span class="jpdb-reader-pitch-component-label">${escapeHtml(entry.component.text)}</span>
23085        </span>`);
23086if (!graphs.length) return "";
23087return `<div class="jpdb-reader-pitch jpdb-reader-pitch-components">${graphs.join("")}</div>`;
23088}
23089function renderPitchGraphSvg(reading, pitch, options = {}) {
23090const morae = splitMorae(reading);
23091const highs = pitchLevelsForDisplay(pitch, reading);
23092if (!highs.length || highs.length !== morae.length) return "";
23093const width = morae.length * 24 + 18;
23094const startX = options.centerContent ? 21 : 9;
23095const point = (index) => `${startX + index * 24},${highs[index] === "H" ? 10 : 29}`;
23096const cls = pitchClassNameForPattern(pitch, reading) || "unknown";
23097const points = highs.map((_, index) => point(index)).join(" ");
23098return `<svg width="${width}" height="46" viewBox="0 0 ${width} 46" aria-hidden="true">
23099        ${highs.length > 1 ? `<polyline class="${cls}" points="${points}"></polyline>` : ""}
23100        ${highs.map((_, index) => `<circle class="${cls}" cx="${startX + index * 24}" cy="${highs[index] === "H" ? 10 : 29}" r="3"></circle>`).join("")}
23101        ${morae.map((mora, index) => `<text x="${startX + index * 24}" y="44" text-anchor="middle">${escapeHtml(mora)}</text>`).join("")}
23102    </svg>`;
23103}
23104function cardPronunciationReading(card) {
23105const reading = pronunciationCandidate(card.reading);
23106if (reading) return reading;
23107const rubyReading = pronunciationCandidate(readingFromWordWithReading(card.wordWithReading ?? ""));
23108if (rubyReading) return rubyReading;
23109return pronunciationCandidate(card.spelling);
23110}
23111function uniqueKanji(value) {
23112return [...new Set(Array.from(value).filter(isKanjiCharacter))];
23113}
23114function isKanjiCharacter(value) {
23115return isUnifiedIdeograph(value);
23116}
23117function containsKanji(value) {
23118return Array.from(value).some(isKanjiCharacter);
23119}
23120function cleanPronunciationReading(value) {
23121return value.replace(/\s+/g, "").trim();
23122}
23123function pronunciationCandidate(value) {
23124const reading = cleanPronunciationReading(value);
23125if (!reading || containsKanji(reading)) return "";
23126return isKanaPronunciation(reading) ? reading : "";
23127}
23128function isKanaPronunciation(value) {
23129return /^[\u3040-\u30ff]+$/u.test(value);
23130}
23131function readingFromWordWithReading(value) {
23132let reading = "";
23133let offset = 0;
23134const rubyPattern = /([^\[\]]+)\[([^\]]+)\]/g;
23135for (const match of value.matchAll(rubyPattern)) {
23136const index = match.index ?? 0;
23137reading += unannotatedPronunciationText(value.slice(offset, index));
23138reading += match[2] ?? "";
23139offset = index + match[0].length;
23140}
23141reading += unannotatedPronunciationText(value.slice(offset));
23142return reading;
23143}
23144function unannotatedPronunciationText(value) {
23145return Array.from(value).filter((character) => !isKanjiCharacter(character)).join("");
23146}
23147function tokensOverlappingSelection(tokens = [], selected, parsedText = selected) {
23148if (!tokens.length) return [];
23149const start = parsedText.indexOf(selected);
23150if (start < 0) return parsedText === selected ? tokens : [];
23151const end = start + selected.length;
23152return tokens.filter((token) => token.start < end && token.end > start);
23153}
23154function extractIpaPronunciations(entries2, match) {
23155const forms = [match.expression, match.reading ?? ""].map(normalizeLookupForm);
23156const result = [];
23157const seen = new Set();
23158for (const entry of entries2) {
23159const data = entry.data;
23160if (entry.mode !== "ipa" || !forms.includes(normalizeLookupForm(entry.expression ?? "")) || !data || typeof data !== "object" || !Array.isArray(data.transcriptions)) continue;
23161if (data.reading && (typeof data.reading !== "string" || !forms.includes(normalizeLookupForm(data.reading)))) continue;
23162for (const transcription of data.transcriptions) {
23163const value = transcription == null ? void 0 : transcription.ipa;
23164if (typeof value !== "string") continue;
23165const ipa = value.trim();
23166if (!ipa || seen.has(ipa)) continue;
23167seen.add(ipa);
23168result.push({ ipa, dictionary: entry.dictionary });
23169}
23170}
23171return result;
23172}
23173function normalizeLookupForm(value) {
23174return value.normalize("NFKC").trim().toLowerCase();
23175}
23176function renderPronunciation(options) {
23177if (!options.settings.showPitchAccent) return "";
23178const target = learningTargetModuleFor(options.card.language ?? "ja") ?? defaultLearningTargetModule();
23179switch (target.featureSemantics.pronunciation) {
23180case "pitch-accent":
23181return renderPitchAccentPronunciation(options);
23182case "ipa":
23183return renderIpaPronunciation(options);
23184default:
23185return "";
23186}
23187}
23188function cardUsesPitchAccentPronunciation(card) {
23189const target = learningTargetModuleFor(card.language ?? "ja") ?? defaultLearningTargetModule();
23190return target.featureSemantics.pronunciation === "pitch-accent";
23191}
23192function renderPitchAccentPronunciation(options) {
23193const whole = renderPitch(options.card, [...options.metaEntries ?? []]);
23194if (whole) return pronunciationRow("pitch-accent", whole);
23195const alignedComponents = options.loading ? [] : alignedExpressionComponentPitches(
23196options.card,
23197[...options.expressionComponents ?? []],
23198[...options.componentPitches ?? []]
23199);
23200const components = renderExpressionComponentPitches(alignedComponents);
23201if (components) return pronunciationRow("pitch-accent", components);
23202if (options.loading) return "";
23203const label = uiText$1(options.settings.interfaceLanguage, "noExactPitch");
23204return pronunciationRow(
23205"pitch-accent",
23206`<div class="jpdb-reader-pitch jpdb-reader-pitch-missing" data-pitch-status="no-exact-match" role="status" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`
23207);
23208}
23209function renderIpaPronunciation(options) {
23210const disabled = new Set(
23211options.settings.dictionaryPreferences.filter((preference) => !preference.enabled).map((preference) => preference.name)
23212);
23213const pronunciations = extractIpaPronunciations(options.metaEntries ?? [], {
23214expression: options.card.spelling,
23215reading: options.card.reading
23216}).filter((pronunciation) => !disabled.has(pronunciation.dictionary));
23217if (!pronunciations.length) return "";
23218const variants = pronunciations.map(({ ipa, dictionary }) => {
23219const source = options.dictionaryLabel(dictionary) || dictionary;
23220const accessibleLabel = `IPA ${ipa}. ${source}`;
23221return `<span class="jpdb-reader-pronunciation-variant" data-dictionary="${escapeHtml(dictionary)}" data-pronunciation-source="local" title="${escapeHtml(accessibleLabel)}" aria-label="${escapeHtml(accessibleLabel)}"><span aria-hidden="true">IPA </span>${escapeHtml(ipa)}</span>`;
23222}).join("");
23223const label = uiText$1(options.settings.interfaceLanguage, "pronunciation");
23224return `<div class="jpdb-reader-pronunciation jpdb-reader-pronunciation-ipa" data-pronunciation-kind="ipa" role="group" aria-label="${escapeHtml(label)}">${variants}</div>`;
23225}
23226function pronunciationRow(kind, content) {
23227return `<div class="jpdb-reader-pronunciation" data-pronunciation-kind="${kind}">${content}</div>`;
23228}
23229function hasFrequencyRankEvidence(card, metaEntries, providerRanks) {
23230return frequencyRank(card.frequencyRank) !== null || metaEntries.some((entry) => entry.mode === "freq" && Boolean(formatMetaFrequency(entry.data))) || Object.values(providerRanks ?? {}).some((evidence) => frequencyRank(evidence?.rank) !== null);
23231}
23232function contextOccurrenceCount({ spelling, language = "ja" }, context = "") {
23233let normalize = normalizeIdentityText;
23234const target = learningTargetModuleFor(language);
23235if (target) normalize = (value) => target.normalizeText(value);
23236const surface = normalize(spelling);
23237const text2 = normalize(context);
23238if (!surface) return 0;
23239let count = 0;
23240for (let offset = text2.indexOf(surface); offset >= 0; offset = text2.indexOf(surface, offset + surface.length)) {
23241count++;
23242}
23243return count;
23244}
23245function frequencyProviderForLookupId(id) {
23246if (id === "jiten-frequency") return "jiten";
23247if (id === "jpdb-frequency") return "jpdb";
23248if (id === "bunpro-frequency") return "bunpro";
23249return null;
23250}
23251const BUNPRO_PRIMARY_LIST_ORDER = ["general", "dictionary", "netflix", "anime", "novels"];
23252function bunproFrequencyRank(card, info) {
23253const lists = (info?.frequencies ?? []).filter((entry) => Number.isInteger(entry.rank) && entry.rank > 0);
23254if (!info || !lists.length) return null;
23255const primary = [...lists].sort((a, b) => listOrderIndex(a.list) - listOrderIndex(b.list))[0];
23256return {
23257provider: "bunpro",
23258rank: primary.rank,
23259spelling: normalizeIdentityText(card.spelling || info.expression),
23260reading: normalizeIdentityText(card.reading || info.reading),
23261source: "live-search",
23262lists
23263};
23264}
23265function listOrderIndex(list) {
23266const index = BUNPRO_PRIMARY_LIST_ORDER.indexOf(list);
23267return index < 0 ? BUNPRO_PRIMARY_LIST_ORDER.length : index;
23268}
23269function liveFrequencyEnabled(settings2, provider) {
23270const frequencyEnabled = settings2.dictionaryLookupLinks.some(
23271(link) => link.enabled && frequencyProviderForLookupId(link.id) === provider
23272);
23273const lookupEnabled = settings2.dictionaryLookupLinks.some((link) => link.enabled && link.id === provider);
23274return frequencyEnabled && lookupEnabled;
23275}
23276function kanjiFrequencyRanks(kanji, jitenKanjiRank, jpdbKanjiFrequency) {
23277const ranks = {};
23278const jitenRank = frequencyRank(jitenKanjiRank ?? null);
23279if (jitenRank) {
23280ranks.jiten = { provider: "jiten", rank: jitenRank, spelling: kanji, reading: kanji, source: "kanji" };
23281}
23282const jpdb = jpdbKanjiFrequencyEvidence(kanji, jpdbKanjiFrequency ?? "");
23283if (jpdb) ranks.jpdb = jpdb;
23284return ranks;
23285}
23286function jpdbKanjiFrequencyEvidence(kanji, frequency) {
23287const text2 = frequency.trim();
23288const match = /([\d,]+)/.exec(text2);
23289const rank = match?.[1] ? Number.parseInt(match[1].replace(/,/g, ""), 10) : NaN;
23290if (!Number.isInteger(rank) || rank <= 0) return null;
23291return {
23292provider: "jpdb",
23293rank,
23294spelling: kanji,
23295reading: kanji,
23296source: "kanji",
23297display: /^top\b/i.test(text2) ? text2 : void 0
23298};
23299}
23300function cardFrequencyRanks(card, isJpdbBackedCard) {
23301const rank = frequencyRank(card.frequencyRank);
23302if (!rank) return {};
23303const provider = card.source === "jiten" || card.reviewSource === "jiten-api" ? "jiten" : isJpdbBackedCard(card) ? "jpdb" : null;
23304return provider ? {
23305[provider]: rankEvidence(provider, rank, card, "card")
23306} : {};
23307}
23308function jitenFrequencyRankForCard(card, info) {
23309const rank = frequencyRank(info?.mainReading?.frequencyRank);
23310return rank ? rankEvidence("jiten", rank, card, "live-search") : null;
23311}
23312function exactJitenFrequencyRank(card, candidates) {
23313return exactSearchFrequencyRank("jiten", card, candidates);
23314}
23315function exactJpdbFrequencyRank(card, candidates) {
23316return exactSearchFrequencyRank("jpdb", card, candidates);
23317}
23318function exactSearchFrequencyRank(provider, card, candidates) {
23319const spelling = normalizeIdentityText(card.spelling);
23320const reading = normalizeIdentityText(card.reading);
23321const match = candidates.find(
23322(candidate) => normalizeIdentityText(candidate.spelling) === spelling && normalizeIdentityText(candidate.reading) === reading && frequencyRank(candidate.frequencyRank) !== null
23323);
23324const rank = frequencyRank(match?.frequencyRank);
23325return match && rank ? rankEvidence(provider, rank, match, "live-search") : null;
23326}
23327function withFrequencyRank(ranks, evidence) {
23328return evidence ? { ...ranks, [evidence.provider]: evidence } : ranks;
23329}
23330function rankEvidence(provider, rank, card, source) {
23331return {
23332provider,
23333rank,
23334spelling: normalizeIdentityText(card.spelling),
23335reading: normalizeIdentityText(card.reading),
23336source
23337};
23338}
23339function frequencyRank(value) {
23340return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
23341}
23342function normalizeIdentityText(value) {
23343return value.normalize("NFKC").trim();
23344}
23345function jpdbVocabularyUrl(card) {
23346return `https://jpdb.io/vocabulary/${card.vid}/${encodeURIComponent(card.spelling)}/${encodeURIComponent(card.reading)}`;
23347}
23348function bunproDefinitionStatusAttributes(status) {
23349if (!status) return "";
23350const reason = "reason" in status ? ` data-bunpro-definition-reason="${escapeHtml(status.reason)}"` : "";
23351return ` data-bunpro-definition-status="${escapeHtml(status.state)}"${reason}`;
23352}
23353const LANGUAGE_ENDONYMS = Object.freeze({
23354ja: "日本語",
23355zh: "中文",
23356yue: "粵語",
23357lzh: "文言"
23358});
23359function headwordLanguageEndonym(language) {
23360return LANGUAGE_ENDONYMS[language] ?? language;
23361}
23362function headwordLanguageName(language, locale = "en") {
23363const display = languageDisplayName(language, locale);
23364return display === language ? headwordLanguageEndonym(language) : display;
23365}
23366function targetLanguageDisplayName(settings2) {
23367return activeTargetLanguageDisplayName(settings2.interfaceLanguage);
23368}
23369function activeTargetLanguageDisplayName(interfaceLanguage) {
23370return targetLanguageDisplayNameFor(activeLearningTargetLanguage(), interfaceLanguage);
23371}
23372function targetLanguageDisplayNameFor(tag, interfaceLanguage) {
23373const uiLanguage = resolveUiLanguage$1(interfaceLanguage);
23374const subtag = languageSubtag(tag) ?? "ja";
23375return rosterIdentityDisplayName(subtag, uiLanguage) ?? headwordLanguageName(subtag, uiLanguage);
23376}
23377function rosterIdentityDisplayName(subtag, uiLanguage) {
23378const identity = ROSTER_IDENTITY_BY_RUNTIME_SUBTAG[subtag] ?? subtag;
23379return TARGET_IDENTITY_NAMES[identity]?.[uiLanguage];
23380}
23381const TARGET_IDENTITY_NAMES = {
23382sh: { en: "Serbo-Croatian", ja: "セルボ・クロアチア語" },
23383tl: { en: "Tagalog", ja: "タガログ語" }
23384};
23385const ROSTER_IDENTITY_BY_RUNTIME_SUBTAG = {
23386bs: "sh",
23387fil: "tl",
23388hr: "sh",
23389sr: "sh"
23390};
23391function activeContentLanguageAxes(settings2) {
23392const targetLanguage = activeLearningTargetLanguage();
23393const outputLanguage = outputLanguageOf(settings2);
23394return {
23395targetLanguage,
23396targetName: targetLanguageDisplayNameFor(targetLanguage, settings2.interfaceLanguage),
23397outputLanguage,
23398outputName: targetLanguageDisplayNameFor(outputLanguage, settings2.interfaceLanguage)
23399};
23400}
23401class CardPopoverRenderer {
23402constructor(dependencies) {
23403this.dependencies = dependencies;
23404}
23405render(card, sentence, trigger, data) {
23406const view = this.renderView(card, data);
23407const ankiSourceSection = this.renderAnkiSourceSection(card, sentence, data, view);
23408const expressionComponents = this.renderExpressionComponents(card, data, view);
23409const definitionSources = this.renderDefinitionSources(card, sentence, data, ankiSourceSection);
23410const fallbackAnkiSection = fallbackAnkiSourceSection(ankiSourceSection, definitionSources);
23411return `
23412            <div class="jpdb-reader-sheet-handle"></div>
23413            <div class="jpdb-reader-popover-body" data-card-popover${bunproDefinitionStatusAttributes(data.bunproDefinitionStatus)}>
23414                ${this.dependencies.renderWordHistory(view.language, trigger)}
23415                ${this.renderHeader(card, sentence, data, view, trigger)}
23416                ${this.renderPartOfSpeech(view)}
23417                ${expressionComponents}
23418                ${definitionSources}
23419                ${fallbackAnkiSection}
23420                ${view.loadingDetails}
23421                ${renderKanjiDefinitions(data.kanjiEntries, (key, initiallyExpanded) => this.dependencies.dictionarySourceAttributes(key, initiallyExpanded), (name) => this.dependencies.dictionaryLabel(name), void 0, uiText$1(view.language, "kanjiDictionaries"), view.language)}
23422            </div>
23423            ${this.renderActions(view)}
23424        `;
23425}
23426renderDefinitionSources(card, sentence, data, ankiSourceSection) {
23427return this.dependencies.renderDefinitionSources(
23428card,
23429data.localEntries,
23430sentence,
23431data.jpdbVocabularyInfo,
23432data.jitenVocabularyInfo ?? null,
23433data.bunproDefinitionInfo ?? null,
23434{ [ANKI_SOURCE_ID]: ankiSourceSection }
23435);
23436}
23437renderView(card, data) {
23438const cardStates = normalizeCardStates(card.cardState);
23439const state = primaryCardState(cardStates);
23440const settings2 = this.settings();
23441const language = settings2.interfaceLanguage;
23442const trustedAccountDataSurface = this.accountDataSurfaceTrusted();
23443const provider = this.apiProviderForCard(card);
23444const gradingProvider = apiSrsGradingProviderViewForCard(card, settings2, this.dependencies.isJpdbBackedCard);
23445const selectedDeckLabel = this.selectedDeckLabelForView(gradingProvider, data, trustedAccountDataSurface);
23446const reviewBlockReason = this.reviewBlockReasonForView(cardStates, data, language);
23447const miningActions = this.renderApiMiningActions(card, cardStates, language, data, gradingProvider, trustedAccountDataSurface);
23448const reviewControls = this.renderReviewControls({
23449card,
23450cardStates,
23451data,
23452provider: gradingProvider,
23453selectedDeckLabel,
23454reviewBlockReason,
23455language
23456}, trustedAccountDataSurface);
23457return {
23458cardStates,
23459state,
23460storedContext: popoverStoredContext(card, data.loading),
23461jpdbUrl: jpdbVocabularyUrl(card),
23462cardPos: formatPartOfSpeech(card.partOfSpeech),
23463cardPosDetails: formatPartOfSpeechDetails(card.partOfSpeech),
23464language,
23465provider,
23466collectAction: miningActions.collect,
23467deckStateActions: miningActions.deckState,
23468ankiActions: renderPopoverAnkiActions(data, settings2, trustedAccountDataSurface),
23469reviewTargetGutter: reviewControls.gutter,
23470reviewButtons: reviewControls.buttons,
23471metaItems: this.renderMetaItems(card, provider, state, data, trustedAccountDataSurface),
23472loadingDetails: this.renderLoadingDetails(data.loading, language),
23473audioButtonDisabled: !settings2.audioEnabled,
23474audioButtonTitle: popoverAudioButtonTitle(settings2),
23475trustedAccountDataSurface
23476};
23477}
23478selectedDeckLabelForView(provider, data, trusted) {
23479return trusted ? this.selectedApiDeckLabel(provider, data) : "";
23480}
23481reviewBlockReasonForView(cardStates, data, language) {
23482if (data.ankiLookup.primary?.primaryCardId) return "";
23483return this.reviewBlockReason(cardStates, language);
23484}
23485renderHeader(card, sentence, data, view, trigger) {
23486const wordPills = this.dependencies.renderWordPills(card, view.jpdbUrl, data.metaEntries, void 0, trigger, data.ankiLookup, data.frequencyRanks);
23487const pills = appendWordPill(wordPills, this.renderContextFrequencyPill(card, sentence, data, view.language));
23488return `<div class="jpdb-reader-header">
23489            <div class="jpdb-reader-heading">
23490                ${this.renderTitleRow(card, data, view)}
23491                ${pills}
23492            </div>
23493            <div class="jpdb-reader-card-tools">
23494                ${renderPronunciation({
23495      card,
23496      settings: this.settings(),
23497      metaEntries: data.metaEntries,
23498      expressionComponents: data.expressionComponents,
23499      componentPitches: data.componentPitches,
23500      loading: data.loading,
23501      dictionaryLabel: (name) => this.dependencies.dictionaryLabel(name)
23502    })}
23503                <button class="jpdb-reader-icon-btn jpdb-reader-audio-control" data-action="audio"${privateCommandAttributes({ kind: "card-action", action: "audio" })} aria-label="${view.audioButtonTitle}" title="${view.audioButtonTitle}"${view.audioButtonDisabled ? " disabled" : ""}>${speakerIcon()}</button>
23504            </div>
23505        </div>`;
23506}
23507renderContextFrequencyPill(card, sentence, data, language) {
23508if (data.loading || hasFrequencyRankEvidence(card, data.metaEntries, data.frequencyRanks)) return "";
23509const count = contextOccurrenceCount(card, sentence);
23510if (!count) return "";
23511const label = formatUiText(language, "contextOccurrences", { count });
23512return `<span class="jpdb-reader-pill jpdb-reader-frequency-pill" data-frequency-source="context" style="${pillStyle("frequency:context")}" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
23513}
23514renderTitleRow(card, data, view) {
23515const pitchTarget = cardUsesPitchAccentPronunciation(card);
23516const pitchClass = pitchTarget ? getPitchClass(card.pitchAccent ?? [], cardPronunciationReading(card) || card.reading) : "";
23517const spellingClass = `jpdb-reader-spelling jpdb-${view.state}${pitchClass ? ` jpdb-pitch-${pitchClass}` : ""}`;
23518const kanjiNavigation = targetUsesCharacterDictionary() ? { enabled: true, label: uiText$1(view.language, "showKanji") } : void 0;
23519const componentSegments = pitchTarget && !pitchClass && !data.loading && this.settings().showPitchAccent ? headwordComponentPitchSegments(card, data.expressionComponents ?? [], data.componentPitches ?? []) : [];
23520const componentSpelling = componentSegments.length ? renderHeadwordComponentPitchSpans(card, componentSegments, this.settings(), kanjiNavigation) : "";
23521const spellingContent = componentSpelling || renderCardSpellingWithFurigana(card, this.settings(), kanjiNavigation);
23522const pitchEvidence = componentSpelling ? ' data-pitch-evidence="components"' : "";
23523const settings2 = this.settings();
23524const axes = activeContentLanguageAxes(settings2);
23525const axesLabel = formatUiText(view.language, "popupLanguageAxes", {
23526target: axes.targetName,
23527output: axes.outputName
23528});
23529const kanjiNavigationAttributes = kanjiNavigation ? ` data-jpdb-reader-kanji-nav data-jpdb-reader-kanji-nav-label="${escapeHtml(kanjiNavigation.label)}"` : "";
23530return `<div class="jpdb-reader-title-row">
23531            <div class="${spellingClass}" data-yomu-headword data-pitch-class="${pitchClass}"${pitchEvidence}${kanjiNavigationAttributes}>${spellingContent}</div>
23532            ${renderMeta(view.metaItems)}
23533            <div class="jpdb-reader-language-axes" data-target-language="${escapeHtml(axes.targetLanguage)}" data-output-language="${escapeHtml(axes.outputLanguage)}">${escapeHtml(axesLabel)}</div>
23534        </div>`;
23535}
23536renderPartOfSpeech(view) {
23537return view.cardPos ? `<div class="jpdb-reader-pos" title="${escapeHtml(view.cardPosDetails)}">${escapeHtml(view.cardPos)}</div>` : "";
23538}
23539renderExpressionComponents(card, data, view) {
23540const components = uniqueExpressionComponents(data.expressionComponents ?? []);
23541if (data.loading || !components.length) return "";
23542if (components.length === 1 && components[0].text === card.spelling.trim()) return "";
23543const rows = components.map((component) => this.renderExpressionComponent(component, data.componentPitches ?? [])).join("");
23544return `<div class="jpdb-reader-expression-components">
23545            <ul class="jpdb-reader-jpdb-used-in jpdb-reader-expression-component-list" role="list" aria-label="${escapeHtml(uiText$1(view.language, "composedOf"))}">${rows}</ul>
23546        </div>`;
23547}
23548renderExpressionComponent(component, componentPitches) {
23549const reading = component.reading.trim();
23550const pitchClass = expressionComponentPitchClass(component, componentPitches);
23551const term = renderExpressionComponentTerm(component, pitchClass);
23552return `<li class="jpdb-reader-jpdb-used-in-row jpdb-reader-expression-component-row">
23553            <div class="jpdb-reader-jpdb-used-in-main jpdb-reader-expression-component-main">
23554                <a class="gloss-link jpdb-reader-jpdb-used-in-link jpdb-reader-expression-component-link" href="#jpdb-reader-dictionary-lookup" role="button" tabindex="0" data-dictionary-lookup="${escapeHtml(component.text)}" data-dictionary-reading="${escapeHtml(reading)}" data-external="false">
23555                    ${term}
23556                </a>
23557            </div>
23558        </li>`;
23559}
23560renderAnkiExistingSection(data, view) {
23561return data.loading ? "" : renderAnkiExistingSection(data.ankiLookup, view.storedContext, this.settings(), {
23562suppressReviewButtons: Boolean(view.reviewButtons),
23563sourceAttributes: (key, initiallyExpanded) => this.dependencies.dictionarySourceAttributes(key, initiallyExpanded),
23564trustedAccountDataSurface: view.trustedAccountDataSurface
23565});
23566}
23567renderAnkiSourceSection(card, sentence, data, view) {
23568return this.renderAnkiExistingSection(data, view) || this.renderAnkiNewCardPreview(card, sentence, data, view);
23569}
23570renderAnkiNewCardPreview(card, sentence, data, view) {
23571if (!popoverCanRenderNewAnkiPreview(data)) return "";
23572const settings2 = this.settings();
23573return renderAnkiNewCardPreview(card, sentence, settings2, {
23574localEntries: data.localEntries,
23575kanjiEntries: data.kanjiEntries,
23576metaEntries: data.metaEntries,
23577dictionaryPreferences: settings2.dictionaryPreferences,
23578...popoverAnkiSourceContext(view.storedContext)
23579}, data.ankiFieldTargetPlan, { trustedAccountDataSurface: view.trustedAccountDataSurface });
23580}
23581renderActions(view) {
23582const hasMiningPanel = Boolean(view.deckStateActions || view.ankiActions) && canExpandMiningDrawer();
23583const miningPanel = hasMiningPanel ? this.renderMiningPanel(view) : "";
23584const hasDrawer = hasMiningPanel || Boolean(view.reviewTargetGutter);
23585const miningClass = hasDrawer ? " jpdb-reader-actions-has-mining jpdb-reader-actions-mining-collapsed" : "";
23586return `<div class="jpdb-reader-actions${miningClass}">
23587            ${view.reviewTargetGutter || renderMiningGutter(miningPanel, view.language)}
23588            ${miningPanel}
23589            ${hasMiningPanel ? "" : view.ankiActions}
23590            ${view.collectAction}
23591            ${view.reviewButtons}
23592        </div>`;
23593}
23594renderMiningPanel(view) {
23595return `<div class="jpdb-reader-mining-panel">
23596            ${view.deckStateActions}
23597            ${view.ankiActions}
23598        </div>`;
23599}
23600renderApiMiningActions(card, cardStates, language, data, provider, trustedAccountDataSurface) {
23601const settings2 = this.settings();
23602const destinations = collectionDestinationsForCard(card, settings2, this.dependencies.isJpdbBackedCard);
23603return renderApiMiningActions(settings2, card, cardStates, language, data, provider, destinations, trustedAccountDataSurface);
23604}
23605renderReviewControls(options, trustedAccountDataSurface) {
23606return trustedAccountDataSurface ? this.renderTrustedReviewControls(options) : buttonsOnly(this.renderPublicReviewButtons(options));
23607}
23608renderPublicReviewButtons(options) {
23609if (!this.canRenderPublicReviewButtons(options)) return "";
23610return renderReviewButtons(this.settings(), null, { gradeProfile: reviewGradeProfile(options.card, options.provider?.id) });
23611}
23612canRenderPublicReviewButtons(options) {
23613if (options.data.loading) return false;
23614if (options.reviewBlockReason) return false;
23615if (!this.settings().enableReviews) return false;
23616return this.canReviewWithApiProvider(options.provider);
23617}
23618renderTrustedReviewControls(options) {
23619const { card, data, provider, reviewBlockReason, language } = options;
23620const earlyResult = this.reviewButtonsEarlyResult(card, data, reviewBlockReason);
23621if (earlyResult) return earlyResult;
23622const targets = this.popoverReviewTargets(card, data, provider, language);
23623if (targets.length) return this.renderTargetedReviewButtons(targets, language, targets.length > 1, this.switchProviderTarget(card, provider));
23624return this.renderUntargetedReviewControls(options);
23625}
23626renderUntargetedReviewControls(options) {
23627const { card, cardStates, data, provider, selectedDeckLabel, reviewBlockReason, language } = options;
23628if (this.shouldUseFallbackReviewButtons(card, data, provider, reviewBlockReason)) return this.renderReviewButtonsFallback(card, data);
23629return buttonsOnly(this.renderApiReviewButtons(card, provider, data, cardStates, selectedDeckLabel, language));
23630}
23631shouldUseFallbackReviewButtons(card, data, provider, reviewBlockReason) {
23632return isLiveJpdbCardOnAcademy(provider, card) || !this.shouldRenderReviewButtons(data, provider, reviewBlockReason);
23633}
23634renderReviewButtonsFallback(card, data) {
23635return this.dependencies.renderReviewButtonsFallback?.(card, data) ?? buttonsOnly("");
23636}
23637reviewButtonsEarlyResult(card, data, reviewBlockReason) {
23638if (reviewBlockReason) return buttonsOnly(`<div class="jpdb-reader-help jpdb-reader-review-blocked">${escapeHtml(reviewBlockReason)}</div>`);
23639if (data.loading || !this.settings().enableReviews) return this.renderReviewButtonsFallback(card, data);
23640return void 0;
23641}
23642renderApiReviewButtons(card, provider, data, cardStates, selectedDeckLabel, language) {
23643return renderReviewButtons(this.settings(), null, {
23644targetLabel: provider?.label ?? uiText$1(language, "gradeJpdbCardTarget"),
23645title: reviewButtonTitle(data, cardStates, selectedDeckLabel, language),
23646intervals: card.reviewGradeIntervals,
23647gradeProfile: reviewGradeProfile(card, provider?.id)
23648});
23649}
23650shouldRenderReviewButtons(data, provider, reviewBlockReason) {
23651if (reviewBlockReason || data.loading || !this.settings().enableReviews) return false;
23652return this.canReviewWithApiProvider(provider);
23653}
23654canReviewWithApiProvider(provider) {
23655const settings2 = this.settings();
23656return Boolean(provider?.hasApiKey && isApiSrsProviderEnabled(settings2, provider.id));
23657}
23658switchProviderTarget(card, provider) {
23659if (!provider || provider.id === "yomu-local" || !provider.hasApiKey) return null;
23660const cycle = apiSrsSwitchableProviderIds(card, this.settings());
23661if (cycle.length < 2) return null;
23662const next = cycle[(cycle.indexOf(provider.id) + 1) % cycle.length];
23663return next && next !== provider.id ? this.providerForReviewTarget({ id: next, kind: next, label: "", shortLabel: "", gradeProfile: "standard" }, null) : null;
23664}
23665popoverReviewTargets(card, data, provider, language) {
23666const ankiTargets = this.ankiReviewTargets(data, language);
23667if (provider?.id === "yomu-local" && ankiTargets.length) return ankiTargets;
23668const apiTargets = this.apiReviewTargets(card, provider, language);
23669if ((provider?.id === "bunpro" || provider?.id === "wanikani") && apiTargets.length) return apiTargets;
23670if (apiTargets.length && ankiTargets.length) {
23671const apiProvider = this.providerForReviewTarget(apiTargets[0], provider);
23672if (!apiProvider) return [...apiTargets, ...ankiTargets];
23673const primaryAnki = ankiTargets[0];
23674return [
23675this.bothReviewTarget(apiProvider, primaryAnki, language),
23676...apiTargets,
23677...ankiTargets
23678];
23679}
23680if (ankiTargets.length) return ankiTargets;
23681return apiTargets;
23682}
23683apiReviewTargets(card, provider, _language) {
23684if (provider?.id === "yomu-local" && card.reviewSource === "jpdb-live") return [];
23685if (provider && this.canReviewWithApiProvider(provider)) return [this.apiReviewTarget(provider, _language, card)];
23686return [];
23687}
23688providerForReviewTarget(target, fallback) {
23689if (target.kind === "jpdb") return { id: "jpdb", label: "JPDB", deckSource: "jpdb", hasApiKey: true };
23690if (target.kind === "jiten") return { id: "jiten", label: "Jiten", deckSource: "jiten", hasApiKey: true };
23691if (target.kind === "bunpro") return { id: "bunpro", label: "Bunpro", deckSource: "bunpro", hasApiKey: true };
23692if (target.kind === "wanikani") return { id: "wanikani", label: "WaniKani", deckSource: "wanikani", hasApiKey: true };
23693if (target.kind === "yomu-local") return { id: "yomu-local", label: ACADEMY_SRS_LABEL, deckSource: "yomu-local", hasApiKey: true };
23694return fallback;
23695}
23696apiReviewTarget(provider, language, card) {
23697if (provider.id === "yomu-local") {
23698return {
23699id: "yomu-local",
23700kind: "yomu-local",
23701label: uiText$1(language, "gradeTargetYomuLocal"),
23702shortLabel: provider.label,
23703gradeProfile: "standard"
23704};
23705}
23706if (provider.id === "bunpro") {
23707return {
23708id: "bunpro",
23709kind: "bunpro",
23710label: uiText$1(language, "gradeTargetBunpro"),
23711shortLabel: provider.label,
23712gradeProfile: card.bunproReviewInputMode === "fsrs" ? "bunpro-fsrs" : "bunpro-regular"
23713};
23714}
23715if (provider.id === "wanikani") {
23716return {
23717id: "wanikani",
23718kind: "wanikani",
23719label: uiText$1(language, "gradeTargetWanikani"),
23720shortLabel: provider.label,
23721gradeProfile: "standard"
23722};
23723}
23724const isJiten = provider.id === "jiten";
23725return {
23726id: provider.id,
23727kind: isJiten ? "jiten" : "jpdb",
23728label: uiText$1(language, isJiten ? "gradeTargetJiten" : "gradeTargetJpdb"),
23729shortLabel: provider.label,
23730gradeProfile: isJiten ? "jiten" : "standard"
23731};
23732}
23733bothReviewTarget(provider, ankiTarget, language) {
23734const label = provider.id === "bunpro" ? uiText$1(language, "gradeTargetBunproAndAnki") : provider.id === "yomu-local" ? uiText$1(language, "gradeTargetYomuLocalAndAnki") : provider.id === "jiten" ? uiText$1(language, "gradeTargetJitenAndAnki") : uiText$1(language, "gradeTargetJpdbAndAnki");
23735return {
23736id: "both",
23737kind: "both",
23738label: formatTargetLabel(label, ankiTarget.plainLabel ?? ankiTarget.shortLabel),
23739shortLabel: uiText$1(language, "gradeTargetBoth"),
23740ankiCardId: ankiTarget.ankiCardId,
23741gradeProfile: provider.id === "jiten" ? "jiten" : "standard"
23742};
23743}
23744ankiReviewTargets(data, language) {
23745const settings2 = this.settings();
23746if (!settings2.enableReviews || !settings2.ankiEnabled || !settings2.ankiSectionEnabled) return [];
23747const orderedNotes = data.ankiLookup.primary ? [
23748data.ankiLookup.primary,
23749...data.ankiLookup.notes.filter((note) => note !== data.ankiLookup.primary)
23750] : data.ankiLookup.notes;
23751const primary = data.ankiLookup.primary;
23752const notes = primary && !data.ankiLookup.notes.includes(primary) ? [...orderedNotes, primary] : orderedNotes;
23753return collectAnkiReviewTargetLabels([], notes).map(({ cardId, label }) => ({
23754id: `anki:${cardId}`,
23755kind: "anki",
23756ankiCardId: cardId,
23757plainLabel: label,
23758label: formatTargetLabel(uiText$1(language, "gradeTargetAnki"), label),
23759shortLabel: compactAnkiReviewTargetLabel(label, cardId),
23760gradeProfile: "anki"
23761}));
23762}
23763renderTargetedReviewButtons(targets, language, canSwitchTarget, switchProviderTarget) {
23764const settings2 = this.settings();
23765const selected = targets[0];
23766if (!selected) return buttonsOnly("");
23767const reviewGroup = canSwitchTarget ? Symbol("review-group") : void 0;
23768const profiles = new Set((canSwitchTarget ? targets : [selected]).map((target) => target.gradeProfile));
23769const gradeRows = [...profiles].map((profile) => renderTargetedGradeRow(
23770reviewGradeScale(settings2, profile),
23771selected,
23772profile,
23773selected.gradeProfile !== profile,
23774settings2,
23775reviewGroup
23776)).join("");
23777if (!gradeRows) return buttonsOnly("");
23778const selector = reviewGroup ? renderReviewTargetSelector(targets, language, reviewGroup) : "";
23779return {
23780gutter: renderReviewTargetGutter(selected, language, canSwitchTarget, switchProviderTarget),
23781buttons: `${selector}${gradeRows}`
23782};
23783}
23784renderMetaItems(card, provider, state, data, trustedAccountDataSurface) {
23785const settings2 = this.settings();
23786return [
23787renderPopoverFrequencyMeta(),
23788renderPopoverProviderMeta(card, provider, state, settings2, trustedAccountDataSurface),
23789renderPopoverAnkiMeta(data, settings2, trustedAccountDataSurface)
23790].filter(Boolean);
23791}
23792renderLoadingDetails(loading, language) {
23793return loading ? `<div class="jpdb-reader-help" data-card-detail
23793s-loading>${escapeHtml(uiText$1(language, "loadingDictionaryDetails"))}</div>` : "";
23794}
23795reviewBlockReason(cardStates, language) {
23796if (cardStates.includes("blacklisted")) return uiText$1(language, "reviewBlockedBlacklisted");
23797if (cardStates.includes("never-forget")) return uiText$1(language, "reviewBlockedNeverForget");
23798return "";
23799}
23800settings() {
23801return this.dependencies.getSettings();
23802}
23803apiProviderForCard(card) {
23804return apiSrsProviderViewForCard(card, this.settings(), this.dependencies.isJpdbBackedCard);
23805}
23806selectedApiDeckLabel(provider, data) {
23807if (provider?.id === "jiten") return jitenDeckLabel((data.jitenDecks ?? [])[0]);
23808return jpdbDeckLabel(this.settings(), this.settings().miningDeck.trim() || "forq", data.jpdbDecks);
23809}
23810accountDataSurfaceTrusted() {
23811return this.dependencies.accountDataSurfaceTrusted?.() ?? currentAccountDataSurfaceIsTrusted();
23812}
23813}
23814function fallbackAnkiSourceSection(ankiSourceSection, definitionSources) {
23815if (!ankiSourceSection) return "";
23816if (definitionSources.includes("jpdb-reader-anki-existing")) return "";
23817if (definitionSources.includes("data-account-private-launcher")) return "";
23818return ankiSourceSection;
23819}
23820function renderPopoverAnkiActions(data, settings2, trusted) {
23821if (data.loading || !trusted) return "";
23822return renderAnkiActionRow(data.ankiLookup, settings2);
23823}
23824function popoverStoredContext(card, loading) {
23825return loading ? null : loadMiningContext(card.spelling);
23826}
23827function popoverAudioButtonTitle(settings2) {
23828const key = settings2.audioEnabled ? "playAudio" : "audioPlaybackDisabled";
23829return uiText$1(settings2.interfaceLanguage, key);
23830}
23831function popoverCanRenderNewAnkiPreview(data) {
23832if (data.loading) return false;
23833if (data.ankiLookup.primary) return false;
23834if (data.ankiLookup.trusted === false) return false;
23835return data.ankiLookup.state === "not-in-deck";
23836}
23837function popoverAnkiSourceContext(context) {
23838return {
23839sourceTitle: context?.sourceTitle,
23840sourceUrl: context?.sourceUrl
23841};
23842}
23843function isLiveJpdbCardOnAcademy(provider, card) {
23844return provider?.id === "yomu-local" && card.reviewSource === "jpdb-live";
23845}
23846function renderPopoverFrequencyMeta(card, provider, settings2) {
23847return "";
23848}
23849function renderPopoverProviderMeta(card, provider, state, settings2, trusted) {
23850if (!provider || !popoverProviderStatusIsVisible(card, provider, trusted)) return "";
23851return `<span class="jpdb-reader-provider-status"><span class="jpdb-reader-state-dot jpdb-${state}"></span>${escapeHtml(provider.label)} ${escapeHtml(providerCardStateLabel(provider.id, state, settings2.interfaceLanguage))}</span>`;
23852}
23853function popoverProviderStatusIsVisible(card, provider, trusted) {
23854if (!trusted || !provider.hasApiKey) return false;
23855if (provider.id !== "yomu-local") return true;
23856return cardIsAcademyBacked(card);
23857}
23858function cardIsAcademyBacked(card) {
23859return card.source === "yomu-local" || card.reviewSource === "yomu-local";
23860}
23861function renderPopoverAnkiMeta(data, settings2, trusted) {
23862return trusted ? renderAnkiMeta(data.ankiLookup, settings2) : "";
23863}
23864function updatePopoverReviewTargetSelection(select) {
23865const selection = readReviewTargetCapability(selectedPopoverReviewOption(select));
23866if (!selection) return;
23867const actions = select.closest(".jpdb-reader-actions");
23868if (!actions) return;
23869updatePopoverReviewTargetLabels(actions, selection.label, selection.shortLabel);
23870updatePopoverReviewGradeProfile(actions, selection.gradeProfile);
23871actions.querySelectorAll('[data-review-target-row] [data-action="grade"][data-grade]').forEach((button) => updatePopoverReviewButton(button, selection));
23872}
23873function selectedPopoverReviewOption(select) {
23874return select.options[select.selectedIndex] ?? null;
23875}
23876function updatePopoverReviewTargetLabels(actions, label, shortLabel) {
23877const current = actions.querySelector("[data-review-target-current]");
23878if (current) current.textContent = shortLabel;
23879const labelText = actions.querySelector("[data-review-target-label] [data-newtab-grade-target-text]");
23880if (labelText) labelText.textContent = label;
23881}
23882function updatePopoverReviewGradeProfile(actions, gradeProfile) {
23883actions.querySelectorAll("[data-review-target-row][data-review-grade-profile]").forEach((row) => {
23884row.hidden = row.dataset.reviewGradeProfile !== gradeProfile;
23885});
23886}
23887function updatePopoverReviewButton(button, selection) {
23888button.dataset.reviewTarget = selection.target;
23889button.dataset.newtabReviewTarget = selection.target;
23890updatePopoverReviewButtonCardId(button, selection.ankiCardId);
23891updatePopoverReviewButtonLabel(button, selection.label);
23892}
23893function updatePopoverReviewButtonCardId(button, ankiCardId) {
23894if (ankiCardId) button.dataset.ankiCardId = String(ankiCardId);
23895else delete button.dataset.ankiCardId;
23896}
23897function updatePopoverReviewButtonLabel(button, label) {
23898if (!label) {
23899button.removeAttribute("title");
23900button.removeAttribute("aria-label");
23901return;
23902}
23903const buttonLabel = button.textContent?.trim() ?? "";
23904button.title = label;
23905button.setAttribute("aria-label", `${buttonLabel}: ${label}`);
23906}
23907function renderTargetedGradeRow(scale, selected, profile, hidden, settings2, reviewGroup) {
23908const { grades, shortcuts } = scale;
23909const targetLabel = renderReviewTargetLabel(selected);
23910const targetAttrs = reviewTargetButtonAttrs(selected);
23911return `<div class="jpdb-reader-row${grades.length === 5 ? " jpdb-reader-grades" : ""}" style="--cols: ${grades.length}" data-review-target-row data-review-grade-profile="${profile}"${hidden ? " hidden" : ""}>
23912        ${targetLabel}
23913        ${grades.map(([grade, label]) => {
23914    const title = selected.label ? ` title="${escapeHtml(selected.label)}" aria-label="${escapeHtml(`${label}: ${selected.label}`)}"` : "";
23915    const gradeShortcut = shortcuts.find(([, value]) => value === grade)?.[0];
23916    return `<button class="jpdb-reader-btn ${grade}" data-action="grade" data-grade="${grade}"${targetAttrs}${privateCommandAttributes({ kind: "card-action", action: "grade", grade, gradeProfile: profile, gradeShortcut, reviewGroup, reviewTarget: selected.kind === "wanikani" ? void 0 : selected.kind, ankiCardId: selected.ankiCardId })}${title}${gradeKeyHintAttributes(settings2, gradeShortcut)}>${escapeHtml(label)}</button>`;
23917  }).join("")}
23918    </div>`;
23919}
23920function buttonsOnly(buttons) {
23921return { gutter: "", buttons };
23922}
23923function togglePopoverReviewTargetSelection(button) {
23924const select = button.closest(".jpdb-reader-actions")?.querySelector("[data-review-target-select]");
23925if (!select || select.options.length < 2) return;
23926select.selectedIndex = (select.selectedIndex + 1) % select.options.length;
23927updatePopoverReviewTargetSelection(select);
23928}
23929function renderReviewTargetGutter(target, language, canSwitchTarget, switchProviderTarget) {
23930const label = uiText$1(language, "showMiningActions");
23931const switchLabel = uiText$1(language, "switchReviewTarget");
23932return `<div class="jpdb-reader-actions-gutter jpdb-reader-review-target-gutter" data-review-target-gutter>
23933        ${renderReviewTargetControl(target, language, canSwitchTarget, switchProviderTarget)}
23934        ${renderReviewTargetToggle(canSwitchTarget, switchLabel)}
23935        <button class="jpdb-reader-mining-collapse jpdb-reader-mining-drawer-handle" data-action="mining-collapse"${privateCommandAttributes({ kind: "card-ui", action: "mining-collapse" })} aria-expanded="false" aria-label="${escapeHtml(label)}"></button>
23936    </div>`;
23937}
23938function renderReviewTargetControl(target, language, canSwitchTarget, switchProviderTarget) {
23939if (!switchProviderTarget && !canSwitchTarget) return "";
23940const currentTarget2 = renderReviewTargetCurrent(target);
23941return switchProviderTarget ? renderProviderToggle(switchProviderTarget, language, currentTarget2) : currentTarget2;
23942}
23943function renderReviewTargetToggle(canSwitchTarget, label) {
23944return canSwitchTarget ? `<button class="jpdb-reader-review-target-toggle" data-action="review-target-toggle"${privateCommandAttributes({ kind: "card-ui", action: "review-target-toggle" })} aria-label="${escapeHtml(label)}">⇄</button>` : "";
23945}
23946function renderReviewTargetSelector(targets, language, reviewGroup) {
23947return `<div class="jpdb-reader-mining-panel jpdb-reader-review-target-panel" data-review-target-selector>
23948        <select class="jpdb-reader-newtab-grade-target-select" data-review-target-select aria-label="${escapeHtml(uiText$1(language, "gradeTargetSelector"))}"${privateCommandAttributes({ kind: "review-selector", reviewGroup })}>
23949            ${targets.map((target, index) => `<option value="${escapeHtml(target.id)}"${index === 0 ? " selected" : ""}${privateCommandAttributes({ kind: "review-target", target: target.kind, gradeProfile: target.gradeProfile, reviewGroup, label: target.label, shortLabel: target.shortLabel, ankiCardId: target.ankiCardId })} data-review-target="${target.kind}" data-review-grade-profile="${target.gradeProfile}" data-review-target-label="${escapeHtml(target.label)}" data-review-target-short-label="${escapeHtml(target.shortLabel)}"${target.ankiCardId ? ` data-anki-card-id="${target.ankiCardId}"` : ""}>${escapeHtml(target.shortLabel)}</option>`).join("")}
23950        </select>
23951    </div>`;
23952}
23953function renderReviewTargetCurrent(target) {
23954return `<span class="jpdb-reader-review-target-current" data-review-target-current>${escapeHtml(target.shortLabel)}</span>`;
23955}
23956function renderReviewTargetLabel(target) {
23957return `<div class="jpdb-reader-sr-only jpdb-reader-newtab-sr-only" data-review-target-label><span data-newtab-grade-target-text>${escapeHtml(target.label)}</span></div>`;
23958}
23959function reviewTargetButtonAttrs(target) {
23960return ` data-review-target="${target.kind}" data-newtab-review-target="${target.kind}"${target.ankiCardId ? ` data-anki-card-id="${target.ankiCardId}"` : ""}`;
23961}
23962function formatTargetLabel(template, target) {
23963return template.replaceAll("{target}", target);
23964}
23965function reviewButtonTitle(data, cardStates, selectedDeckLabel, language) {
23966const reviewAddsToDeck = !data.ankiLookup.primary?.primaryCardId && cardStates.includes("not-in-deck");
23967return reviewAddsToDeck ? `${uiText$1(language, "reviewAddsToDeck")} ${selectedDeckLabel}` : "";
23968}
23969function miningActionState(cardStates, language) {
23970const isNeverForget = cardStates.includes("never-forget");
23971const isBlacklisted = cardStates.includes("blacklisted");
23972return {
23973isNeverForget,
23974isBlacklisted,
23975neverForgetLabel: isNeverForget ? uiText$1(language, "forget") : uiText$1(language, "never"),
23976blacklistLabel: isBlacklisted ? uiText$1(language, "unlist") : uiText$1(language, "blacklist")
23977};
23978}
23979function renderApiMiningActions(settings2, card, cardStates, language, data, provider, destinations, trustedAccountDataSurface) {
23980if (!trustedAccountDataSurface) return { collect: destinations.length ? renderPrivateCollectAction(language) : "", deckState: "" };
23981const addDeckSelect = renderAddDeckSelect(settings2, data, language, provider, destinations);
23982const canChangeDeckState = (Boolean(addDeckSelect) || canRenderApiMiningActions(settings2, provider)) && canToggleApiDeckState(card, settings2);
23983return {
23984collect: addDeckSelect ? renderCollectAction(renderApiDeckAdd(addDeckSelect, destinations[0], language)) : "",
23985deckState: canChangeDeckState ? renderApiDeckStateActions(miningActionState(cardStates, language), language) : ""
23986};
23987}
23988function renderCollectAction(content) {
23989return `<div class="jpdb-reader-collect">${content}</div>`;
23990}
23991function renderPrivateCollectAction(language) {
23992return renderCollectAction(`<button class="jpdb-reader-btn add jpdb-reader-mining-title" data-action="add-default"${privateCommandAttributes({ kind: "card-action", action: "add-default" })}>${collectButtonLabel(language)}</button>`);
23993}
23994function collectButtonLabel(language) {
23995return `${escapeHtml(uiText$1(language, "addToDeck"))} <span aria-hidden="true">+</span>`;
23996}
23997function canToggleApiDeckState(card, settings2) {
23998return apiSrsSwitchableProviderIds(card, settings2).some((id) => id === "jpdb" || id === "jiten");
23999}
24000function canRenderApiMiningActions(settings2, provider) {
24001return Boolean(provider?.hasApiKey && isApiSrsProviderEnabled(settings2, provider.id));
24002}
24003function renderAddDeckSelect(settings2, data, language, provider, destinations) {
24004const deckOptions = renderDeckChoiceOptions(settings2, data.jpdbDecks, data.ankiDecks, {
24005includeJpdb: provider?.id === "jpdb" && destinations.includes("jpdb"),
24006includeJiten: provider?.id === "jiten" && destinations.includes("jiten"),
24007includeBunpro: destinations.includes("bunpro"),
24008includeYomuLocal: destinations.includes("yomu-local"),
24009jitenDecks: data.jitenDecks ?? []
24010});
24011if (!deckOptions) return "";
24012return `<select class="jpdb-reader-add-deck-select" data-add-deck-select aria-label="${escapeHtml(uiText$1(language, "deck"))}" hidden>${deckOptions}</select>`;
24013}
24014function renderApiDeckAdd(addDeckSelect, defaultDestination, language) {
24015const label = collectButtonLabel(language);
24016const deckSource = directCollection(addDeckSelect, defaultDestination);
24017if (!deckSource) {
24018return `<button class="jpdb-reader-btn add jpdb-reader-mining-title" data-action="deck-picker"${privateCommandAttributes({ kind: "card-ui", action: "deck-picker" })} aria-expanded="false">${label}</button>${addDeckSelect}`;
24019}
24020return `<button class="jpdb-reader-btn add jpdb-reader-mining-title" data-action="add" data-deck-source="${deckSource}"${privateCommandAttributes({ kind: "card-action", action: "add", deckSource })}>${label}</button>`;
24021}
24022function directCollection(addDeckSelect, defaultDestination) {
24023if (defaultDestination !== "bunpro" && defaultDestination !== "yomu-local") return void 0;
24024const sources = addDeckSelect.match(/data-deck-source="[^"]*"/g) ?? [];
24025return sources.length === 1 && sources[0] === `data-deck-source="${defaultDestination}"` ? defaultDestination : void 0;
24026}
24027function renderApiDeckStateActions(state, language) {
24028const neverForgetClass = state.isNeverForget ? " danger" : "";
24029return `<div class="jpdb-reader-mining-details" role="group" aria-label="${escapeHtml(uiText$1(language, "deckActions"))}">
24030<div class="jpdb-reader-row jpdb-reader-mining-action-row" style="--cols: 2">
24031<button class="jpdb-reader-btn nf${neverForgetClass}" data-action="neverforget"${privateCommandAttributes({ kind: "card-action", action: "neverforget" })} aria-pressed="${state.isNeverForget}">${state.neverForgetLabel}</button>
24032<button class="jpdb-reader-btn blacklist" data-action="blacklist"${privateCommandAttributes({ kind: "card-action", action: "blacklist" })} aria-pressed="${state.isBlacklisted}">${state.blacklistLabel}</button>
24033</div>
24034</div>`;
24035}
24036function renderAnkiMeta(lookup, settings2) {
24037if (!settings2.ankiEnabled) return "";
24038if (lookup.trusted === false && !lookup.primary) return "";
24039if (!lookup.primary && lookup.state === "not-in-deck") return "";
24040const language = settings2.interfaceLanguage;
24041return `<span><span class="jpdb-reader-state-dot anki-${lookup.state}"></span>Anki ${escapeHtml(cardStateLabel$1(lookup.state, language))}</span>`;
24042}
24043function renderMeta(metaItems) {
24044return metaItems.length ? `<div class="jpdb-reader-meta">${metaItems.join("")}</div>` : "";
24045}
24046function appendWordPill(wordPills, pill) {
24047if (!pill) return wordPills;
24048const closingTag = wordPills.includes("jpdb-reader-word-pills") ? wordPills.lastIndexOf("</div>") : -1;
24049return closingTag >= 0 ? `${wordPills.slice(0, closingTag)}${pill}${wordPills.slice(closingTag)}` : `${wordPills}<div class="jpdb-reader-word-pills">${pill}</div>`;
24050}
24051function uniqueExpressionComponents(components) {
24052const seen = new Set();
24053return components.filter((component) => {
24054const key = `${component.text}
24055${component.reading}`;
24056if (seen.has(key)) return false;
24057seen.add(key);
24058return true;
24059});
24060}
24061function expressionComponentPitchClass(component, componentPitches) {
24062const match = componentPitches.find((pitch) => pitch.text === component.text && pitch.reading === component.reading) ?? componentPitches.find((pitch) => pitch.text === component.text);
24063return match ? getPitchClass([match.pitch], match.reading) : "";
24064}
24065function renderExpressionComponentTerm(component, pitchClass) {
24066const text2 = component.text.trim();
24067const reading = component.reading.trim();
24068const classes = [
24069"jpdb-reader-word",
24070"jpdb-reader-passive-word",
24071"jpdb-reader-expression-component-term",
24072"jpdb-reader-jpdb-used-in-term",
24073reading && reading !== text2 ? "jpdb-reader-has-furi" : "",
24074pitchClass ? `jpdb-pitch-${pitchClass}` : "jpdb-pitch-unknown"
24075].filter(Boolean).join(" ");
24076const pitchAttribute = pitchClass || "unknown";
24077const readingAttribute = reading ? ` data-reading="${escapeHtml(reading)}"` : "";
24078const content = reading && reading !== text2 ? renderRuby(text2, expressionComponentRubyToken(text2, reading, pitchClass)) : escapeHtml(text2);
24079return `<span class="${classes}" data-jpdb-reader-passive="true" data-pitch-class="${escapeHtml(pitchAttribute)}" data-sentence="${escapeHtml(text2)}" data-expression="${escapeHtml(text2)}"${readingAttribute} tabindex="-1">${content}</span>`;
24080}
24081function expressionComponentRubyToken(text2, reading, pitchClass) {
24082return {
24083card: {
24084vid: 0,
24085sid: 0,
24086rid: 0,
24087spelling: text2,
24088reading,
24089frequencyRank: null,
24090partOfSpeech: [],
24091meanings: [],
24092cardState: ["not-in-deck"],
24093pitchAccent: [],
24094wordWithReading: null
24095},
24096start: 0,
24097end: text2.length,
24098length: text2.length,
24099rubies: [],
24100pitchClass,
24101sentence: text2
24102};
24103}
24104function renderProviderToggle(nextProvider, language, content = "") {
24105const label = `${uiText$1(language, "switchGradingProvider")} (${nextProvider.label})`;
24106return `<button class="jpdb-reader-provider-toggle" data-action="grade-provider-toggle"${privateCommandAttributes({ kind: "card-action", action: "grade-provider-toggle" })} aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">⇄ ${content}</button>`;
24107}
24108function canExpandMiningDrawer() {
24109return Boolean(yomuKanjiStudyCompanion()?.setMiningControlsExpanded);
24110}
24111function renderMiningGutter(miningActions, language) {
24112const label = uiText$1(language, "showMiningActions");
24113return miningActions ? `<div class="jpdb-reader-actions-gutter"><button class="jpdb-reader-mining-collapse jpdb-reader-mining-drawer-handle" data-action="mining-collapse"${privateCommandAttributes({ kind: "card-ui", action: "mining-collapse" })} aria-expanded="false" aria-label="${escapeHtml(label)}"></button></div>` : "";
24114}
24115function jitenDeckLabel(deck) {
24116return deck?.name ? `Jiten: ${deck.name}` : "Jiten";
24117}
24118function matchesShortcut(event, shortcut = "") {
24119if (!shortcut) return false;
24120const parts = parseShortcut(shortcut);
24121const key = parts.key?.toLowerCase();
24122if (!key) return false;
24123const eventKey = normalizeEventKey(event.key).toLowerCase();
24124return eventKey === key && shortcutModifiersMatch(event, parts.modifiers);
24125}
24126function shortcutModifiersMatch(event, modifiers) {
24127return event.altKey === modifiers.has("alt") && event.ctrlKey === modifiers.has("ctrl") && event.metaKey === modifiers.has("meta") && event.shiftKey === modifiers.has("shift");
24128}
24129function parseShortcut(shortcut) {
24130const parts = shortcut.split("+").map((part) => normalizeShortcutPart(part)).filter(Boolean);
24131const modifiers = new Set(parts.filter(isModifierKey).map((part) => part.toLowerCase()));
24132const key = [...parts].reverse().find((part) => !isModifierKey(part)) ?? "";
24133return { key: key.toLowerCase(), modifiers };
24134}
24135function normalizeShortcutPart(part) {
24136const value = typeof part === "string" ? part.trim() : "";
24137if (!value) return "";
24138const lower = value.toLowerCase();
24139const alias = shortcutPartAlias(lower);
24140if (alias) return alias;
24141if (value.length === 1) return value.toUpperCase();
24142return value[0]?.toUpperCase() + value.slice(1);
24143}
24144function shortcutPartAlias(lower) {
24145return SHORTCUT_PART_ALIASES.get(lower) ?? "";
24146}
24147const SHORTCUT_PART_ALIASES = new Map([
24148["control", "Ctrl"],
24149["cmd", "Meta"],
24150["command", "Meta"],
24151["win", "Meta"],
24152["windows", "Meta"],
24153["option", "Alt"],
24154["esc", "Escape"],
24155["spacebar", "Space"],
24156[" ", "Space"]
24157]);
24158function normalizeEventKey(key) {
24159if (key === " ") return "Space";
24160return normalizeShortcutPart(key);
24161}
24162function isModifierKey(key) {
24163return key === "Alt" || key === "Ctrl" || key === "Meta" || key === "Shift";
24164}
24165function reviewShortcutButton(root, event, settings2) {
24166if (!settings2.enableReviews) return void 0;
24167return [...root?.querySelectorAll("button") ?? []].find((button) => {
24168if (button.disabled || button.closest("[hidden]")) return false;
24169const command = readCardCommandCapability(button);
24170if (command?.action !== "grade" || !command.gradeShortcut) return false;
24171if (!privateReviewGradeAllowed(button, command)) return false;
24172return matchesShortcut(event, settings2.shortcuts[command.gradeShortcut]);
24173});
24174}
24175function sourceCardAnkiLookupOrEmpty(card) {
24176return ankiLookupFromSourceCard(card) ?? emptyAnkiLookupResult$1();
24177}
24178function cardNeedsJpdbDeckPoolLookup(card) {
24179return normalizeCardStates(card.cardState).includes("not-in-deck");
24180}
24181function applyPooledJpdbDeckState(card) {
24182const states = normalizeCardStates(card.cardState).filter((state) => state !== "not-in-deck");
24183card.cardState = states.length ? states : ["in-deck"];
24184}
24185function emptyAnkiLookupResult$1() {
24186return { state: "not-in-deck", notes: [], primary: null };
24187}
24188function ankiLookupFromSourceCard(card) {
24189const primaryCardId = sourceCardPrimaryAnkiCardId(card);
24190if (!primaryCardId) return null;
24191const state = primaryCardState(normalizeCardStates(card.cardState));
24192const note = ankiExistingNoteFromSourceCard(card, primaryCardId, state);
24193return { state, notes: [note], primary: note };
24194}
24195function sourceCardPrimaryAnkiCardId(card) {
24196if (card.source !== "anki" && card.reviewSource !== "anki") return null;
24197const primaryCardId = Number(card.ankiCardId ?? card.rid);
24198return Number.isFinite(primaryCardId) && primaryCardId > 0 ? primaryCardId : null;
24199}
24200function ankiExistingNoteFromSourceCard(card, primaryCardId, state) {
24201const fields = ankiFieldsFromSourceCard(card);
24202const noteId = Number(card.ankiNoteId ?? 0);
24203const renderedCards = sourceCardRenderedCards(card, primaryCardId, fields);
24204const note = {
24205noteId: Number.isFinite(noteId) ? noteId : 0,
24206modelName: card.ankiModelName ?? "",
24207deckNames: card.ankiDeckNames ?? [],
24208cardIds: [primaryCardId],
24209primaryCardId,
24210state,
24211fields,
24212renderedCards,
24213tags: [],
24214reps: card.ankiReps ?? 0,
24215lapses: card.ankiLapses ?? 0
24216};
24217return note;
24218}
24219function sourceCardRenderedCards(card, primaryCardId, fields) {
24220return card.ankiRenderedCards?.length ? card.ankiRenderedCards : [{
24221cardId: primaryCardId,
24222deckName: card.ankiDeckNames?.[0] ?? "",
24223question: card.spelling,
24224answer: fields.Meaning
24225}];
24226}
24227function ankiFieldsFromSourceCard(card) {
24228return {
24229Expression: card.spelling,
24230Reading: card.reading,
24231Meaning: card.meanings.flatMap((meaning) => meaning.glosses).join("; "),
24232Sentence: card.sentence ?? "",
24233Audio: card.ankiAudioFilenames?.map((filename) => `[sound:${filename}]`).join(" ") ?? ""
24234};
24235}
24236function cardKey(card) {
24237return `${card.vid}:${card.sid}:${card.spelling}:${card.reading}`;
24238}
24239function pruneExpiringMapEntries(cache, limit, now = Date.now()) {
24240for (const [key, entry] of cache) {
24241if (entry.expiresAt <= now) cache.delete(key);
24242}
24243while (cache.size > limit) {
24244const oldest = cache.keys().next().value;
24245if (typeof oldest !== "string") break;
24246cache.delete(oldest);
24247}
24248}
24249class PromiseLruCache {
24250constructor(maxSize) {
24251this.maxSize = maxSize;
24252}
24253entries = new Map();
24254getOrLoad(key, load) {
24255const cached = this.entries.get(key);
24256if (cached) {
24257this.entries.delete(key);
24258this.entries.set(key, cached);
24259return cached;
24260}
24261const promise = load();
24262this.entries.set(key, promise);
24263this.prune();
24264void promise.catch(() => {
24265if (this.entries.get(key) === promise) this.entries.delete(key);
24266});
24267return promise;
24268}
24269clear() {
24270this.entries.clear();
24271}
24272prune() {
24273while (this.entries.size > Math.max(1, this.maxSize)) {
24274const oldest = this.entries.keys().next().value;
24275if (oldest === void 0) return;
24276this.entries.delete(oldest);
24277}
24278}
24279}
24280const JITEN_DAILY_STATS_KEY = "jpdb-reader-jiten-daily-stats";
24281const JITEN_DAILY_STATS_MAX_DAYS = 400;
24282function recordJitenDailyStats(counts, now = new Date(), credential = "") {
24283const newCardsToday = finiteCount(counts.newCardsToday);
24284const reviewsToday = finiteCount(counts.reviewsToday);
24285if (!hasDailyStats(newCardsToday, reviewsToday)) return;
24286try {
24287const context = sensitiveFingerprint(credential);
24288const container = storedJitenDailyStats();
24289const stored = jitenStatsForContext(container, context);
24290const key = jitenStatsDateKey(now);
24291stored[key] = updatedDailyStats(stored[key], newCardsToday, reviewsToday, now);
24292saveJitenStatsForContext(container, context, stored);
24293} catch {
24294}
24295}
24296function hasDailyStats(newCardsToday, reviewsToday) {
24297return newCardsToday !== void 0 || reviewsToday !== void 0;
24298}
24299function jitenStatsForContext(container, context) {
24300return context ? { ...container.accounts[context] } : legacyJitenDailyStats();
24301}
24302function updatedDailyStats(previous, newCardsToday, reviewsToday, now) {
24303return {
24304newCardsToday: greatestDailyCount(previous?.newCardsToday, newCardsToday),
24305reviewsToday: greatestDailyCount(previous?.reviewsToday, reviewsToday),
24306updatedAt: now.getTime()
24307};
24308}
24309function greatestDailyCount(previous, next) {
24310return Math.max(previous ?? 0, next ?? 0);
24311}
24312function saveJitenStatsForContext(container, context, stats) {
24313const pruned = pruneJitenDailyStats(stats);
24314if (!context) {
24315gmStorageSetSync(JITEN_DAILY_STATS_KEY, pruned);
24316return;
24317}
24318container.accounts[context] = pruned;
24319gmStorageSetSync(JITEN_DAILY_STATS_KEY, container);
24320}
24321function storedJitenDailyStats() {
24322const stored = gmStorageGetSync(JITEN_DAILY_STATS_KEY, null);
24323return isStoredJitenDailyStats(stored) ? { version: 2, accounts: { ...stored.accounts } } : { version: 2, accounts: {} };
24324}
24325function legacyJitenDailyStats() {
24326const stored = gmStorageGetSync(JITEN_DAILY_STATS_KEY, {});
24327return isLegacyJitenDailyStats(stored) ? { ...stored } : {};
24328}
24329function isStoredJitenDailyStats(value) {
24330if (!isNonNullObject(value)) return false;
24331if (value.version !== 2) return false;
24332return isNonNullObject(value.accounts);
24333}
24334function isLegacyJitenDailyStats(value) {
24335if (!isRecord$2(value)) return false;
24336return !("version" in value);
24337}
24338function jitenStatsDateKey(date) {
24339const month = String(date.getMonth() + 1).padStart(2, "0");
24340const day = String(date.getDate()).padStart(2, "0");
24341return `${date.getFullYear()}-${month}-${day}`;
24342}
24343function pruneJitenDailyStats(stored) {
24344const keys = Object.keys(stored).sort();
24345while (keys.length > JITEN_DAILY_STATS_MAX_DAYS) {
24346delete stored[keys.shift() ?? ""];
24347}
24348return stored;
24349}
24350function finiteCount(value) {
24351return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
24352}
24353const DEFAULT_MAX_BATCH_BYTES = 16384;
24354const DEFAULT_MAX_BATCH_ITEMS = 80;
24355const DEFAULT_CONCURRENCY = 2;
24356const JSON_STRING_OVERHEAD_BYTES = 7;
24357const utf8Encoder = new TextEncoder();
24358class JitenParseBatcher {
24359constructor(options) {
24360this.options = options;
24361this.gate = new ConcurrencyGate(options.concurrency ?? DEFAULT_CONCURRENCY);
24362}
24363pending = new Map();
24364inFlight = new Map();
24365gate;
24366flushScheduled = false;
24367load(paragraphs) {
24368return Promise.all(paragraphs.map((paragraph) => paragraph.trim() ? this.loadParagraph(paragraph) : Promise.resolve(this.options.emptyResult())));
24369}
24370loadParagraph(paragraph) {
24371const existing = this.inFlight.get(paragraph);
24372if (existing) return existing;
24373let resolve;
24374let reject;
24375const promise = new Promise((resolvePromise, rejectPromise) => {
24376resolve = resolvePromise;
24377reject = rejectPromise;
24378});
24379const entry = { paragraph, promise, resolve, reject };
24380this.pending.set(paragraph, entry);
24381this.inFlight.set(paragraph, promise);
24382void promise.then(
24383() => this.forgetInFlight(paragraph, promise),
24384() => this.forgetInFlight(paragraph, promise)
24385);
24386this.scheduleFlush();
24387return promise;
24388}
24389scheduleFlush() {
24390if (this.flushScheduled) return;
24391this.flushScheduled = true;
24392queueMicrotask(() => this.flush());
24393}
24394flush() {
24395this.flushScheduled = false;
24396const entries2 = [...this.pending.values()];
24397this.pending.clear();
24398for (const batch of this.batches(entries2)) this.loadQueuedBatch(batch);
24399}
24400loadQueuedBatch(batch) {
24401const paragraphs = batch.map((entry) => entry.paragraph);
24402const request = this.gate.run(() => this.options.loadBatch(paragraphs));
24403batch.forEach((entry, index) => {
24404void request.then(
24405(results) => entry.resolve(results[index] ?? this.options.emptyResult()),
24406(error) => entry.reject(error)
24407);
24408});
24409}
24410batches(entries2) {
24411const maxBytes = Math.max(1, this.options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES);
24412const maxItems = Math.max(1, this.options.maxBatchItems ?? DEFAULT_MAX_BATCH_ITEMS);
24413const batches = [];
24414let batch = [];
24415let batchBytes = 0;
24416for (const entry of entries2) {
24417const bytes = utf8Encoder.encode(entry.paragraph).length + JSON_STRING_OVERHEAD_BYTES;
24418if (batch.length && (batch.length >= maxItems || batchBytes + bytes > maxBytes)) {
24419batches.push(batch);
24420batch = [];
24421batchBytes = 0;
24422}
24423batch.push(entry);
24424batchBytes += bytes;
24425}
24426if (batch.length) batches.push(batch);
24427return batches;
24428}
24429forgetInFlight(paragraph, promise) {
24430if (this.inFlight.get(paragraph) === promise) this.inFlight.delete(paragraph);
24431}
24432}
24433const JITEN_API_BASE_URL = "https://api.jiten.moe/api";
24434const REQUEST_TIMEOUT_MS = 3e4;
24435const MISSING_API_KEY_MESSAGE = "Jiten API key is not set.";
24436const PUBLIC_READ_CACHE_LIMIT = 160;
24437class JitenApiError extends Error {
24438constructor(message, status) {
24439super(message);
24440this.status = status;
24441this.name = "JitenApiError";
24442}
24443}
24444function jitenVocabularyIdentity(info) {
24445const annotated = info?.mainReading?.text.trim() ?? "";
24446if (!annotated) return null;
24447const spelling = cleanJitenAnnotatedSpelling$1(annotated).trim();
24448const cleanedReading = cleanJitenAnnotatedReading(annotated).trim();
24449if (!spelling) return null;
24450const reading = cleanedReading === spelling && /[\u3400-\u9fff々〆]/u.test(spelling) ? "" : cleanedReading;
24451return {
24452spelling,
24453reading,
24454wordWithReading: spelling === annotated ? null : annotated
24455};
24456}
24457function enrichCardFromJitenVocabularyInfo(card, info) {
24458const identity = jitenVocabularyIdentity(info);
24459if (!identity || normalizedJitenIdentity(identity.spelling) !== normalizedJitenIdentity(card.spelling)) return false;
24460let changed = false;
24461const currentReading = card.reading.trim();
24462const normalizedCurrentReading = normalizedJitenIdentity(currentReading);
24463const normalizedSpelling = normalizedJitenIdentity(card.spelling);
24464if (identity.reading && currentReading && normalizedCurrentReading !== normalizedSpelling && normalizedCurrentReading !== normalizedJitenIdentity(identity.reading)) return false;
24465if (identity.reading && (!currentReading || normalizedJitenIdentity(currentReading) === normalizedJitenIdentity(card.spelling)) && currentReading !== identity.reading) {
24466card.reading = identity.reading;
24467changed = true;
24468}
24469if (!card.wordWithReading && identity.wordWithReading) {
24470card.wordWithReading = identity.wordWithReading;
24471changed = true;
24472}
24473if (card.frequencyRank === null && typeof info?.mainReading?.frequencyRank === "number" && info.mainReading.frequencyRank > 0) {
24474card.frequencyRank = info.mainReading.frequencyRank;
24475changed = true;
24476}
24477const pronunciationReading = card.reading.trim() || identity.reading;
24478for (const position of info?.pitchAccents ?? []) {
24479const pattern = pitchPatternFromPosition(pronunciationReading, position);
24480if (!pattern || card.pitchAccent.includes(pattern)) continue;
24481card.pitchAccent.push(pattern);
24482changed = true;
24483}
24484return changed;
24485}
24486function normalizedJitenIdentity(value) {
24487return value.normalize("NFKC").replace(/\s+/gu, "").trim();
24488}
24489class JitenApiClient {
24490constructor(getApiKey, options = {}) {
24491this.getApiKey = getApiKey;
24492this.options = options;
24493this.parseBatcher = this.createParseBatcher();
24494}
24495parseBatcher;
24496vocabularyInfoCache = new PromiseLruCache(PUBLIC_READ_CACHE_LIMIT);
24497vocabularySearchCache = new PromiseLruCache(PUBLIC_READ_CACHE_LIMIT);
24498kanjiCache = new PromiseLruCache(PUBLIC_READ_CACHE_LIMIT);
24499kanjiWordsCache = new PromiseLruCache(PUBLIC_READ_CACHE_LIMIT);
24500createParseBatcher() {
24501return new JitenParseBatcher({
24502loadBatch: (paragraphs) => this.fetchParseBatch(paragraphs),
24503emptyResult: () => []
24504});
24505}
24506clear() {
24507this.parseBatcher = this.createParseBatcher();
24508this.vocabularyInfoCache.clear();
24509this.vocabularySearchCache.clear();
24510this.kanjiCache.clear();
24511this.kanjiWordsCache.clear();
24512}
24513async ping() {
24514await this.request("reader/ping", void 0);
24515return true;
24516}
24517async validateApiKey(apiKey) {
24518const client = apiKey === void 0 ? this : new JitenApiClient(() => apiKey, this.options);
24519try {
24520await client.ping();
24521return true;
24522} catch (error) {
24523if (isJitenAuthenticationError(error) || isMissingJitenApiKeyError(error)) return false;
24524throw error;
24525}
24526}
24527async parse(paragraphs) {
24528return this.parseBatcher.load(paragraphs);
24529}
24530lookupVocabularyInfo(card) {
24531const reference = jitenCardReference(card);
24532return this.vocabularyInfoCache.getOrLoad(jitenLookupKey(reference.wordId, reference.readingIndex), () => this.fetchVocabularyInfo(reference));
24533}
24534async fetchVocabularyInfo(reference) {
24535const endpoint = `vocabulary/${reference.wordId}/${reference.readingIndex}/info`;
24536const examplesPromise = this.lookupVocabularyExamples(reference).catch(() => []);
24537const info = await this.requestEndpoint(endpoint, void 0, { method: "GET" });
24538if (!isJsonRecord(info)) return null;
24539const normalized = normalizeJitenVocabularyInfo(info);
24540if (!normalized) return null;
24541normalized.examples = await examplesPromise;
24542return normalized;
24543}
24544async lookupVocabularyInfoForCard(card) {
24545if (isJitenReferenceableCard(card)) return this.lookupVocabularyInfo(card);
24546const jitenCard = await this.lookupJitenCardForVocabularyInfo(card);
24547if (!jitenCard) return null;
24548const reading = card.reading.trim();
24549const exactMatch = jitenCard.spelling === card.spelling && (!reading || jitenCard.reading === reading);
24550if (exactMatch && typeof card.jitenWordId !== "number" && typeof jitenCard.jitenWordId === "number") {
24551card.jitenWordId = jitenCard.jitenWordId;
24552card.jitenReadingIndex = jitenCard.jitenReadingIndex;
24553}
24554return this.lookupVocabularyInfo(jitenCard);
24555}
24556searchVocabulary(query, limit = 10) {
24557const normalizedQuery = query.trim();
24558if (!normalizedQuery) return Promise.resolve([]);
24559const normalizedLimit = Math.max(1, Math.floor(limit));
24560return this.vocabularySearchCache.getOrLoad(`${normalizedQuery}:${normalizedLimit}`, () => this.fetchVocabularySearch(normalizedQuery, normalizedLimit));
24561}
24562async fetchVocabularySearch(query, limit) {
24563const response = await this.requestEndpoint("vocabulary/search", void 0, {
24564method: "GET",
24565query: { query, limit }
24566});
24567if (!isJsonRecord(response) || !Array.isArray(response.results)) return [];
24568return response.results.map((result) => ({
24569vid: result.wordId,
24570sid: result.readingIndex,
24571rid: 0,
24572spelling: result.text,
24573reading: cleanJitenAnnotatedReading(result.rubyText || result.text),
24574frequencyRank: typeof result.frequencyRank === "number" ? result.frequencyRank : null,
24575partOfSpeech: Array.isArray(result.partsOfSpeech) ? result.partsOfSpeech.map(String) : [],
24576meanings: (Array.isArray(result.meanings) ? result.meanings : []).map((meaning) => ({
24577glosses: [meaning],
24578partOfSpeech: []
24579})),
24580cardState: ["not-in-deck"],
24581pitchAccent: [],
24582wordWithReading: result.rubyText || null,
24583source: "jiten",
24584reviewSource: "jiten-api",
24585jitenWordId: result.wordId,
24586jitenReadingIndex: result.readingIndex
24587}));
24588}
24589async lookupJitenCardForVocabularyInfo(card) {
24590const spelling = card.spelling.trim();
24591if (!spelling) return null;
24592let tokens = [];
24593const apiKey = this.getApiKey().trim();
24594if (apiKey) {
24595try {
24596const [parsed] = await this.parse([spelling]);
24597tokens = parsed ?? [];
24598} catch (error) {
24599}
24600}
24601if (!tokens.length) {
24602try {
24603const searchCards = await this.searchVocabulary(spelling);
24604tokens = searchCards.map((c) => ({
24605card: c,
24606start: 0,
24607end: spelling.length,
24608length: spelling.length,
24609rubies: [],
24610pitchClass: "",
24611sentence: spelling
24612}));
24613} catch (error) {
24614}
24615}
24616return bestParsedJitenCard(card, spelling, tokens);
24617}
24618lookupKanji(character) {
24619const kanji = character.trim();
24620if (!kanji) return Promise.resolve(null);
24621return this.kanjiCache.getOrLoad(kanji, () => this.fetchKanji(kanji));
24622}
24623async fetchKanji(kanji) {
24624const payload = await this.requestEndpoint(`kanji/${encodeURIComponent(kanji)}`, void 0, { method: "GET" });
24625return normalizeJitenKanjiInfo(payload);
24626}
24627lookupKanjiWords(character, options = {}) {
24628const kanji = character.trim();
24629if (!kanji) return Promise.resolve(null);
24630const key = [kanji, options.reading ?? "", options.page ?? "", options.pageSize ?? ""].join(":");
24631return this.kanjiWordsCache.getOrLoad(key, () => this.fetchKanjiWords(kanji, options));
24632}
24633async fetchKanjiWords(kanji, options) {
24634const payload = await this.requestEndpoint(`kanji/${encodeURIComponent(kanji)}/words`, void 0, {
24635method: "GET",
24636query: {
24637reading: options.reading,
24638page: options.page,
24639pageSize: options.pageSize
24640}
24641});
24642return normalizeJitenKanjiWordsPage(payload);
24643}
24644async fetchParseBatch(paragraphs) {
24645const response = await this.request("reader/parse", { text: paragraphs });
24646return jitenParseResultToTokens(paragraphs, response);
24647}
24648async listReaderStudyDecks() {
24649const response = await this.request("srs/reader-study-decks", void 0);
24650return normalizeReaderStudyDecks(response);
24651}
24652async listStudyDecks() {
24653const response = await this.requestEndpoint("srs/study-decks", void 0, { method: "GET" });
24654if (!Array.isArray(response)) return [];
24655return response.map((row) => {
24656const record2 = row;
24657const id = Number(record2?.userStudyDeckId);
24658const name = typeof record2?.name === "string" ? record2.name : "";
24659return Number.isFinite(id) && id > 0 && name ? { id, name } : null;
24660}).filter((deck) => deck !== null);
24661}
24662async studyDeckWordKeys(deckId) {
24663const response = await this.requestEndpoint(`srs/study-decks/${Math.floor(deckId
24663)}/word-keys`, void 0, { method: "GET" });
24664const keys = new Set();
24665if (!Array.isArray(response)) return keys;
24666for (const row of response) {
24667const record2 = row;
24668const wordId = Number(record2?.wordId);
24669if (!Number.isFinite(wordId)) continue;
24670keys.add(`${wordId}:${Number(record2?.readingIndex) || 0}`);
24671}
24672return keys;
24673}
24674async listStudyDeckVocabularyCards(deckId, limit = 5e3) {
24675const normalizedDeckId = normalizeJitenStudyDeckId(deckId);
24676const cardLimit = Math.max(1, Math.floor(limit));
24677const cards = [];
24678let offset = 0;
24679while (cards.length < cardLimit) {
24680const page = normalizeJitenStudyDeckVocabularyPage(
24681await this.requestEndpoint(`srs/study-decks/${normalizedDeckId}/vocabulary`, void 0, {
24682method: "GET",
24683query: { offset }
24684})
24685);
24686if (!page.cards.length) break;
24687cards.push(...page.cards);
24688const pageSize = Math.max(1, page.pageSize || page.cards.length);
24689const nextOffset = Math.max(offset + pageSize, page.currentOffset + pageSize);
24690if (nextOffset <= offset || nextOffset >= page.totalItems) break;
24691offset = nextOffset;
24692}
24693return cards.slice(0, cardLimit);
24694}
24695async listRecentReviews(limit = 5e3) {
24696const reviewLimit = Math.max(1, Math.floor(limit));
24697const reviews = [];
24698let offset = 0;
24699while (reviews.length < reviewLimit) {
24700const page = normalizeJitenRecentReviewsPage(
24701await this.requestEndpoint("srs/review-history", void 0, {
24702method: "GET",
24703query: {
24704offset,
24705limit: Math.min(100, reviewLimit - reviews.length)
24706}
24707})
24708);
24709if (!page.reviews.length) break;
24710reviews.push(...page.reviews);
24711const pageSize = Math.max(1, page.pageSize || page.reviews.length);
24712const nextOffset = Math.max(offset + pageSize, page.currentOffset + pageSize);
24713if (nextOffset <= offset || nextOffset >= page.totalItems) break;
24714offset = nextOffset;
24715}
24716return reviews.slice(0, reviewLimit);
24717}
24718async listStudyBatchCards(limit = 80) {
24719const cardLimit = Math.max(1, Math.floor(limit));
24720const response = await this.requestEndpoint("srs/study-batch", void 0, {
24721method: "GET",
24722query: { limit: cardLimit }
24723});
24724recordJitenDailyStats(response, new Date(), this.getApiKey());
24725return normalizeJitenStudyBatchCards(response).slice(0, cardLimit);
24726}
24727async reviewCard(card, grade) {
24728await this.request("srs/review", {
24729...jitenCardReference(card),
24730rating: jitenRatingForGrade(grade)
24731});
24732}
24733async undoReview(card) {
24734await this.request("srs/undo-review", jitenCardReference(card));
24735}
24736async batchReviewCards(cards, grade) {
24737const reviews = cards.flatMap((card) => {
24738return attempt(() => [{ ...jitenCardReference(card), rating: jitenRatingForGrade(grade) }], [], "jiten.batchReviewCards");
24739});
24740if (!reviews.length) return 0;
24741await this.request("srs/batch-review", { reviews });
24742return reviews.length;
24743}
24744async refreshCardState(card) {
24745jitenCardReference(card);
24746await this.refreshCardStates([card]);
24747}
24748async refreshCardStates(cards) {
24749const entries2 = cards.map((card) => {
24750return attempt(() => ({ card, ref: jitenCardReference(card) }), null, "jiten.refreshCardStates");
24751}).filter((entry) => entry !== null);
24752if (!entries2.length) return 0;
24753const response = await this.request("reader/lookup-vocabulary", {
24754words: entries2.map((entry) => [entry.ref.wordId, entry.ref.readingIndex])
24755});
24756const states = isJsonRecord(response) && Array.isArray(response.result) ? response.result : [];
24757let refreshed = 0;
24758entries2.forEach((entry, index) => {
24759const state = states[index];
24760if (!Array.isArray(state) || !state.every((value) => Number.isInteger(value) && Object.hasOwn(JITEN_CARD_STATE_MAP, value))) return;
24761entry.card.cardState = jitenKnownStateToCardStates(state);
24762refreshed += 1;
24763});
24764return refreshed;
24765}
24766async setVocabularyState(card, deck, action) {
24767await this.request("srs/set-vocabulary-state", {
24768...jitenCardReference(card),
24769state: `${deck}-${action}`
24770});
24771}
24772async addToStudyDeck(deckId, card, sentence, source) {
24773const normalizedDeckId = normalizeJitenStudyDeckId(deckId);
24774await this.requestEndpoint(`srs/study-decks/${normalizedDeckId}/words`, {
24775...jitenCardReference(card),
24776occurrences: 1,
24777sentence,
24778source
24779});
24780}
24781async lookupVocabularyExamples(card) {
24782const endpoint = `vocabulary/${card.wordId}/${card.readingIndex}/random-example-sentences`;
24783const payload = await this.requestEndpoint(endpoint, [], { method: "POST" });
24784return normalizeJitenVocabularyExamples(payload);
24785}
24786async request(endpoint, body) {
24787return this.requestEndpoint(endpoint, body);
24788}
24789async requestEndpoint(endpoint, body, options = {}) {
24790const apiKey = this.getApiKey().trim();
24791const requiresAuth = endpoint.startsWith("reader/") || endpoint.startsWith("srs/");
24792if (requiresAuth && !apiKey) throw new JitenApiError(MISSING_API_KEY_MESSAGE);
24793const authenticated = requiresAuth && Boolean(apiKey);
24794const method = options.method ?? "POST";
24795const data = method === "GET" ? void 0 : body === void 0 ? void 0 : JSON.stringify(body);
24796const url = endpointUrl(this.options.baseUrl, endpoint, options.query);
24797if (this.options.fetchImpl) {
24798const response = await fetchWithTimeout(
24799this.options.fetchImpl,
24800url,
24801{
24802method,
24803headers: this.headers(apiKey),
24804body: data
24805},
24806this.options.timeoutMs ?? REQUEST_TIMEOUT_MS
24807);
24808return parseJitenResponse(response, authenticated);
24809}
24810try {
24811const payload = await this.requestImpl()(url, {
24812method,
24813headers: this.headers(apiKey),
24814data,
24815responseType: "json",
24816timeoutMs: this.options.timeoutMs ?? REQUEST_TIMEOUT_MS,
24817timeoutLabel: "Jiten request timed out.",
24818failureLabel: "Jiten request",
24819statusFailureMessage: (status) => `Jiten request failed (${status}).`,
24820proxyUrl: this.proxyUrl(),
24821allowDirectCrossOrigin: false,
24822allowConfiguredProxy: true,
24823allowSensitiveConfiguredProxy: true,
24824allowPublicProxies: !apiKey,
24825preferFetch: true
24826});
24827return parseJitenPayload(payload);
24828} catch (error) {
24829throw normalizeJitenRequestError(error, authenticated);
24830}
24831}
24832requestImpl() {
24833return this.options.requestImpl ?? requestHttp;
24834}
24835proxyUrl() {
24836return typeof this.options.proxyUrl === "function" ? this.options.proxyUrl() : this.options.proxyUrl ?? "";
24837}
24838headers(apiKey) {
24839const headers = {
24840"Content-Type": "application/json",
24841Accept: "application/json"
24842};
24843if (apiKey) {
24844headers.Authorization = `ApiKey ${apiKey}`;
24845}
24846return headers;
24847}
24848}
24849function jitenParseResultToTokens(paragraphs, result) {
24850const payload = isJsonRecord(result) ? result : {};
24851const vocabulary = jitenVocabularyEntries(payload.vocabulary);
24852const cardByKey = new Map(vocabulary.map((entry) => [jitenLookupKey(entry.wordId, entry.readingIndex), jitenCardFromVocabulary(entry)]));
24853const vocabByKey = new Map(vocabulary.map((entry) => [jitenLookupKey(entry.wordId, entry.readingIndex), entry]));
24854const rawTokens = Array.isArray(payload.tokens) ? payload.tokens : [];
24855const tokens = paragraphs.map((paragraph, paragraphIndex) => {
24856const parsed = [];
24857for (const token of jitenTokenEntries(rawTokens[paragraphIndex])) {
24858const card = cardByKey.get(jitenLookupKey(token.wordId, token.readingIndex));
24859if (!card) continue;
24860const vocabularyEntry = vocabByKey.get(jitenLookupKey(token.wordId, token.readingIndex));
24861const pitchClass = card.partOfSpeech.includes("prt") ? "" : getPitchClass(card.pitchAccent, card.reading);
24862const span = jitenTokenTextSpan(paragraph, token, card);
24863const rubies = jitenTokenRubies(vocabularyEntry, span.start);
24864if (rubies.length) card.wordWithReading = jitenWordWithReading(card.spelling, rubies, span.start);
24865parsed.push({
24866card,
24867start: span.start,
24868end: span.end,
24869length: span.length,
24870rubies,
24871pitchClass,
24872sentence: paragraph
24873});
24874}
24875return parsed;
24876});
24877addJitenSentenceInfo(paragraphs, tokens);
24878return tokens;
24879}
24880function jitenCardReference(card) {
24881const wordId = finiteJitenInteger(card.jitenWordId) ?? (card.source === "jiten" ? finiteJitenInteger(card.vid) : void 0);
24882const readingIndex = finiteJitenInteger(card.jitenReadingIndex) ?? (card.source === "jiten" ? finiteJitenInteger(card.sid) : void 0);
24883if (wordId === void 0 || readingIndex === void 0 || wordId <= 0 || readingIndex < 0) {
24884throw new JitenApiError("Card is not backed by Jiten.");
24885}
24886return { wordId, readingIndex };
24887}
24888function isJitenReferenceableCard(card) {
24889try {
24890jitenCardReference(card);
24891return true;
24892} catch {
24893return false;
24894}
24895}
24896function bestParsedJitenCard(card, spelling, tokens) {
24897const fullSpan = tokens.filter((token) => token.start === 0 && token.end === spelling.length).map((token) => token.card).filter((candidate) => isJitenReferenceableCard(candidate));
24898if (!fullSpan.length) return null;
24899const reading = card.reading.trim();
24900return fullSpan.find((candidate) => candidate.spelling === spelling && (!reading || candidate.reading === reading)) ?? fullSpan.find((candidate) => candidate.spelling === spelling) ?? fullSpan[0] ?? null;
24901}
24902function jitenRatingForGrade(grade) {
24903if (grade === "easy") return 4;
24904if (grade === "okay" || grade === "pass") return 3;
24905if (grade === "hard" || grade === "something") return 2;
24906return 1;
24907}
24908function jitenCardFromVocabulary(vocabulary) {
24909const reading = cleanJitenAnnotatedReading(vocabulary.reading);
24910const wordWithReading = cleanJitenAnnotatedSpelling$1(vocabulary.reading).trim() === vocabulary.spelling ? vocabulary.reading : null;
24911const pitchAccent = jitenPitchAccentPatterns(vocabulary.pitchAccents, reading);
24912const reviewGradeIntervals = jitenReviewGradeIntervals(vocabulary);
24913const deckNames = jitenVocabularyDeckNames(vocabulary);
24914return {
24915vid: vocabulary.wordId,
24916sid: vocabulary.readingIndex,
24917rid: 0,
24918spelling: vocabulary.spelling,
24919reading,
24920frequencyRank: typeof vocabulary.frequencyRank === "number" ? vocabulary.frequencyRank : null,
24921partOfSpeech: arrayOfStrings(vocabulary.partsOfSpeech),
24922meanings: (Array.isArray(vocabulary.meaningsChunks) ? vocabulary.meaningsChunks : []).map((glosses, index) => ({
24923glosses: arrayOfStrings(glosses),
24924partOfSpeech: jitenMeaningPartOfSpeech(vocabulary.meaningsPartOfSpeech, index)
24925})),
24926cardState: jitenKnownStateToCardStates(vocabulary.knownState),
24927pitchAccent,
24928wordWithReading,
24929source: "jiten",
24930sentence: typeof vocabulary.sentence === "string" && vocabulary.sentence.trim() ? vocabulary.sentence : void 0,
24931reviewSource: "jiten-api",
24932jitenWordId: vocabulary.wordId,
24933jitenReadingIndex: vocabulary.readingIndex,
24934...deckNames.length ? { deckNames } : {},
24935...reviewGradeIntervals ? { reviewGradeIntervals } : {}
24936};
24937}
24938function normalizeJitenStudyBatchCards(response) {
24939const cards = Array.isArray(response.cards) ? response.cards : [];
24940return cards.map(jitenCardFromStudyCard).filter((card) => Boolean(card));
24941}
24942function normalizeJitenStudyDeckVocabularyPage(response) {
24943if (!isJsonRecord(response)) return { cards: [], totalItems: 0, pageSize: 0, currentOffset: 0 };
24944const data = response.data ?? response.Data;
24945const cards = arrayOfRecords(data).map(jitenCardFromStudyDeckVocabularyWord).filter((card) => Boolean(card));
24946return {
24947cards,
24948totalItems: firstRecordFiniteNumber(response, ["totalItems", "TotalItems"]) ?? cards.length,
24949pageSize: firstRecordFiniteNumber(response, ["pageSize", "PageSize"]) ?? cards.length,
24950currentOffset: firstRecordFiniteNumber(response, ["currentOffset", "CurrentOffset"]) ?? 0
24951};
24952}
24953function normalizeJitenRecentReviewsPage(response) {
24954if (!isJsonRecord(response)) return { reviews: [], totalItems: 0, pageSize: 0, currentOffset: 0 };
24955const data = response.data ?? response.Data;
24956const reviews = arrayOfRecords(data).map(normalizeJitenRecentReview).filter((review) => Boolean(review));
24957return {
24958reviews,
24959totalItems: firstRecordFiniteNumber(response, ["totalItems", "TotalItems"]) ?? reviews.length,
24960pageSize: firstRecordFiniteNumber(response, ["pageSize", "PageSize"]) ?? reviews.length,
24961currentOffset: firstRecordFiniteNumber(response, ["currentOffset", "CurrentOffset"]) ?? 0
24962};
24963}
24964function normalizeJitenRecentReview(value) {
24965const wordId = finiteJitenInteger(value.wordId ?? value.WordId);
24966const readingIndex = finiteJitenInteger(value.readingIndex ?? value.ReadingIndex);
24967const reviewDateTime = firstRecordString(value, ["reviewDateTime", "ReviewDateTime"]);
24968const reviewedAt = reviewDateTime ? Date.parse(reviewDateTime) : Number.NaN;
24969if (wordId === void 0 || readingIndex === void 0 || !Number.isFinite(reviewedAt)) return null;
24970return {
24971wordId,
24972readingIndex,
24973wordText: firstRecordString(value, ["wordText", "WordText"]) ?? "",
24974rating: finiteJitenInteger(value.rating ?? value.Rating) ?? 0,
24975reviewDateTime: reviewDateTime ?? "",
24976reviewedAt,
24977reviewDuration: nullableFiniteInteger(value.reviewDuration ?? value.ReviewDuration),
24978cardState: finiteJitenInteger(value.cardState ?? value.CardState) ?? 0
24979};
24980}
24981function jitenCardFromStudyDeckVocabularyWord(value) {
24982const word = value;
24983if (!isJsonRecord(word) || !isJsonRecord(word.mainReading)) return null;
24984const wordId = finiteJitenInteger(word.wordId);
24985const readingIndex = finiteJitenInteger(word.mainReading.readingIndex);
24986const annotatedText = typeof word.mainReading.text === "string" ? word.mainReading.text.trim() : "";
24987if (wordId === void 0 || readingIndex === void 0 || !annotatedText) return null;
24988const spelling = cleanJitenAnnotatedSpelling$1(annotatedText).trim() || cleanJitenAnnotatedReading(annotatedText).trim();
24989const reading = cleanJitenAnnotatedReading(annotatedText).trim() || spelling;
24990if (!spelling) return null;
24991return {
24992vid: wordId,
24993sid: readingIndex,
24994rid: 0,
24995spelling,
24996reading,
24997frequencyRank: positiveJitenInteger(word.mainReading.frequencyRank) ?? null,
24998partOfSpeech: arrayOfStrings(word.partsOfSpeech),
24999meanings: jitenStudyDeckVocabularyMeanings(word.definitions),
25000cardState: jitenKnownStateToCardStates(word.knownStates),
25001pitchAccent: jitenPitchAccentPatterns(word.pitchAccents, reading),
25002wordWithReading: annotatedText,
25003source: "jiten",
25004reviewSource: "jiten-api",
25005jitenWordId: wordId,
25006jitenReadingIndex: readingIndex
25007};
25008}
25009function jitenStudyDeckVocabularyMeanings(value) {
25010return arrayOfRecords(value).map((definition) => ({
25011glosses: arrayOfStrings(definition.meanings),
25012partOfSpeech: firstNonEmptyStringArray(definition.partsOfSpeech, definition.pos)
25013})).filter((meaning) => meaning.glosses.length);
25014}
25015function jitenCardFromStudyCard(card) {
25016const wordId = finiteJitenInteger(card.wordId);
25017const readingIndex = finiteJitenInteger(card.readingIndex);
25018if (wordId === void 0 || readingIndex === void 0) return null;
25019const reading = jitenStudyCardReading(card);
25020const reviewGradeIntervals = jitenReviewGradeIntervals(card);
25021return {
25022vid: wordId,
25023sid: readingIndex,
25024rid: finiteJitenInteger(card.cardId) ?? 0,
25025spelling: jitenStudyCardSpelling(card),
25026reading,
25027frequencyRank: jitenStudyCardFrequencyRank(card),
25028partOfSpeech: arrayOfStrings(card.partsOfSpeech),
25029meanings: jitenStudyCardMeanings(card),
25030cardState: jitenStudyStateToCardStates(card.state, card.isNewCard),
25031pitchAccent: jitenStudyCardPitchAccent(card, reading),
25032wordWithReading: card.wordText || null,
25033source: "jiten",
25034sentence: jitenStudyCardSentence(card),
25035reviewSource: "jiten-api",
25036jitenWordId: wordId,
25037jitenReadingIndex: readingIndex,
25038...typeof card.sourceDeckName === "string" && card.sourceDeckName.trim() ? { deckNames: [card.sourceDeckName.trim()] } : {},
25039...reviewGradeIntervals ? { reviewGradeIntervals } : {},
25040...typeof card.sourceDeckName === "string" && card.sourceDeckName.trim() ? { sourceDeckName: card.sourceDeckName.trim() } : {}
25041};
25042}
25043function jitenVocabularyDeckNames(vocabulary) {
25044return uniqueJitenText([
25045...jitenDeckNamesFromValue(vocabulary.deckNames),
25046...jitenDeckNamesFromValue(vocabulary.decks),
25047...jitenDeckNamesFromValue(vocabulary.studyDecks),
25048...jitenDeckNamesFromValue(vocabulary.userStudyDecks),
25049...jitenDeckNamesFromValue(vocabulary.readerStudyDecks),
25050...jitenDeckNamesFromValue(vocabulary.lookupDecks),
25051typeof vocabulary.sourceDeckName === "string" ? vocabulary.sourceDeckName : ""
25052]);
25053}
25054function jitenDeckNamesFromValue(value) {
25055if (typeof value === "string") return [value];
25056if (Array.isArray(value)) return value.flatMap(jitenDeckNamesFromValue);
25057if (!isJsonRecord(value)) return [];
25058return [
25059firstRecordString(value, ["name", "title", "deckName", "sourceDeckName"]) ?? "",
25060...jitenDeckNamesFromValue(value.deck),
25061...jitenDeckNamesFromValue(value.studyDeck),
25062...jitenDeckNamesFromValue(value.userStudyDeck)
25063];
25064}
25065function jitenStudyCardPitchAccent(card, reading) {
25066return jitenPitchAccentPatterns(card.pitchAccents, reading);
25067}
25068function jitenStudyCardFrequencyRank(card) {
25069return typeof card.frequencyRank === "number" ? card.frequencyRank : null;
25070}
25071function jitenStudyCardMeanings(card) {
25072return (Array.isArray(card.definitions) ? card.definitions : []).map((definition) => ({
25073glosses: arrayOfStrings(definition.meanings),
25074partOfSpeech: arrayOfStrings(definition.partsOfSpeech)
25075}));
25076}
25077function jitenStudyCardSentence(card) {
25078return typeof card.exampleSentence?.text === "string" ? card.exampleSentence.text : void 0;
25079}
25080function jitenStudyCardSpelling(card) {
25081return (card.wordTextPlain || cleanJitenAnnotatedReading(card.wordText || "") || jitenStudyCardReading(card)).trim();
25082}
25083function jitenStudyCardReading(card) {
25084const reading = (Array.isArray(card.readings) ? card.readings : []).find((candidate) => candidate.readingIndex === card.readingIndex);
25085return cleanJitenAnnotatedReading(reading?.text || reading?.rubyText || card.wordText || card.wordTextPlain || "").trim();
25086}
25087function jitenStudyStateToCardStates(state, isNewCard) {
25088if (isNewCard) return ["new"];
25089return [JITEN_FSRS_CARD_STATE_MAP[state] ?? "known"];
25090}
25091const JITEN_FSRS_CARD_STATE_MAP = {
250920: "new",
250931: "learning",
250942: "due",
250953: "failed",
250964: "blacklisted",
250975: "never-forget",
250986: "suspended"
25099};
25100function cleanJitenAnnotatedReading(value) {
25101return value.replace(/([\u4e00-\u9faf\u3005-\u3007]+)\[([^\]]+)\]/g, "$2");
25102}
25103function cleanJitenAnnotatedSpelling$1(value) {
25104return value.replace(/([\u4e00-\u9faf\u3005-\u3007]+)\[[^\]]+]/g, "$1");
25105}
25106function jitenKnownStateToCardStates(states) {
25107const mapped = jitenStateNumbers(states).map((state) => JITEN_CARD_STATE_MAP[state]).filter((state) => Boolean(state));
25108return mapped.length ? mapped : ["not-in-deck"];
25109}
25110const JITEN_CARD_STATE_MAP = {
251110: "new",
251121: "young",
251132: "mature",
251143: "blacklisted",
251154: "due",
251165: "mastered",
251176: "redundant",
251187: "in-deck"
25119};
25120function normalizeJitenVocabularyInfo(value) {
25121const record2 = jitenPayloadRecord(value);
25122if (!record2) return null;
25123const wordId = finiteJitenInteger(record2.wordId);
25124if (wordId === void 0 || wordId <= 0) return null;
25125const mainReading = normalizeJitenVocabularyReading(record2.mainReading);
25126return {
25127wordId,
25128mainReading,
25129alternativeReadings: arrayOfRecords(record2.alternativeReadings).map(normalizeJitenVocabularyReading).filter((item) => Boolean(item)),
25130partsOfSpeech: arrayOfStrings(record2.partsOfSpeech),
25131definitions: arrayOfRecords(record2.definitions).map(normalizeJitenVocabularyDefinition).filter((item) => Boolean(item)),
25132pitchAccents: jitenStateNumbers(record2.pitchAccents),
25133knownStates: Array.isArray(record2.knownStates) ? jitenKnownStateToCardStates(rec
25133ord2.knownStates) : [],
25134composedOf: normalizeJitenVocabularyWordSummaries(record2.composedOf),
25135usedIn: normalizeJitenVocabularyWordSummaries(record2.usedIn),
25136usedInTotal: finiteJitenInteger(record2.usedInTotal) ?? 0,
25137examples: []
25138};
25139}
25140function jitenPayloadRecord(value) {
25141if (!isJsonRecord(value)) return null;
25142return isJsonRecord(value.data) ? value.data : value;
25143}
25144function normalizeJitenVocabularyReading(value) {
25145if (!isJsonRecord(value)) return null;
25146const text2 = firstRecordString(value, ["text"]);
25147const readingIndex = finiteJitenInteger(value.readingIndex);
25148if (!text2 || readingIndex === void 0) return null;
25149return {
25150text: text2,
25151readingIndex,
25152frequencyRank: nullableFiniteInteger(value.frequencyRank),
25153usedInMediaAmount: nullableFiniteInteger(value.usedInMediaAmount)
25154};
25155}
25156function normalizeJitenVocabularyDefinition(value) {
25157if (!isJsonRecord(value)) return null;
25158const meanings = firstNonEmptyStringArray(value.meanings, value.englishMeanings);
25159if (!meanings.length) return null;
25160return {
25161index: finiteJitenInteger(value.index) ?? finiteJitenInteger(value.senseIndex) ?? 0,
25162meanings,
25163partsOfSpeech: firstNonEmptyStringArray(value.partsOfSpeech, value.pos),
25164field: arrayOfStrings(value.field),
25165dial: arrayOfStrings(value.dial),
25166misc: arrayOfStrings(value.misc),
25167restrictedToReadingIndices: jitenStateNumbers(value.restrictedToReadingIndices)
25168};
25169}
25170function normalizeJitenVocabularyWordSummaries(value) {
25171return arrayOfRecords(value).map(normalizeJitenVocabularyWordSummary).filter((item) => Boolean(item));
25172}
25173function normalizeJitenVocabularyWordSummary(value) {
25174if (!isJsonRecord(value)) return null;
25175const wordId = finiteJitenInteger(value.wordId);
25176const readingIndex = finiteJitenInteger(value.readingIndex);
25177const reading = firstRecordString(value, ["reading"]) ?? "";
25178if (wordId === void 0 || readingIndex === void 0 || !reading) return null;
25179return {
25180wordId,
25181readingIndex,
25182reading,
25183readingFurigana: firstRecordString(value, ["readingFurigana"]) ?? "",
25184mainDefinition: firstRecordString(value, ["mainDefinition"]) ?? "",
25185frequencyRank: nullableFiniteInteger(value.frequencyRank),
25186matchSurface: firstRecordString(value, ["matchSurface"]) ?? "",
25187audioUrls: normalizeJitenAudioUrls(value),
25188knownStates: Array.isArray(value.knownStates) ? jitenKnownStateToCardStates(value.knownStates) : void 0,
25189pitchAccents: jitenStateNumbers(value.pitchAccents)
25190};
25191}
25192function normalizeJitenVocabularyExamples(value) {
25193return arrayOfRecords(value).map(normalizeJitenVocabularyExample).filter((item) => Boolean(item));
25194}
25195function normalizeJitenVocabularyExample(value) {
25196if (!isJsonRecord(value)) return null;
25197const text2 = firstRecordString(value, ["text"]);
25198if (!text2) return null;
25199return {
25200sentenceId: finiteJitenInteger(value.sentenceId) ?? 0,
25201text: text2,
25202wordPosition: finiteJitenInteger(value.wordPosition) ?? -1,
25203wordLength: finiteJitenInteger(value.wordLength) ?? 0,
25204difficulty: nullableFiniteNumber(value.difficulty),
25205translation: firstRecordString(value, ["translation", "english", "englishText", "translatedText"]) ?? "",
25206sourceTitle: jitenExampleSourceTitle(value),
25207audioUrls: normalizeJitenAudioUrls(value)
25208};
25209}
25210function normalizeJitenKanjiInfo(value) {
25211if (!isJsonRecord(value)) return null;
25212const character = firstRecordString(value, ["character"]);
25213if (!character) return null;
25214return {
25215character,
25216onReadings: arrayOfStrings(value.onReadings),
25217kunReadings: arrayOfStrings(value.kunReadings),
25218meanings: arrayOfStrings(value.meanings),
25219strokeCount: nullableFiniteInteger(value.strokeCount),
25220jlptLevel: nullableFiniteInteger(value.jlptLevel),
25221grade: nullableFiniteInteger(value.grade),
25222frequencyRank: nullableFiniteInteger(value.frequencyRank),
25223groupingTags: normalizeJitenKanjiGroupingTags(value),
25224topWords: normalizeJitenVocabularyWordSummaries(value.topWords),
25225wordsByReading: arrayOfRecords(value.wordsByReading).map(normalizeJitenKanjiReadingWords).filter((item) => Boolean(item))
25226};
25227}
25228const JITEN_KANJI_GROUPING_TAG_FIELDS = {
25229kanken: ["kanken", "kankenLevel"],
25230wanikani: ["wanikani", "waniKani", "wanikaniLevel", "waniKaniLevel", "wk", "wkLevel"],
25231rtk: ["rtk", "rtkFrame", "rtkIndex"],
25232klc: ["klc", "klcFrame", "klcIndex"],
25233tmw: ["tmw", "tmwLevel", "tmwIndex", "theMoeWay", "theMoeWayLevel"]
25234};
25235function normalizeJitenKanjiGroupingTags(value) {
25236return {
25237kanken: jitenKanjiGroupingTag(value, JITEN_KANJI_GROUPING_TAG_FIELDS.kanken),
25238wanikani: jitenKanjiGroupingTag(value, JITEN_KANJI_GROUPING_TAG_FIELDS.wanikani),
25239rtk: jitenKanjiGroupingTag(value, JITEN_KANJI_GROUPING_TAG_FIELDS.rtk),
25240klc: jitenKanjiGroupingTag(value, JITEN_KANJI_GROUPING_TAG_FIELDS.klc),
25241tmw: jitenKanjiGroupingTag(value, JITEN_KANJI_GROUPING_TAG_FIELDS.tmw)
25242};
25243}
25244function jitenKanjiGroupingTag(value, keys) {
25245const text2 = firstRecordString(value, keys);
25246if (text2) return text2;
25247const number = firstRecordFiniteNumber(value, keys);
25248return number === null ? null : String(number);
25249}
25250function normalizeJitenKanjiReadingWords(value) {
25251if (!isJsonRecord(value)) return null;
25252const reading = firstRecordString(value, ["reading"]);
25253if (!reading) return null;
25254return {
25255reading,
25256totalWords: finiteJitenInteger(value.totalWords) ?? 0,
25257words: normalizeJitenVocabularyWordSummaries(value.words)
25258};
25259}
25260function normalizeJitenKanjiWordsPage(value) {
25261if (!isJsonRecord(value)) return null;
25262return {
25263items: normalizeJitenVocabularyWordSummaries(value.items ?? value.data),
25264total: finiteJitenInteger(value.total ?? value.totalItems) ?? 0,
25265pageSize: finiteJitenInteger(value.pageSize) ?? 0,
25266offset: finiteJitenInteger(value.offset ?? value.currentOffset) ?? 0
25267};
25268}
25269function jitenMeaningPartOfSpeech(value, index) {
25270if (!Array.isArray(value)) return [];
25271return Array.isArray(value[index]) ? arrayOfStrings(value[index]) : arrayOfStrings(value);
25272}
25273function jitenTokenTextSpan(paragraph, token, card) {
25274const raw = { start: token.start, end: token.end, length: token.end - token.start };
25275const utf8 = utf8ByteRangeToUtf16Range(paragraph, token.start, token.end);
25276return bestJitenTextSpan(paragraph, card.spelling, [raw, utf8]) ?? raw;
25277}
25278function bestJitenTextSpan(text2, expectedSurface, candidates) {
25279let best = null;
25280for (const span of candidates) {
25281if (span.start < 0 || span.end <= span.start || span.end > text2.length) continue;
25282const surface = text2.slice(span.start, span.end);
25283let score = 1;
25284if (surface === expectedSurface) score += 100;
25285else if (expectedSurface && (expectedSurface.startsWith(surface) || surface.startsWith(expectedSurface))) score += 20;
25286if (/[\u3040-\u30ff\u3400-\u9fff々〆]/u.test(surface)) score += 10;
25287if (!best || score > best.score) best = { span, score };
25288}
25289return best?.span ?? null;
25290}
25291function utf8ByteRangeToUtf16Range(text2, start, end) {
25292const utf16Start = utf16OffsetForUtf8ByteOffset(text2, start);
25293const utf16End = utf16OffsetForUtf8ByteOffset(text2, end);
25294return { start: utf16Start, end: utf16End, length: utf16End - utf16Start };
25295}
25296function utf16OffsetForUtf8ByteOffset(text2, byteOffset) {
25297if (byteOffset <= 0) return 0;
25298let bytes = 0;
25299let offset = 0;
25300for (const char of text2) {
25301if (bytes >= byteOffset) return offset;
25302const nextBytes = bytes + utf8ByteLength(char);
25303if (nextBytes > byteOffset) return offset;
25304bytes = nextBytes;
25305offset += char.length;
25306}
25307return text2.length;
25308}
25309function utf8ByteLength(char) {
25310const point = char.codePointAt(0) ?? 0;
25311if (point <= 127) return 1;
25312if (point <= 2047) return 2;
25313if (point <= 65535) return 3;
25314return 4;
25315}
25316function jitenTokenRubies(vocabulary, tokenStart) {
25317return extractJitenRubiesFromAnnotated(vocabulary?.reading ?? "").map((ruby) => ({
25318...ruby,
25319start: tokenStart + ruby.start,
25320end: tokenStart + ruby.end
25321}));
25322}
25323function extractJitenRubiesFromAnnotated(input) {
25324const rubies = [];
25325const regex = /((?:.|\n)*?)([\u4e00-\u9faf\u3005-\u3007]+)\[([^\]]+)\]/g;
25326let match;
25327let currentOffset = 0;
25328while ((match = regex.exec(input)) !== null) {
25329const prefix = match[1] ?? "";
25330const base = match[2] ?? "";
25331const text2 = match[3] ?? "";
25332currentOffset += prefix.length;
25333const start = currentOffset;
25334const length = base.length;
25335rubies.push({ text: text2, start, end: start + length, length });
25336currentOffset += length;
25337}
25338return rubies;
25339}
25340function jitenWordWithReading(spelling, rubies, tokenStart) {
25341const word = Array.from(spelling);
25342for (let index = rubies.length - 1; index >= 0; index -= 1) {
25343const ruby = rubies[index];
25344if (!ruby) continue;
25345word.splice(ruby.start - tokenStart + ruby.length, 0, `[${ruby.text}]`);
25346}
25347return word.join("");
25348}
25349function addJitenSentenceInfo(paragraphs, tokens) {
25350paragraphs.forEach((paragraph, index) => {
25351const group = tokens[index] ?? [];
25352const sentences = splitJitenJapaneseTextIntoSentences(paragraph);
25353if (sentences.length === 1) {
25354group.forEach((token) => {
25355token.sentence = sentences[0];
25356});
25357return;
25358}
25359let offset = 0;
25360sentences.forEach((sentence, sentenceIndex) => {
25361const compareSentence = sentence.replace(/(^[「『])|([。!?」』]$)/g, "");
25362const position = paragraph.substring(offset).indexOf(compareSentence);
25363if (position === -1) return;
25364const sentenceStart = offset + position;
25365const nextCompareSentence = sentences[sentenceIndex + 1]?.replace(/(^[「『])|([。!?」』]$)/g, "");
25366const nextPosition = nextCompareSentence ? paragraph.indexOf(nextCompareSentence, sentenceStart + compareSentence.length) : -1;
25367const sentenceEnd = nextPosition !== -1 ? nextPosition : paragraph.length;
25368group.forEach((token) => {
25369if (token.start >= sentenceStart && token.end <= sentenceEnd) token.sentence = sentence;
25370});
25371offset = sentenceStart + compareSentence.length;
25372});
25373});
25374}
25375function splitJitenJapaneseTextIntoSentences(text2) {
25376const sentences = text2.match(/.*?[。!?」』](?=\s?|$)|「.*?」|『.*?』/g) || [];
25377return sentences.length ? sentences.map((sentence) =>
25377 sentence.trim()).filter(Boolean).filter((sentence) => !/^[」』]$/.test(sentence)).map((sentence) => {
25378if (/「.*?」|『.*?』/.test(sentence)) return sentence;
25379const trimmed = sentence.replace(/(^「|『)|(」|』$)/, "");
25380return /[。!?]$/.test(trimmed) ? trimmed : `${trimmed}。`;
25381}) : [text2];
25382}
25383function arrayOfStrings(value) {
25384if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
25385return typeof value === "string" ? [value] : [];
25386}
25387function firstNonEmptyStringArray(...values) {
25388for (const value of values) {
25389const strings = arrayOfStrings(value);
25390if (strings.length) return strings;
25391}
25392return [];
25393}
25394function arrayOfRecords(value) {
25395return Array.isArray(value) ? value.filter(isJsonRecord) : [];
25396}
25397function nullableFiniteInteger(value) {
25398return finiteJitenInteger(value) ?? null;
25399}
25400function positiveJitenInteger(value) {
25401const parsed = finiteJitenInteger(value);
25402return parsed !== void 0 && parsed > 0 ? parsed : void 0;
25403}
25404function nullableFiniteNumber(value) {
25405return finiteJitenNumber(value) ?? null;
25406}
25407function firstRecordString(record2, keys) {
25408for (const key of keys) {
25409const value = record2[key];
25410if (typeof value === "string" && value.trim()) return value.trim();
25411}
25412return null;
25413}
25414function firstRecordFiniteNumber(record2, keys) {
25415for (const key of keys) {
25416const value = finiteJitenNumber(record2[key]);
25417if (value !== void 0) return value;
25418}
25419return null;
25420}
25421function jitenExampleSourceTitle(value) {
25422const direct = firstRecordString(value, ["sourceTitle", "title"]);
25423if (direct) return direct;
25424const sourceDeck = isJsonRecord(value.sourceDeck) ? firstRecordString(value.sourceDeck, ["title", "name"]) : null;
25425const sourceDeckParent = isJsonRecord(value.sourceDeckParent) ? firstRecordString(value.sourceDeckParent, ["title", "name"]) : null;
25426return sourceDeck ?? sourceDeckParent ?? "";
25427}
25428function normalizeJitenAudioUrls(value) {
25429const urls = uniqueJitenText([
25430...arrayOfStrings(value.audioUrls),
25431...arrayOfStrings(value.audioUrl),
25432...arrayOfStrings(value.soundUrls),
25433...arrayOfStrings(value.soundUrl)
25434]).filter(isLikelyJitenAudioUrl);
25435return urls.length ? urls : void 0;
25436}
25437function uniqueJitenText(values) {
25438const seen = new Set();
25439return values.map((value) => value.trim()).filter((value) => {
25440if (!value || seen.has(value)) return false;
25441seen.add(value);
25442return true;
25443});
25444}
25445function isLikelyJitenAudioUrl(value) {
25446try {
25447const url = new URL(value);
25448return /^https?:$/i.test(url.protocol);
25449} catch {
25450return false;
25451}
25452}
25453function jitenVocabularyEntries(value) {
25454return Array.isArray(value) ? value.filter(isJitenRawVocabulary) : [];
25455}
25456function isJitenRawVocabulary(value) {
25457if (!isJsonRecord(value)) return false;
25458const wordId = finiteJitenInteger(value.wordId);
25459const readingIndex = finiteJitenInteger(value.readingIndex);
25460return wordId !== void 0 && wordId > 0 && readingIndex !== void 0 && readingIndex >= 0 && typeof value.spelling === "string" && typeof value.reading === "string";
25461}
25462function jitenTokenEntries(value) {
25463return Array.isArray(value) ? value.filter(isJitenRawToken) : [];
25464}
25465function isJitenRawToken(value) {
25466if (!isJsonRecord(value)) return false;
25467return [
25468hasPositiveJitenInteger(value.wordId),
25469hasNonNegativeJitenInteger(value.readingIndex),
25470hasNonNegativeJitenInteger(value.start),
25471hasPositiveJitenInteger(value.length),
25472hasJitenRawTokenEndAfterStart(value)
25473].every(Boolean);
25474}
25475function hasPositiveJitenInteger(value) {
25476const parsed = finiteJitenInteger(value);
25477return parsed !== void 0 && parsed > 0;
25478}
25479function hasNonNegativeJitenInteger(value) {
25480const parsed = finiteJitenInteger(value);
25481return parsed !== void 0 && parsed >= 0;
25482}
25483function hasJitenRawTokenEndAfterStart(value) {
25484const start = finiteJitenInteger(value.start);
25485const end = finiteJitenInteger(value.end);
25486return start !== void 0 && end !== void 0 && end > start;
25487}
25488function jitenPitchAccentPatterns(value, reading) {
25489return jitenStateNumbers(value).map((position) => pitchPatternFromPosition(reading, position)).filter(Boolean);
25490}
25491function jitenStateNumbers(value) {
25492return Array.isArray(value) ? value.map(finiteJitenInteger).filter((item) => item !== void 0) : [];
25493}
25494function jitenReviewGradeIntervals(payload) {
25495const record2 = payload;
25496for (const key of JITEN_REVIEW_INTERVAL_KEYS) {
25497const parsed = jitenReviewGradeIntervalsFromValue(record2[key]);
25498if (parsed) return parsed;
25499}
25500return void 0;
25501}
25502function jitenReviewGradeIntervalsFromValue(value) {
25503if (Array.isArray(value)) return jitenReviewGradeIntervalsFromArray(value);
25504if (isJsonRecord(value)) return jitenReviewGradeIntervalsFromRecord(value);
25505return void 0;
25506}
25507function jitenReviewGradeIntervalsFromArray(values) {
25508const intervals = {};
25509values.forEach((value, index) => {
25510const meta = isJsonRecord(value) ? jitenReviewRatingMetaFromRecord(value) : void 0;
25511addJitenReviewInterval(intervals, meta ?? JITEN_REVIEW_RATINGS[index], value);
25512});
25513return Object.keys(intervals).length ? intervals : void 0;
25514}
25515function jitenReviewGradeIntervalsFromRecord(record2) {
25516const intervals = {};
25517for (const meta of JITEN_REVIEW_RATINGS) {
25518const value = meta.keys.map((key) => record2[key]).find((candidate) => candidate !== void 0);
25519addJitenReviewInterval(intervals, meta, value);
25520}
25521return Object.keys(intervals).length ? intervals : void 0;
25522}
25523function addJitenReviewInterval(intervals, meta, value) {
25524if (!meta) return;
25525const interval = jitenReviewInterval(value, meta);
25526if (!interval) return;
25527for (const grade of meta.grades) intervals[grade] = interval;
25528}
25529function jitenReviewInterval(value, meta) {
25530const record2 = isJsonRecord(value) ? value : null;
25531const buttonLabel = jitenReviewButtonLabel(record2, meta);
25532const intervalLabel = jitenReviewIntervalLabel(value, record2);
25533if (!intervalLabel) return null;
25534return {
25535buttonLabel,
25536intervalLabel,
25537label: prefixedReviewIntervalLabel(buttonLabel, intervalLabel),
25538source: "jiten-study-batch"
25539};
25540}
25541function jitenReviewButtonLabel(record2, meta) {
25542return firstString(record2, ["buttonLabel", "gradeLabel", "ratingLabel", "name"]) ?? meta.buttonLabel;
25543}
25544function jitenReviewIntervalLabel(value, record2) {
25545if (typeof value === "string") return normalizeIntervalLabel(value);
25546const explicit = firstString(record2, [
25547"intervalLabel",
25548"nextReviewLabel",
25549"nextIntervalLabel",
25550"nextReviewInterval",
25551"nextInterval",
25552"interval",
25553"duration",
25554"time",
25555"label",
25556"text"
25557]);
25558if (explicit) return normalizeIntervalLabel(explicit);
25559return jitenReviewIntervalNumberLabel(record2) ?? "";
25560}
25561function jitenReviewIntervalNumberLabel(record2) {
25562if (!record2) return null;
25563for (const [key, unit] of JITEN_REVIEW_INTERVAL_NUMERIC_KEYS) {
25564const value = finiteJitenNumber(record2[key]);
25565if (value !== void 0) return formatJitenInterval(value, unit);
25566}
25567return null;
25568}
25569function jitenReviewRatingMetaFromRecord(record2) {
25570const rating = finiteJitenInteger(record2.rating) ?? finiteJitenInteger(record2.ease) ?? finiteJitenInteger(record2.button) ?? finiteJitenInteger(record2.value);
25571if (rating !== void 0) return JITEN_REVIEW_RATINGS.find((meta) => meta.rating === rating);
25572const label = firstString(record2, ["grade", "key", "id", "name", "buttonLabel", "gradeLabel", "ratingLabel"]);
25573return label ? JITEN_REVIEW_RATINGS.find((meta) => meta.keys.includes(normalizeJitenReviewKey(label))) : void 0;
25574}
25575function firstString(record2, keys) {
25576if (!record2) return null;
25577for (const key of keys) {
25578const value = record2[key];
25579if (typeof value === "string" && value.trim()) return value.trim();
25580}
25581return null;
25582}
25583function normalizeIntervalLabel(value) {
25584return value.replace(/\s+/g, " ").trim();
25585}
25586function prefixedReviewIntervalLabel(buttonLabel, intervalLabel) {
25587return intervalLabel.toLocaleLowerCase().startsWith(buttonLabel.toLocaleLowerCase()) ? intervalLabel : `${buttonLabel} ${intervalLabel}`;
25588}
25589function normalizeJitenReviewKey(value) {
25590return value.replace(/[_\s-]+/g, "").toLocaleLowerCase();
25591}
25592function formatJitenInterval(value, unit) {
25593if (unit === "seconds") return formatJitenSeconds(value);
25594if (unit === "minutes") return `${formatJitenIntervalNumber(value)}m`;
25595if (unit === "hours") return `${formatJitenIntervalNumber(value)}h`;
25596if (unit === "days") return `${formatJitenIntervalNumber(value)}d`;
25597if (unit === "months") return `${formatJitenIntervalNumber(value)}mo`;
25598return `${formatJitenIntervalNumber(value)}y`;
25599}
25600function formatJitenSeconds(seconds) {
25601if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s`;
25602const minutes = seconds / 60;
25603if (minutes < 60) return `${formatJitenIntervalNumber(minutes)}m`;
25604const hours = minutes / 60;
25605if (hours < 24) return `${formatJitenIntervalNumber(hours)}h`;
25606const days = hours / 24;
25607if (days < 365) return `${formatJitenIntervalNumber(days)}d`;
25608return `${formatJitenIntervalNumber(days / 365)}y`;
25609}
25610function formatJitenIntervalNumber(value) {
25611return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, "");
25612}
25613const JITEN_REVIEW_INTERVAL_KEYS = [
25614"reviewButtons",
25615"reviewGradeIntervals",
25616"nextReviewIntervals",
25617"nextIntervals",
25618"nextReviews",
25619"reviewIntervals",
25620"srsIntervals",
25621"ratingIntervals"
25622];
25623const JITEN_REVIEW_RATINGS = [
25624{ rating: 1, buttonLabel: "Again", grades: ["nothing", "fail"], keys: ["1", "rating1", "again", "nothing", "fail"] },
25625{ rating: 2, buttonLabel: "Hard", grades: ["something", "hard"], keys: ["2", "rating2", "hard", "something"] },
25626{ rating: 3, buttonLabel: "Good", grades: ["okay", "pass"], keys: ["3", "rating3", "good", "okay", "pass"] },
25627{ rating: 4, buttonLabel: "Easy", grades: ["easy"], keys: ["4", "rating4", "easy"] }
25628];
25629const JITEN_REVIEW_INTERVAL_NUMERIC_KEYS = [
25630["intervalSeconds", "seconds"],
25631["nextIntervalSeconds", "seconds"],
25632["nextReviewSeconds", "seconds"],
25633["intervalMinutes", "minutes"],
25634["nextIntervalMinutes", "minutes"],
25635["nextReviewMinutes", "minutes"],
25636["intervalHours", "hours"],
25637["nextIntervalHours", "hours"],
25638["nextReviewHours", "hours"],
25639["intervalDays", "days"],
25640["nextIntervalDays", "days"],
25641["nextReviewDays", "days"],
25642["intervalMonths", "months"],
25643["nextIntervalMonths", "months"],
25644["nextReviewMonths", "months"],
25645["intervalYears", "years"],
25646["nextIntervalYears", "years"],
25647["nextReviewYears", "years"]
25648];
25649function jitenLookupKey(wordId, readingIndex) {
25650return `${wordId}:${readingIndex}`;
25651}
25652function isJitenAuthenticationError(error) {
25653return error instanceof JitenApiError && (error.status === 401 || error.status === 403);
25654}
25655async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
25656const controller = new AbortController();
25657const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs);
25658try {
25659return await fetchImpl(url, { ...init, signal: controller.signal });
25660} catch (error) {
25661if (isAbortError$2(error)) throw new JitenApiError("Jiten request timed out.");
25662throw error;
25663} finally {
25664globalThis.clearTimeout(timeoutId);
25665}
25666}
25667async function parseJitenResponse(response, authenticated) {
25668const text2 = await response.text();
25669const json = parseJson(text2, void 0, "jiten.parseJitenResponse");
25670const errorMessage = jitenApplicationErrorMessage(json);
25671const rejectedKey = authenticated && (response.status === 401 || response.status === 403);
25672if (errorMessage) {
25673throw rejectedKey ? new JitenApiError("Jiten rejected the API key.", response.status) : new JitenApiError(errorMessage, response.status);
25674}
25675if (!response.ok) throw new JitenApiError(jitenStatusMessage(response.status, authenticated), response.status);
25676return json;
25677}
25678function parseJitenPayload(payload) {
25679const errorMessage = jitenApplicationErrorMessage(payload);
25680if (errorMessage) throw new JitenApiError(errorMessage);
25681return payload;
25682}
25683function normalizeJitenRequestError(error, authenticated) {
25684if (error instanceof JitenApiError) return error;
25685const status = error instanceof Error ? statusFromMessage(error.message) : void 0;
25686if (status) return new JitenApiError(jitenStatusMessage(status, authenticated), status);
25687if (error instanceof Error && /timed out|abort/i.test(error.message)) return new JitenApiError("Jiten request timed out.");
25688return error instanceof Error ? error : new JitenApiError("Jiten request failed.");
25689}
25690function jitenStatusMessage(status, authenticated) {
25691return authenticated && (status === 401 || status === 403) ? "Jiten rejected the API key." : `Jiten request failed (${status}).`;
25692}
25693function statusFromMessage(message) {
25694const match = /\((\d{3})\)/.exec(message);
25695return match ? Number(match[1]) : void 0;
25696}
25697function jitenApplicationErrorMessage(value) {
25698if (!isJsonRecord(value)) return void 0;
25699const message = value.error_message;
25700return typeof message === "string" && message ? message : void 0;
25701}
25702function normalizeReaderStudyDecks(value) {
25703if (!Array.isArray(value)) throw new JitenApiError("Jiten reader study deck response was invali
25703d.");
25704return value.map(normalizeReaderStudyDeck);
25705}
25706function normalizeReaderStudyDeck(value) {
25707if (!isJsonRecord(value)) throw new JitenApiError("Jiten reader study deck response was invalid.");
25708const { userStudyDeckId, name } = value;
25709if (typeof userStudyDeckId !== "number" || !Number.isFinite(userStudyDeckId) || typeof name !== "string") {
25710throw new JitenApiError("Jiten reader study deck response was invalid.");
25711}
25712return { userStudyDeckId, name };
25713}
25714function normalizeJitenStudyDeckId(value) {
25715const id = typeof value === "number" ? value : Number(value.trim());
25716if (!Number.isInteger(id) || id <= 0) throw new JitenApiError("Jiten study deck id was invalid.");
25717return id;
25718}
25719function finiteJitenInteger(value) {
25720return typeof value === "number" && Number.isInteger(value) ? value : void 0;
25721}
25722function finiteJitenNumber(value) {
25723return typeof value === "number" && Number.isFinite(value) ? value : void 0;
25724}
25725function isMissingJitenApiKeyError(error) {
25726return error instanceof JitenApiError && error.message === MISSING_API_KEY_MESSAGE;
25727}
25728function isJsonRecord(value) {
25729return Boolean(value && typeof value === "object");
25730}
25731function endpointUrl(baseUrl, endpoint, query) {
25732const base = (baseUrl?.trim() || JITEN_API_BASE_URL).replace(/\/+$/, "");
25733const url = `${base}/${endpoint}`;
25734const params = new URLSearchParams();
25735Object.entries(query ?? {}).forEach(([key, value]) => {
25736if (value === void 0 || value === null || value === "") return;
25737params.set(key, String(value));
25738});
25739const queryString = params.toString();
25740return queryString ? `${url}?${queryString}` : url;
25741}
25742class TermSpanResolver {
25743#target;
25744#lookup;
25745#fallback;
25746#preconfirm;
25747#plan;
25748#admit;
25749#maximumSourceLength;
25750constructor(options) {
25751this.#target = options.target;
25752this.#lookup = options.lookup;
25753this.#fallback = options.fallback;
25754this.#preconfirm = options.preconfirm;
25755this.#plan = options.plan;
25756this.#admit = options.admit;
25757this.#maximumSourceLength = Math.max(1, Math.floor(options.maximumSourceLength ?? 18));
25758}
25759/** Resolve the longest provider-confirmed term beginning at `source.start`. */
25760async resolveAt(source) {
25761const range = sourceRange(source);
25762if (range.start === range.end) return null;
25763const planned = this.#candidatesAt(range, range.start);
25764if (!planned.length) return null;
25765const confirmations = await this.#confirm(planned);
25766return firstConfirmedSpan(
25767range.text,
25768planned,
25769confirmations,
25770this.#admit,
25771singleStartAdmitContext(planned, confirmations)
25772);
25773}
25774/**
25775* Resolve all confirmed terms in reading order, advancing by each winning
25776* span. Fallback is considered only after confirmed spans are fixed and is
25777* bounded to the uncovered gaps between them.
25778*/
25779async resolveAll(source) {
25780const range = sourceRange(source);
25781if (range.start === range.end) return [];
25782const plannedByStart = new Map();
25783const allPlanned = [];
25784for (const start of codePointStarts(range.text, range.start, range.end)) {
25785const planned = this.#candidatesAt(range, start);
25786plannedByStart.set(start, planned);
25787allPlanned.push(...planned);
25788}
25789const confirmations = await this.#confirm(allPlanned);
25790const confirmed = this.#confirmedSpans(range, plannedByStart, confirmations);
25791if (!this.#fallback) return confirmed;
25792return this.#withFallbackSpans(range, confirmed, this.#fallback);
25793}
25794async #confirm(planned) {
25795const preconfirmed = new Map();
25796const pending = [];
25797for (const item of planned) {
25798const match = this.#preconfirm?.({
25799start: item.start,
25800end: item.end,
25801surface: item.request.surface,
25802lookupCandidate: item.request.lookupCandidate
25803});
25804if (match === null || match === void 0) {
25805pending.push(item);
25806continue;
25807}
25808preconfirmed.set(item.request, match);
25809}
25810if (!pending.length) return preconfirmed;
25811const looked = await this.#lookup.lookup(pending.map((item) => item.request));
25812if (!preconfirmed.size) return looked;
25813for (const [request, match] of looked) preconfirmed.set(request, match);
25814return preconfirmed;
25815}
25816#candidatesAt(range, start) {
25817const planned = [];
25818const maximumEnd = Math.min(range.end, start + this.#maximumSourceLength);
25819for (const end of codePointEndsLongestFirst(range.text, start, maximumEnd)) {
25820const surface = range.text.slice(start, end);
25821if (this.#plan && !this.#plan({
25822start,
25823end,
25824surface,
25825lookupCandidate: { term: surface, rules: [], reasons: [], depth: 0 }
25826})) {
25827continue;
25828}
25829const lookupCandidates = [...this.#target.lookupCandidates(surface)].map((lookupCandidate, order) => ({ lookupCandidate, order })).sort((a, b) => this.#target.compareLookupCandidates(
25830a.lookupCandidate,
25831b.lookupCandidate
25832) || a.order - b.order);
25833for (const { lookupCandidate } of lookupCandidates) {
25834planned.push({
25835start,
25836end,
25837request: { surface, lookupCandidate }
25838});
25839}
25840}
25841return planned;
25842}
25843#confirmedSpans(range, plannedByStart, confirmations) {
25844const confirmed = [];
25845const admitContext = {
25846hasConfirmedSpanAt: (offset) => (plannedByStart.get(offset) ?? []).some((item) => confirmations.has(item.request))
25847};
25848let cursor = range.start;
25849while (cursor < range.end) {
25850const winner = firstConfirmedSpan(
25851range.text,
25852plannedByStart.get(cursor) ?? [],
25853confirmations,
25854this.#admit,
25855admitContext
25856);
25857if (winner) {
25858confirmed.push(winner);
25859cursor = winner.end;
25860continue;
25861}
25862cursor = nextCodePointOffset(range.text, cursor);
25863}
25864return confirmed;
25865}
25866async #withFallbackSpans(range, confirmed, fallback) {
25867const resolved = [];
25868let gapStart = range.start;
25869for (const span of confirmed) {
25870resolved.push(...await fallbackSpansInGap(range.text, gapStart, span.start, fallback));
25871resolved.push(span);
25872gapStart = span.end;
25873}
25874resolved.push(...await fallbackSpansInGap(range.text, gapStart, range.end, fallback));
25875return resolved;
25876}
25877}
25878function singleStartAdmitContext(planned, confirmations) {
25879return {
25880hasConfirmedSpanAt: (offset) => planned.some((item) => item.start === offset && confirmations.has(item.request))
25881};
25882}
25883function firstConfirmedSpan(text2, planned, confirmations, admit, admitContext = { hasConfirmedS
25883panAt: () => false }) {
25884for (const item of planned) {
25885if (!confirmations.has(item.request)) continue;
25886const match = confirmations.get(item.request);
25887const surface = text2.slice(item.start, item.end);
25888if (admit && !admit({
25889start: item.start,
25890end: item.end,
25891surface,
25892lookupCandidate: item.request.lookupCandidate
25893}, match, admitContext)) continue;
25894return {
25895kind: "confirmed",
25896start: item.start,
25897end: item.end,
25898surface,
25899lookupCandidate: item.request.lookupCandidate,
25900match
25901};
25902}
25903return null;
25904}
25905async function fallbackSpansInGap(text2, start, end, fallback) {
25906const spans = [];
25907let cursor = start;
25908while (cursor < end) {
25909const candidate = await fallback.spanAt({ text: text2, start: cursor, end });
25910if (isValidFallbackSpan(text2, cursor, end, candidate)) {
25911spans.push({
25912kind: "fallback",
25913start: candidate.start,
25914end: candidate.end,
25915surface: text2.slice(candidate.start, candidate.end),
25916fallback: candidate.value
25917});
25918cursor = candidate.end;
25919continue;
25920}
25921cursor = nextCodePointOffset(text2, cursor);
25922}
25923return spans;
25924}
25925function isValidFallbackSpan(text2, expectedStart, maximumEnd, candidate) {
25926return candidate !== null && Number.isInteger(candidate.start) && Number.isInteger(candidate.end) && candidate.start === expectedStart && candidate.end > candidate.start && candidate.end <= maximumEnd && isCodePointBoundary(text2, candidate.start) && isCodePointBoundary(text2, candidate.end);
25927}
25928function sourceRange(source) {
25929const end = source.end ?? source.text.length;
25930if (!Number.isInteger(source.start) || !Number.isInteger(end) || source.start < 0 || end < source.start || end > source.text.length || !isCodePointBoundary(source.text, source.start) || !isCodePointBoundary(source.text, end)) {
25931throw new RangeError("Term span source must use valid UTF-16 code-point boundaries.");
25932}
25933return { text: source.text, start: source.start, end };
25934}
25935function codePointStarts(text2, start, end) {
25936const starts = [];
25937let cursor = start;
25938while (cursor < end) {
25939starts.push(cursor);
25940cursor = nextCodePointOffset(text2, cursor);
25941}
25942return starts;
25943}
25944function codePointEndsLongestFirst(text2, start, end) {
25945const ends = [];
25946let cursor = start;
25947while (cursor < end) {
25948const next = nextCodePointOffset(text2, cursor);
25949if (next > end) break;
25950cursor = next;
25951ends.push(cursor);
25952}
25953return ends.reverse();
25954}
25955function nextCodePointOffset(text2, offset) {
25956const codePoint = text2.codePointAt(offset);
25957return offset + (codePoint !== void 0 && codePoint > 65535 ? 2 : 1);
25958}
25959function isCodePointBoundary(text2, offset) {
25960if (offset <= 0 || offset >= text2.length) return true;
25961const before = text2.charCodeAt(offset - 1);
25962const after = text2.charCodeAt(offset);
25963return !(before >= 55296 && before <= 56319 && after >= 56320 && after <= 57343);
25964}
25965const KANA_CHAR_RE = new RegExp(`^[${KANA_WITH_PROLONGED}]$`, "u");
25966function splitReadingAcrossKanji(base, reading, readingsForKanji) {
25967if (kanjiCharacterCount(base) < 2) return null;
25968const sourceReading = reading.trim();
25969const kana = toHiragana(sourceReading);
25970if (!kana || !KANA_ONLY_RUN_RE.test(kana)) return null;
25971const trimmed = trimSharedKanaAffixes(base, kana);
25972const characters = Array.from(trimmed.base);
25973if (characters.length < 2 || !characters.every(isKanjiRubyCharacter)) return null;
25974const plans = alignKanjiReadings(characters, trimmed.reading, 0, readingsForKanji);
25975if (plans.length !== 1) return null;
25976const readingCharacters = Array.from(sourceReading);
25977const segments = [];
25978let readingOffset = trimmed.readingStart;
25979let baseOffset = trimmed.baseStart;
25980plans[0].forEach((segment, index) => {
25981const character = characters[index] ?? "";
25982const segmentText = readingCharacters.slice(readingOffset, readingOffset + segment.length).join("");
25983segments.push({
25984text: segmentText,
25985start: baseOffset,
25986end: baseOffset + character.length
25987});
25988readingOffset += segment.length;
25989baseOffset += character.length;
25990});
25991return segments;
25992}
25993function trimSharedKanaAffixes(base, reading) {
25994let baseStart = 0;
25995let baseEnd = base.length;
25996let readingStart = 0;
25997let readingEnd = reading.length;
25998while (baseStart < baseEnd && readingStart < readingEnd && sameKana(base[baseStart], reading[readingStart])) {
25999baseStart += 1;
26000readingStart += 1;
26001}
26002while (baseEnd > baseStart && readingEnd > readingStart && sameKana(base[baseEnd - 1], reading[readingEnd - 1])) {
26003baseEnd -= 1;
26004readingEnd -= 1;
26005}
26006return {
26007base: base.slice(baseStart, baseEnd),
26008reading: reading.slice(readingStart, readingEnd),
26009baseStart,
26010readingStart
26011};
26012}
26013function sameKana(base, reading) {
26014return Boolean(base && reading && KANA_CHAR_RE.test(base) && toHiragana(base) === reading);
26015}
26016function kanjiCharacterCount(value) {
26017return Array.from(value).filter(isKanjiRubyCharacter).length;
26018}
26019function isKanjiRubyCharacter(value) {
26020return value === ITERATION_MARK || isJapaneseKanjiCharacter(value);
26021}
26022function alignKanjiReadings(characters, kana, index, readingsForKanji) {
26023if (index >= characters.length) return kana.length === 0 ? [[]] : [];
26024const candidates = candidateReadings(characters[index], readingsForKanji);
26025const plans = [];
26026for (const candidate of candidates) {
26027if (!kana.startsWith(candidate)) continue;
26028for (const rest of alignKanjiReadings(characters, kana.slice(candidate.length), index + 1, readingsForKanji)) {
26029plans.push([candidate, ...rest]);
26030if (plans.length > 1) return plans;
26031}
26032}
26033return plans;
26034}
26035function candidateReadings(kanji, readingsForKanji) {
26036const seen = new Set();
26037for (const raw of readingsForKanji(kanji)) {
26038const normalized = toHiragana(raw.trim()).replace(/[.\-.].*$/u, "");
26039if (!normalized || !KANA_ONLY_RUN_RE.test(normalized)) continue;
26040seen.add(normalized);
26041const voiced = withInitialDakuten(normalized);
26042if (voiced) seen.add(voiced);
26043if (/[つくきち]$/u.test(normalized)) seen.add(`${normalized.slice(0, -1)}っ`);
26044}
26045return [...seen].sort((a, b) => b.length - a.length);
26046}
26047const DAKUTEN_MAP = {
26048か: "が",
26049き: "ぎ",
26050く: "ぐ",
26051け: "げ",
26052こ: "ご",
26053さ: "ざ",
26054し: "じ",
26055す: "ず",
26056せ: "ぜ",
26057そ: "ぞ",
26058た: "だ",
26059ち: "ぢ",
26060つ: "づ",
26061て: "で",
26062と: "ど",
26063は: "ば",
26064ひ: "び",
26065ふ: "ぶ",
26066へ: "べ",
26067ほ: "ぼ"
26068};
26069function withInitialDakuten(reading) {
26070const voiced = DAKUTEN_MAP[reading[0] ?? ""];
26071return voiced ? `${voiced}${reading.slice(1)}` : null;
26072}
26073function toHiragana(value) {
26074return value.replace(/[ァ-ヶ]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 96));
26075}
26076const LOCAL_MATCH_LIMIT = 40;
26077const LOCAL_ENRICHMENT_CONCURRENCY = 12;
26078const LOCAL_PARSE_CACHE_LIMIT = 600;
26079const LOCAL_PITCH_CACHE_LIMIT = 800;
26080const JPDB_PARSE_FALLBACK_TIMEOUT_MS = 6e3;
26081const LOCAL_PARSE_TIMEOUT_MS = 8e3;
26082const YOUTUBE_VIEW_METRIC_RE = /回視聴/gu;
26083const JITEN_MIN_BATCH_CHARS = 24;
26084const JAPANESE_CHAR_COUNT_RE = new RegExp(
26085`(?:[${KANA}${ITERATION_MARK}${HALFWIDTH_KATAKANA}]|${KANJI_PATTERN})`,
26086"gu"
26087);
26088function japaneseBatchCharCount(paragraphs) {
26089return paragraphs.reduce((total, text2) => total + (text2.match(JAPANESE_CHAR_COUNT_RE)?.length ?? 0), 0);
26090}
26091const LOCAL_RUBY_SPLIT_BASE_RE = new RegExp(
26092`^(?:[${KANA}${ITERATION_MARK}${PROLONGED_SOUND_MARK}${KATAKANA_MIDDLE_DOT}]|${KANJI_PATTERN})+$`,
26093"u"
26094);
26095const LOCAL_RUBY_SPLIT_KANJI_RE = new RegExp(`(?:${KANJI_PATTERN}|[${ITERATION_MARK}])`, "u");
26096const LOCAL_RUBY_SPLIT_KANJI_CHAR_RE = new RegExp(`^(?:${KANJI_PATTERN}|[${ITERATION_MARK}])$`, "u");
26097const log$8 = Logger.scope("ReaderParser");
26098function apiFirstParseOptions(options = {}) {
26099const requireApi = options.requireApi ?? options.requireJpdb ?? true;
26100return { includeLocalPitch: false, ...options, requireApi };
26101}
26102function jpdbFirstParseOptions(options = {}) {
26103const requireApi = options.requireApi ?? options.requireJpdb ?? true;
26104const requireJpdb = options.requireJpdb ?? requireApi;
26105return apiFirstParseOptions({ ...options, requireApi, requireJpdb });
26106}
26107class ReaderParser {
26108constructor(dependencies) {
26109this.dependencies = dependencies;
26110}
26111localCardCache = new Map();
26112localCardEvidenceCache = new Map();
26113localParseCache = new Map();
26114localPitchCache = new Map();
26115localTermDictionaryAvailability;
26116enrichmentGate = new ConcurrencyGate(LOCAL_ENRICHMENT_CONCURRENCY);
26117kanjiReadingCache = new Map();
26118async parse(paragraphs, options = {}) {
26119const { getSettings } = this.dependencies;
26120const settings2 = getSettings();
26121const target = activeLearningTarget();
26122const targetGeneration = activeLearningTargetGeneration();
26123const done = log$8.time("parse", {
26124paragraphs: paragraphs.length,
26125hasApiKey: hasJpdbApiCredential(settings2),
26126hasJitenApiKey: hasJitenApiCredential(settings2),
26127localFallback: settings2.localDictionariesEnabled
26128});
26129try {
26130const parsed = await this.parseWithPreferredSource(paragraphs, options, settings2, target);
26131if (!isCurrentLearningTarget(target, targetGeneration)) return emptyParseResult(paragraphs);
26132const authoritative = await this.withAuthoritativeTermSpans(paragraphs, parsed, options, target);
26133if (!isCurrentLearningTarget(target, targetGeneration)) return emptyParseResult(paragraphs);
26134const evidenceReconciled = this.withCachedCardEvidence(paragraphs, authoritative);
26135const rubyAligned = await this.reconcileLocalParse(paragraphs, evidenceReconciled, options, target);
26136if (!isCurrentLearningTarget(target, targetGeneration)) return emptyParseResult(paragraphs);
26137const normalized = this.withNormalizedMetricParseResult(paragraphs, rubyAligned);
26138if (!settings2.yomuLocalSrsEnabled || !this.dependencies.yomuLocalSrs) return normalized;
26139try {
26140const hydrated = await withTimeout(
26141hydrateYomuLocalSrsCardStates(normalized, this.dependencies.yomuLocalSrs),
26142LOCAL_PARSE_TIMEOUT_MS,
26143() => new Error("Academy SRS state hydration timed out.")
26144);
26145return isCurrentLearningTarget(target, targetGeneration) ? hydrated : emptyParseResult(paragraphs);
26146} catch (error) {
26147log$8.warn("Academy SRS state hydration failed; keeping provider states", error);
26148return isCurrentLearningTarget(target, targetGeneration) ? normalized : emptyParseResult(paragraphs);
26149}
26150} finally {
26151done();
26152}
26153}
26154/**
26155* Resolve the authoritative token underneath one source offset through the
26156* exact same span pipeline used for passive annotation.
26157*/
26158async lookupTokenAt(text2, offset, range = { start: 0, end: text2.length }, options = {}) {
26159const [tokens] = await this.parse([text2], { ...options, allowSegmentedFallback: true });
26160return pickAuthoritativeTokenAt(tokens ?? [], text2, offset, range);
26161}
26162async withAuthoritativeTermSpans(paragraphs, parsed, options, target) {
26163const sources = await this.authoritativeSpanSources(paragraphs, options, target);
26164const lookup = new BatchedParserSpanLookup((requests) => this.authoritativeSpanConfirmations(requests, sources, options, target));
26165const resolved = await Promise.all(paragraphs.map((text2, index) => this.authoritativeTermSpans(text2, parsed[index] ?? [], options, target, lookup)));
26166return resolved.every((tokens, index) => tokens === parsed[index]) ? parsed : resolved;
26167}
26168async authoritativeTermSpans(text2, decorations, options, target, lookup) {
26169const fallbackSegments = target.segment(text2);
26170const fallback = options.allowSegmentedFallback === true ? {
26171spanAt: (request) => {
26172const segment = fallbackSegments.find((item) => item.start <= request.start && request.start < item.end);
26173if (!segment) return null;
26174const end = Math.min(segment.end, request.end);
26175if (end <= request.start) return null;
26176const surface = text2.slice(request.start, end);
26177if (!target.isLookupableText(surface)) return null;
26178return {
26179start: request.start,
26180end,
26181value: this.fallbackCardFromText(surface, target)
26182};
26183}
26184} : void 0;
26185const resolver = new TermSpanResolver({
26186target,
26187maximumSourceLength: 18,
26188lookup,
26189fallback,
26190preconfirm: decorationAlignedSpanConfirmation(text2, decorations, target, {
26191trustLocalDecorations: this.localStoreLacksExactBatch()
26192}),
26193plan: (candidate) => !segmentsContainInternalParticle(text2, fallbackSegments, candidate.start, candidate.end),
26194admit: rejectUntrustworthySpanShapes(text2, fallbackSegments, target)
26195});
26196const spans = (await Promise.all(target.pointerWordSegments(text2).map((run) => resolver.resolveAll({ text: text2, start: run.start, end: run.end })))).flat().sort((first, second) =>
26196 first.start - second.start || second.end - first.end);
26197const tokens = spans.map((span) => authoritativeTokenFromSpan(text2, span, decorations));
26198return tokens.length === decorations.length && tokens.every((token, index) => token === decorations[index]) ? decorations : tokens;
26199}
26200/**
26201* Confirm candidates through exact term lookups. Paragraph parser tokens
26202* are intentionally absent from this method: they may decorate a winning
26203* card later, but their offsets and lexical choices cannot establish a
26204* source span.
26205*/
26206async authoritativeSpanConfirmations(requests, sources, options, target) {
26207const confirmed = new Map();
26208for (const source of sources) {
26209const pending = requests.filter((request) => !confirmed.has(request));
26210if (!pending.length) break;
26211if (source === "local" && await this.confirmLocalParserSpanRequests(pending, target, confirmed)) continue;
26212const terms = authoritativeLookupTerms(pending, target);
26213if (!terms.length) continue;
26214const cards = await this.authoritativeCardsForSource(source, terms, options, target);
26215confirmParserSpanRequests(pending, cards, target, confirmed);
26216}
26217return confirmed;
26218}
26219localStoreLacksExactBatch() {
26220const store = this.dependencies.dictionaries;
26221return typeof store.lookupExactTermCandidates !== "function";
26222}
26223async confirmLocalParserSpanRequests(requests, target, confirmed) {
26224const store = this.dependencies.dictionaries;
26225if (typeof store.lookupExactTermCandidates !== "function") {
26226return typeof store.lookup !== "function";
26227}
26228try {
26229const matches = await store.lookupExactTermCandidates(
26230requests,
26231this.dependencies.getSettings().dictionaryPreferences,
26232target
26233);
26234for (const match of matches) {
26235const card = this.localCardFromEntry(match.entry, target);
26236const term = target.normalizeText(match.request.lookupCandidate.term);
26237if (cardMatchesLookupTerm(card, term, target) && parserSpanRulesMatch(card, match.request, target)) {
26238confirmed.set(match.request, { card });
26239}
26240}
26241return true;
26242} catch (error) {
26243log$8.warn("Exact local term span lookup failed", { requests: requests.length }, error);
26244return true;
26245}
26246}
26247async authoritativeSpanSources(paragraphs, options, target) {
26248const settings2 = this.dependencies.getSettings();
26249const hasLocal = await this.hasLocalTermDictionaries();
26250const local = hasLocal ? ["local"] : [];
26251if (target.language !== "ja" || shouldSkipApiParser(options)) return local;
26252if (settings2.parserProvider === "local" && hasLocal) return local;
26253if (this.shouldRouteShortBatchToLocal([...paragraphs], options, settings2) && hasLocal) return local;
26254const jpdb = hasJpdbApiCredential(settings2) ? ["jpdb"] : [];
26255const jiten = shouldUseJitenParser(settings2, options, this.dependencies.jiten) ? ["jiten"] : [];
26256const publicJiten = options.allowSegmentedFallback === true && this.dependencies.jitenPublicVocabulary && (typeof navigator === "undefined" || navigator.onLine !== false) ? ["public-jiten"] : [];
26257if (options.requireJpdb === true) return [...jpdb, ...local, ...publicJiten];
26258if (settings2.parserProvider === "jiten") return [...jiten, ...local, ...publicJiten];
26259if (settings2.parserProvider === "jpdb") return [...jpdb, ...local, ...publicJiten];
26260return shouldPreferJitenParser(settings2, options, this.dependencies.jiten) ? [...jiten, ...jpdb, ...local, ...publicJiten] : [...jpdb, ...jiten, ...local, ...publicJiten];
26261}
26262async authoritativeCardsForSource(source, terms, options, target) {
26263try {
26264if (source === "local") return await this.localAuthoritativeCards(terms, target);
26265if (source === "public-jiten") return await this.publicJitenAuthoritativeCards(terms, options, target);
26266const parsed = source === "jpdb" ? await this.parseWithJpdb([...terms], options) : await this.parseWithJiten([...terms], options);
26267return authoritativeCardsFromParsedTerms(terms, parsed, target);
26268} catch (error) {
26269log$8.warn("Exact term span lookup failed", { source, terms: terms.length }, error);
26270return new Map();
26271}
26272}
26273async localAuthoritativeCards(terms, target) {
26274const settings2 = this.dependencies.getSettings();
26275const store = this.dependencies.dictionaries;
26276if (typeof store.lookup !== "function") return new Map();
26277const rows = await mapLimited(terms, LOCAL_ENRICHMENT_CONCURRENCY, async (term) => {
26278const entries2 = await store.lookup(term, term, LOCAL_MATCH_LIMIT, settings2.dictionaryPreferences);
26279return [term, entries2];
26280});
26281const cards = new Map();
26282for (const [term, entries2] of rows) {
26283const key = target.normalizeText(term);
26284cards.set(key, entries2.filter((entry) => [entry.expression, entry.reading].some((value) => target.normalizeText(value) === key)).map((entry) => this.localCardFromEntry(entry, target)));
26285}
26286return cards;
26287}
26288async publicJitenAuthoritativeCards(terms, options, target) {
26289const client = this.dependencies.jitenPublicVocabulary;
26290if (!client) return new Map();
26291if (typeof client.lookupMany === "function") {
26292const found = await client.lookupMany(terms, {
26293detailLimit: Math.max(options.publicJitenDetailLimit ?? 0, terms.length)
26294});
26295const cards = new Map();
26296found.forEach((card, term) => cards.set(target.normalizeText(term), [card]));
26297return cards;
26298}
26299const parsed = await client.parse(terms, {
26300detailLimit: Math.max(options.publicJitenDetailLimit ?? 0, terms.length)
26301});
26302return authoritativeCardsFromParsedTerms(terms, parsed, target);
26303}
26304async reconcileLocalParse(paragraphs, parsed, options, target) {
26305try {
26306return await withTimeout(
26307this.reconcileLocalParseResolved(paragraphs, parsed, options, target),
26308LOCAL_PARSE_TIMEOUT_MS,
26309() => new Error("Local parse reconciliation timed out.")
26310);
26311} catch (error) {
26312log$8.warn("Local parse reconciliation timed out or failed; keeping unreconciled parse", error);
26313return parsed;
26314}
26315}
26316async reconcileLocalParseResolved(paragraphs, parsed, _options, target) {
26317return this.withLocallySplitKanjiRubies(paragraphs, parsed, target);
26318}
26319async parseWithPreferredSource(paragraphs, options, settings2, target) {
26320if (target.language !== "ja") {
26321return Promise.all(paragraphs.map((text2) => this.parseLocalOrSegmentedText(text2, options, target)));
26322}
26323if (settings2.parserProvider === "local" && await this.hasLocalTermDictionaries(true)) {
26324return Promise.all(paragraphs.map((text2) => this.parseLocalOrSegmentedText(text2, options, target)));
26325}
26326if (this.shouldRouteShortBatchToLocal(paragraphs, options, settings2) && await this.hasLocalTermDictionaries(true)) {
26327return Promise.all(paragraphs.map((text2) => this.parseLocalOrSegmentedText(text2, options, target)));
26328}
26329if (settings2.parserProvider === "jiten" && options.requireJpdb !== true) {
26330const jitenResult2 = await this.tryParseWithJiten(paragraphs, options, settings2, target);
26331if (jitenResult2) return jitenResult2;
26332return this.parseWithFallbackSource(paragraphs, options, target);
26333}
26334if (settings2.parserProvider === "jpdb") {
26335const jpdbResult2 = await this.tryParseWithJpdb(paragraphs, options, settings2, target);
26336if (jpdbResult2) return jpdbResult2;
26337return this.parseWithFallbackSource(paragraphs, options, target);
26338}
26339if (shouldPreferJitenParser(settings2, options, this.dependencies.jiten)) {
26340const jitenResult2 = await this.tryParseWithJiten(paragraphs, options, settings2, target);
26341if (jitenResult2) return jitenResult2;
26342const jpdbResult2 = await this.tryParseWithJpdb(paragraphs, options, settings2, target);
26343if (jpdbResult2) return jpdbResult2;
26344return this.parseWithFallbackSource(paragraphs, options, target);
26345}
26346const jpdbResult = await this.tryParseWithJpdb(paragraphs, options, settings2, target);
26347if (jpdbResult) return jpdbResult;
26348const jitenResult = await this.tryParseWithJiten(paragraphs, options, settings2, target);
26349if (jitenResult) return jitenResult;
26350return this.parseWithFallbackSource(paragraphs, options, target);
26351}
26352shouldRouteShortBatchToLocal(paragraphs, options, settings2) {
26353if (settings2.parserProvider !== "jiten" || options.requireJpdb === true) return false;
26354return japaneseBatchCharCount(paragraphs) < JITEN_MIN_BATCH_CHARS;
26355}
26356async tryParseWithJpdb(paragraphs, options, settings2, target) {
26357if (!hasJpdbApiCredential(settings2) || shouldSkipApiParser(options)) return null;
26358try {
26359const result = await this.parseWithJpdb(paragraphs, options);
26360return this.withSegmentedFallbackGaps(paragraphs, result, options, target);
26361} catch (error) {
26362this.handleRemoteParseError("JPDB", error, options);
26363return null;
26364}
26365}
26366async parseWithJpdb(paragraphs, options) {
26367const parsePromise = this.dependencies.jpdb.parse(paragraphs);
26368const timeoutMs = remoteParseFallbackTimeoutMs(options);
26369return timeoutMs > 0 ? withTimeout(parsePromise, timeoutMs, () => new Error("JPDB parse timed out.")) : parsePromise;
26370}
26371async tryParseWithJiten(paragraphs, options, settings2, target) {
26372if (!shouldUseJitenParser(settings2, options, this.dependencies.jiten)) return null;
26373try {
26374const result = await this.parseWithJiten(paragraphs, options);
26375return this.withSegmentedFallbackGaps(paragraphs, result, options, target);
26376} catch (error) {
26377this.handleRemoteParseError("Jiten", error, options);
26378return null;
26379}
26380}
26381async parseWithJiten(paragraphs, options) {
26382const parsePromise = this.dependencies.jiten.parse(paragraphs);
26383const timeoutMs = remoteParseFallbackTimeoutMs(options);
26384return timeoutMs > 0 ? withTimeout(parsePromise, timeoutMs, () => new Error("Jiten parse timed out.")) : parsePromise;
26385}
26386handleRemoteParseError(source, error, options) {
26387const canFallback = this.canUseParseFallback(options);
26388log$8.warn(remoteParseErrorMessage(source, options, canFallback), error);
26389if (shouldRethrowRemoteParseError(options, canFallback)) throw error;
26390}
26391async parseWithFallbackSource(paragraphs, options, target) {
26392if (!await this.hasLocalTermDictionaries()) {
26393const publicJitenResult = await this.tryParseWithPublicJiten(paragraphs, options, target);
26394if (publicJitenResult) return publicJitenResult;
26395}
26396return Promise.all(paragraphs.map((text2) => this.parseLocalOrSegmentedText(text2, options, target)));
26397}
26398async tryParseWithPublicJiten(paragraphs, options, target) {
26399if (options.allowSegmentedFallback !== true || shouldSkipApiParser(options)) return null;
26400if (typeof navigator !== "undefined" && navigator.onLine === false) return null;
26401const parser = this.dependencies.jitenPublicVocabulary;
26402if (typeof parser?.parse !== "function") return null;
26403try {
26404const parsed = options.publicJitenDetailLimit === void 0 ? await parser.parse(paragraphs) : await parser.parse(paragraphs, { detailLimit: options.publicJitenDetailLimit });
26405if (!parsed.some((tokens) => tokens.length)) return null;
26406return this.withSegmentedFallbackGaps(paragraphs, parsed, options, target);
26407} catch (error) {
26408log$8.warn("Jiten public parse failed; using local or segmented fallback", error);
26409return null;
26410}
26411}
26412canParse() {
26413return true;
26414}
26415isJpdbBackedCard(card) {
26416return (!card.source || card.source === "jpdb") && card.vid > 0;
26417}
26418getCachedCard(vid, sid) {
26419return this.dependencies.jpdb.getCard(vid, sid) ?? this.localCardCache.get(cardCacheKey(vid, sid));
26420}
26421cacheCards(cards) {
26422cards.forEach((card) => {
26423this.rememberLocalCardEvidence(card);
26424});
26425}
26426withCachedCardEvidence(paragraphs, parsed) {
26427let reconciled = parsed;
26428parsed.forEach((tokens, paragraphIndex) => {
26429let nextTokens = tokens;
26430tokens.forEach((token, tokenIndex) => {
26431const surface = paragraphs[paragraphIndex]?.slice(token.start, token.end) ?? "";
26432const card = this.rememberLocalCardEvidence(token.card, surface);
26433if (card === token.card) return;
26434if (nextTokens === tokens) nextTokens = [...tokens];
26435nextTokens[tokenIndex] = {
26436...token,
26437card,
26438pitchClass: getPitchClass(card.pitchAccent, card.reading || card.spelling) || token.pitchClass
26439};
26440});
26441if (nextTokens === tokens) return;
26442if (reconciled === parsed) reconciled = [...parsed];
26443reconciled[paragraphIndex] = nextTokens;
26444});
26445return reconciled;
26446}
26447rememberLocalCardEvidence(card, surface) {
26448if (!cardUsesReaderLocalCache(card)) return card;
26449const evidenceKey = cardEvidenceCacheKey(card);
26450const cached = this.localCardEvidenceCache.get(evidenceKey);
26451const remembered = cached ? cardWithPreservedCachedEvidence(card, cached, surface) : card;
26452this.localCardEvidenceCache.set(evidenceKey, remembered);
26453this.localCardCache.set(cardCacheKey(card.vid, card.sid), remembered);
26454return remembered;
26455}
26456clearLocalCache() {
26457this.localCardCache.clear();
26458this.localCardEvidenceCache.clear();
26459this.localParseCache.clear();
26460this.localPitchCache.clear();
26461this.localTermDictionaryAvailability = void 0;
26462}
26463localCardFromEntry(entry, target = activeLearningTarget()) {
26464const id = -stablePositiveHashId(`${target.language}
26465${entry.dictionary}
26466${entry.expression}
26467${entry.reading}`);
26468const partOfSpeech = (entry.rules ?? "").split(/\s+/).filter(Boolean);
26469const card = {
26470vid: id,
26471sid: id,
26472rid: 0,
26473spelling: entry.expression,
26474reading: entry.reading || entry.expression,
26475language: target.language,
26476frequencyRank: entry.jpdbFrequency ?? null,
26477partOfSpeech,
26478meanings: [{
26479glosses: entry.glossary.map(glossaryToText).filter(Boolean).slice(0, 8),
26480partOfSpeech
26481}],
26482cardState: ["not-in-deck"],
26483provisionalState: true,
26484pitchAccent: [],
26485wordWithReading: null,
26486source: "local"
26487};
26488return this.rememberLocalCardEvidence(card);
26489}
26490fallbackCardFromText(text2, target = activeLearningTarget()) {
26491const card = bareFallbackCardFromText(text2, target.language);
26492return this.rememberLocalCardEvidence(card);
26493}
26494canUseLocalDictionaryFallback() {
26495return this.dependencies.getSettings().localDictionariesEnabled;
26496}
26497canUseParseFallback(options) {
26498return this.canUseLocalDictionaryFallback() || options.allowSegmentedFallback === true;
26499}
26500async parseLocalOrSegmentedText(text2, options, target) {
26501const settings2 = this.dependencies.getSettings();
26502const key = localParseCacheKey(text2, options, settings2, target);
26503const cached = this.localParseCache.get(key);
26504if (cached) {
26505this.localParseCache.delete(key);
26506this.localParseCache.set(key, cached);
26507return cached;
26508}
26509const promise = this.parseLocalOrSegmentedTextUncached(text2, options, target).catch((error) => {
26510if (this.localParseCache.get(key) === promise) this.localParseCache.delete(key);
26511throw error;
26512});
26513this.rememberLocalParseCacheEntry(key, promise);
26514return promise;
26515}
26516async parseLocalOrSegmentedTextUncached(text2, options, target) {
26517try {
26518return await withTimeout(
26519this.resolveLocalOrSegmentedText(text2, options, target),
26520LOCAL_PARSE_TIMEOUT_MS,
26521() => new Error("Local parse timed out.")
26522);
26523} catch (error) {
26524log$8.warn("Local parse timed out; using segmented fallback", { length: text2.length }, error);
26525return this.localParseTimeoutFallback(text2, options, target);
26526}
26527}
26528async resolveLocalOrSegmentedText(text2, options, target) {
26529if (this.canUseLocalDictionaryFallback()) {
26530const tokens = await this.parseLocalDictionaryText(text2, options, target);
26531if (tokens.length) {
26532return options.allowSegmentedFallback === true ? this.fillSegmentedFallbackGaps(text2, tokens, target) : tokens;
26533}
26534}
26535return options.allowSegmentedFallback === true ? this.parseSegmentedText(text2, target) : [];
26536}
26537localParseTimeoutFallback(text2, options, target) {
26538return options.allowSegmentedFallback === true ? this.parseSegmentedText(text2, target) : [];
26539}
26540rememberLocalParseCacheEntry(key, promise) {
26541this.localParseCache.set(key, promise);
26542while (this.localParseCache.size > LOCAL_PARSE_CACHE_LIMIT) {
26543const oldest = this.localParseCache.keys().next().value;
26544if (typeof oldest !== "string") break;
26545this.localParseCache.delete(oldest);
26546}
26547}
26548async parseLocalDictionaryText(text2, options, target) {
26549const { dictionaries, getSettings } = this.dependencies;
26550if (!await this.hasLocalTermDictionaries()) return [];
26551const settings2 = getSettings();
26552const matches = await dictionaries.findTermMatches(text2, LOCAL_MATCH_LIMIT, settings2.dictionaryPreferences, target).catch((error) => {
26553log$8.warn("Local dictionary parse failed", { length: text2.length }, error);
26554return [];
26555});
26556return mapLimited(matches, LOCAL_ENRICHMENT_CONCURRENCY, (match) => this.localTokenFromMatch(text2, match, options, target));
26557}
26558async localTokenFromMatch(text2, match, options, target) {
26559const card = this.localCardFromEntry(match.entry, target);
26560const reading = card.reading && card.reading !== match.surface ? card.reading : "";
26561const pitch = await this.enrichmentGate.run(() => this.localPitchPattern(card, options));
26562if (pitch && !card.pitchAccent.length) card.pitchAccent = [pitch];
26563const rubies = reading ? match.deinflected ? inferredInflectedSurfaceRubies(match.surface, card.spelling, reading).map((ruby) => ({
26564...ruby,
26565start: match.start + ruby.start,
26566end: match.start + ruby.end
26567})) : await this.enrichmentGate.run(() => this.localRubySegments(match.surface, reading, match.start, match.end)) : [];
26568return {
26569card,
26570start: match.start,
26571end: match.end,
26572length: match.end - match.start,
26573rubies,
26574pitchClass: pitch ? getPitchClass([pitch], card.reading) : "",
26575sentence: text2
26576};
26577}
26578async hasLocalTermDictionaries(confirmed = false) {
26579if (!this.canUseLocalDictionaryFallback()) return false;
26580return await this.reportedTermDictionaryAvailability() ?? !confirmed;
26581}
26582reportedTermDictionaryAvailability() {
26583const store = this.dependencies.dictionaries;
26584if (typeof store.hasTermDictionaries !== "function") return Promise.resolve(void 0);
26585this.localTermDictionaryAvailability ??= store.hasTermDictionaries().catch((error) => {
26586this.localTermDictionaryAvailability = void 0;
26587log$8.warn("Local term dictionary availability check failed", { error });
26588return void 0;
26589});
26590return this.localTermDictionaryAvailability;
26591}
26592parseSegmentedText(text2, target) {
26593return target.segment(text2).map((segment) => {
26594const card = this.fallbackCardFromText(segment.text, target);
26595return {
26596card,
26597start: segment.start,
26598end: segment.end,
26599length: segment.end - segment.start,
26600rubies: [],
26601pitchClass: "",
26602sentence: text2
26603};
26604});
26605}
26606async withSegmentedFallbackGaps(paragraphs, parsed, options, target) {
26607const hasExactCardinality = parsed.length === paragraphs.length && paragraphs.every((_, index) => Array.isArray(parsed[index]));
26608if (options.allowSegmentedFallback !== true && hasExactCardinality) return parsed;
26609return Promise.all(paragraphs.map(async (text2, index) => {
26610const tokens = parsed[index] ?? [];
26611if (options.allowSegmentedFallback !== true) return tokens;
26612const withLocal = await this.fillGapsWithLocalDictionaryTokens(text2, tokens, options, target);
26613return this.fillSegmentedFallbackGaps(text2, withLocal, target);
26614}));
26615}
26616async fillGapsWithLocalDictionaryTokens(text2, tokens, options, target) {
26617if (!this.canUseLocalDictionaryFallback()) return tokens;
26618if (!await this.hasLocalTermDictionaries()) return tokens;
26619const localTokens = await this.parseLocalDictionaryText(text2, options, target).catch(() => []);
26620const additions = localTokens.filter((local) => !tokens.some((token) => rangesOverlap$1(local.start, local.end, token.start, token.end)));
26621return additions.length ? [...tokens, ...additions].sort(compareTokensByOffset) : tokens;
26622}
26623fillSegmentedFallbackGaps(text2, tokens, target) {
26624tokens = nonOverlappingTokens([...tokens].sort((first, second) => first.start - second.start || second.end - second.start - (first.end - first.start)), text2);
26625const fallbackTokens = this.parseSegmentedText(text2, target);
26626const extras = this.segmentedFallbackGapTokens(text2, fallbackTokens, tokens, target);
26627return extras.length ? [...tokens, ...extras].sort(compareTokensByOffset) : tokens;
26628}
26629/**
26630* Segmentation is gap coverage, never lexical evidence. A fallback span may
26631* carry wider lookup terms for a later dictionary request, but it must not
26632* replace or resize a span which a provider or local dictionary confirmed.
26633*
26634* ICU boundaries can cross a confirmed token. Subtract those ranges and
26635* mint fallback cards only for the uncovered source slices, preserving the
26636* confirmed token verbatim. This makes dictionary evidence monotonic and
26637* prevents a broad segmented token from repainting a narrower real word.
26638*/
26639segmentedFallbackGapTokens(text2, fallbackTokens, confirmedTokens, target) {
26640const extras = [];
26641for (const fallback of fallbackTokens) {
26642const blockers = confirmedTokens.filter((token) => rangesOverlap$1(fallback.start, fallback.end, token.start, token.end)).sort(compareTokensByOffset);
26643let start = fallback.start;
26644for (const blocker of blockers) {
26645this.pushSegmentedFallbackGap(extras, text2, start, Math.min(blocker.start, fallback.end), target);
26646start = Math.max(start, blocker.end);
26647if (start >= fallback.end) break;
26648}
26649this.pushSegmentedFallbackGap(extras, text2, start, fallback.end, target);
26650}
26651return extras;
26652}
26653pushSegmentedFallbackGap(tokens, text2, start, end, target) {
26654if (end <= start) return;
26655const surface = text2.slice(start, end);
26656if (!target.isLookupableText(surface)) return;
26657const card = this.fallbackCardFromText(surface, target);
26658tokens.push({
26659card,
26660start,
26661end,
26662length: end - start,
26663rubies: [],
26664pitchClass: "",
26665sentence: text2
26666});
26667}
26668async localRubySegments(surface, reading, start, end) {
26669const whole = [{ text: reading, start, end, length: end - start }];
26670const characters = [...new Set(Array.from(surface).filter((character) => LOCAL_RUBY_SPLIT_KANJI_CHAR_RE.test(character)))];
26671if (characters.length < 2) return whole;
26672const readings = new Map();
26673await Promise.all(characters.map(async (character) => {
26674readings.set(character, await this.cachedKanjiReadings(character));
26675}));
26676const segments = splitReadingAcrossKanji(surface, reading, (kanji) => readings.get(kanji) ?? []);
26677if (!segments) return whole;
26678return segments.map((segment) => ({
26679text: segment.text,
26680start: start + segment.start,
26681end: start + segment.end,
26682length: segment.end - segment.start
26683}));
26684}
26685cachedKanjiReadings(character) {
26686const cached = this.kanjiReadingCache.get(character);
26687if (cached) return cached;
26688const settings2 = this.dependencies.getSettings();
26689const lookupKanji = this.dependencies.dictionaries.lookupKanji;
26690const promise = typeof lookupKanji === "function" && settings2.localDictionariesEnabled ? lookupKanji.call(this.dependencies.dictionaries, character, 3, settings2.dictionaryPreferences).then((entries2) => entries2.flatMap((entry) => [...entry.onyomi, ...entry.kunyomi])).catch(() => []) : Promise.resolve([]);
26691this.kanjiReadingCache.set(character, promise);
26692if (this.kanjiReadingCache.size > 400) {
26693const oldest = this.kanjiReadingCache.keys().next().value;
26694if (oldest) this.kanjiReadingCache.delete(oldest);
26695}
26696return promise;
26697}
26698async withLocallySplitKanjiRubies(paragraphs, parsed, target) {
26699if (activeLearningTarget() !== target) return parsed;
26700if (!this.canUseLocalKanjiRubySplits()) return parsed;
26701let nextParsed = parsed;
26702await Promise.all(parsed.map(async (tokens, paragraphIndex) => {
26703let nextTokens = tokens;
26704await Promise.all(tokens.map(async (token, tokenIndex) => {
26705const surface = paragraphs[paragraphIndex]?.slice(token.start, token.end) ?? "";
26706const split = await this.locallySplitTokenRubies(surface, token);
26707if (!split || rubiesEqual(split, token.rubies)) return;
26708if (nextTokens === tokens) nextTokens = [...tokens];
26709nextTokens[tokenIndex] = { ...token, rubies: split };
26710this.syncCardWordWithReadingFromTokenRuby(nextTokens[tokenIndex], surface);
26711}));
26712if (nextTokens === tokens) return;
26713if (nextParsed === parsed) nextParsed = [...parsed];
26714nextParsed[paragraphIndex] = nextTokens;
26715}));
26716return nextParsed;
26717}
26718canUseLocalKanjiRubySplits() {
26719const settings2 = this.dependencies.getSettings();
26720return settings2.localDictionariesEnabled && typeof this.dependencies.dictionaries.lookupKanji === "function";
26721}
26722async locallySplitTokenRubies(surface, token) {
26723if (!surface) return null;
26724if (token.rubies.length) {
26725return await this.locallySplitExistingRubies(surface, token);
26726}
26727const reading = token.card.reading.trim();
26728if (!shouldTryLocalKanjiRubySplit(surface, reading)) return null;
26729const whole = [{ text: reading, start: token.start, end: token.end, length: token.end - token.start }];
26730const split = await this.enrichmentGate.run(() => this.localRubySegments(surface, reading, token.start, token.end));
26731return rubiesEqual(split, whole) ? null : split;
26732}
26733async locallySplitExistingRubies(surface, token) {
26734let changed = false;
26735const split = [];
26736for (const ruby of token.rubies) {
26737const start = ruby.start - token.start;
26738const end = ruby.end - token.start;
26739const base = surface.slice(start, end);
26740if (!shouldTryLocalKanjiRubySplit(base, ruby.text)) {
26741split.push(ruby);
26742continue;
26743}
26744const next = await this.enrichmentGate.run(() => this.localRubySegments(base, ruby.text, ruby.start, ruby.end));
26745changed ||= !rubiesEqual(next, [ruby]);
26746split.push(...next);
26747}
26748return changed ? split : null;
26749}
26750syncCardWordWithReadingFromTokenRuby(token, surface) {
26751if (!token.rubies.length || token.card.spelling !== surface) return;
26752let word = token.card.spelling;
26753for (let index = token.rubies.length - 1; index >= 0; index -= 1) {
26754const { text: text2, end } = token.rubies[index];
26755const insertionOffset = end - token.start;
26756word = `${word.slice(0, insertionOffset)}[${text2}]${word.slice(insertionOffset)}`;
26757}
26758token.card.wordWithReading = word;
26759}
26760async localPitchPattern(card, options) {
26761const settings2 = this.dependencies.getSettings();
26762if (options.includeLocalPitch === false) return "";
26763if (!settings2.showPitchAccent || !settings2.localDictionariesEnabled) return "";
26764const lookupTermMeta = this.dependencies.dictionaries.lookupTermMeta;
26765if (typeof lookupTermMeta !== "function") return "";
26766const key = localPitchCacheKey(card, settings2);
26767const cached = this.localPitchCache.get(key);
26768if (cached) return firstLocalPitchPattern$1(await cached);
26769const promise = localPitchResolutionFromMetaLookup(
26770card.spelling,
26771card.reading,
26772(expression) => lookupTermMeta.call(this.dependencies.dictionaries, expression, 12, settings2.dictionaryPreferences)
26773).catch((error) => {
26774log$8.warn("Local pitch parse failed", { term: card.spelling }, error);
26775return { patterns: [] };
26776});
26777this.rememberLocalPitchCacheEntry(key, promise);
26778return firstLocalPitchPattern$1(await promise);
26779}
26780withNormalizedMetricTokens(text2, tokens) {
26781if (!text2.includes("回視聴")) return tokens;
26782const replacements = [];
26783const replacementRanges = [];
26784for (const match of text2.matchAll(YOUTUBE_VIEW_METRIC_RE)) {
26785const start = match.index ?? -1;
26786if (start < 0) continue;
26787const end = start + match[0].length;
26788const overlapping = tokens.filter((token) => rangesOverlap$1(start, end, token.start, token.end));
26789const alreadyCovered = overlapping.some((token) => token.start >= start + 1 && token.end <= end && token.card.spelling === "視聴");
26790const hasBrokenMetricToken = overlapping.some((token) => text2.slice(token.start, token.end) === "回視");
26791if (alreadyCovered && !hasBrokenMetricToken) continue;
26792if (!hasBrokenMetricToken && overlapping.length > 0) continue;
26793replacementRanges.push({ start, end });
26794replacements.push(
26795this.metricToken(text2, "回", "かい", start, start + 1),
26796this.metricToken(text2, "視聴", "しちょう", start + 1, end)
26797);
26798}
26799if (!replacements.length) return tokens;
26800return [
26801...tokens.filter((token) => !replacementRanges.some((range) => rangesOverlap$1(range.start, range.end, token.start, token.end))),
26802...replacements
26803].sort(compareTokensByOffset);
26804}
26805withNormalizedMetricParseResult(paragraphs, parsed) {
26806let normalized = parsed;
26807for (const [index, tokens] of parsed.entries()) {
26808const nextTokens = this.withNormalizedMetricTokens(paragraphs[index] ?? "", tokens);
26809if (nextTokens === tokens) continue;
26810if (normalized === parsed) normalized = [...parsed];
26811normalized[index] = nextTokens;
26812}
26813return normalized;
26814}
26815metricToken(sentence, surface, reading, start, end) {
26816const card = this.fallbackCardFromText(surface);
26817card.reading = reading;
26818return {
26819card,
26820start,
26821end,
26822length: end - start,
26823rubies: [{ text: reading, start, end, length: end - start }],
26824pitchClass: "",
26825sentence
26826};
26827}
26828rememberLocalPitchCacheEntry(key, promise) {
26829this.localPitchCache.set(key, promise);
26830while (this.localPitchCache.size > LOCAL_PITCH_CACHE_LIMIT) {
26831const oldest = this.localPitchCache.keys().next().value;
26832if (typeof oldest !== "string") break;
26833this.localPitchCache.delete(oldest);
26834}
26835}
26836}
26837class BatchedParserSpanLookup {
26838constructor(load) {
26839this.load = load;
26840}
26841pending = [];
26842flushScheduled = false;
26843lookup(requests) {
26844return new Promise((resolve, reject) => {
26845this.pending.push({ requests, resolve, reject });
26846if (this.flushScheduled) return;
26847this.flushScheduled = true;
26848queueMicrotask(() => {
26849void this.flush();
26850});
26851});
26852}
26853async flush() {
26854this.flushScheduled = false;
26855const pending = this.pending;
26856this.pending = [];
26857try {
26858const matches = await this.load(pending.flatMap((item) => [...item.requests]));
26859for (const item of pending) {
26860const result = new Map();
26861for (const request of item.requests) {
26862const match = matches.get(request);
26863if (match) result.set(request, match);
26864}
26865item.resolve(result);
26866}
26867} catch (error) {
26868pending.forEach((item) => item.reject(error));
26869}
26870}
26871}
26872function authoritativeLookupTerms(requests, target) {
26873const terms = new Map();
26874for (const request of requests) {
26875const term = request.lookupCandidate.term.trim();
26876const key = target.normalizeText(term);
26877if (key && !terms.has(key)) terms.set(key, term);
26878}
26879return [...terms.values()];
26880}
26881function confirmParserSpanRequests(requests, cardsByTerm, target, confirmed) {
26882for (const request of requests) {
26883if (confirmed.has(request)) continue;
26884if (!target.isLookupableText(request.surface)) continue;
26885const term = target.normalizeText(request.lookupCandidate.term);
26886if (!term) continue;
26887const card = cardsByTerm.get(term)?.find((candidate) => cardMatchesLookupTerm(candidate, term, target) && parserSpanRulesMatch(candidate, request, target));
26888if (card) confirmed.set(request, { card });
26889}
26890}
26891function decorationAlignedSpanConfirmation(text2, decorations, target, options) {
26892const bySpan = new Map();
26893for (const token of decorations) {
26894if (token.card.source === "fallback") continue;
26895if (token.card.source === "local" && !options.trustLocalDecorations) continue;
26896bySpan.set(`${token.start}:${token.end}`, token);
26897}
26898if (!bySpan.size) return () => null;
26899return (candidate) => {
26900const token = bySpan.get(`${candidate.start}:${candidate.end}`);
26901if (!token) return null;
26902if (endsInsideKanaRun(text2, candidate.end)) return null;
26903const term = target.normalizeText(candidate.lookupCandidate.term);
26904return cardMatchesLookupTerm(token.card, term, target) ? { card: token.card } : null;
26905};
26906}
26907function rejectUntrustworthySpanShapes(text2, segments, target) {
26908const segmentsAreWordCovers = target.language === "ja" || target.lookupStartsAtSegmentBoundary;
26909return (candidate, match, context) => {
26910const term = target.normalizeText(candidate.lookupCandidate.term);
26911const identity = term === target.normalizeText(candidate.surface);
26912if (identity && match.card.source !== "local" && !match.card.pitchAccent?.length && (segmentsContainInternalParticle(text2, segments, candidate.start, candidate.end) || hasInternalKanaToKanjiTransition(text2, candidate.start, candidate.end))) {
26913return false;
26914}
26915if (segmentsAreWordCovers && endsStrictlyInsideSegment(segments, candidate.end) && !context.hasConfirmedS
26915panAt(candidate.end) && !KANA_CASE_PARTICLE_CONTINUATIONS.some((particle) => text2.startsWith(particle, candidate.end))) {
26916return false;
26917}
26918return true;
26919};
26920}
26921function endsStrictlyInsideSegment(segments, end) {
26922return segments.some((segment) => segment.start < end && end < segment.end);
26923}
26924function segmentsContainInternalParticle(text2, segments, start, end) {
26925return segments.some((segment) => segment.start >= start && segment.end <= end && !(segment.start === start && segment.end === end) && SPAN_BOUNDARY_PARTICLES.includes(text2.slice(segment.start, segment.end)));
26926}
26927const KANA_RUN_CHARACTER_RE = new RegExp(`^[${KANA}${PROLONGED_SOUND_MARK}]$`, "u");
26928const KANJI_CHARACTER_RE = new RegExp(`^(?:${KANJI_PATTERN}|[${ITERATION_MARK}])$`, "u");
26929function hasInternalKanaToKanjiTransition(text2, start, end) {
26930for (let index = start + 1; index < end; index++) {
26931if (KANA_RUN_CHARACTER_RE.test(text2[index - 1]) && KANJI_CHARACTER_RE.test(text2[index])) return true;
26932}
26933return false;
26934}
26935const KANA_CASE_PARTICLE_CONTINUATIONS = [
26936"から",
26937"まで",
26938"より",
26939"だけ",
26940"しか",
26941"など",
26942"は",
26943"が",
26944"を",
26945"に",
26946"へ",
26947"と",
26948"で",
26949"の",
26950"や"
26951];
26952const KANA_FINAL_PARTICLE_CONTINUATIONS = ["かしら", "かな", "っけ", "ね", "な", "か", "よ", "わ", "ぞ", "ぜ", "さ"];
26953const SPAN_BOUNDARY_PARTICLES = [...KANA_CASE_PARTICLE_CONTINUATIONS, "な"];
26954let standaloneGrammarParticleSet;
26955function standaloneGrammarParticles() {
26956standaloneGrammarParticleSet ??= new Set([
26957...SPAN_BOUNDARY_PARTICLES,
26958...KANA_FINAL_PARTICLE_CONTINUATIONS
26959]);
26960return standaloneGrammarParticleSet;
26961}
26962function pickAuthoritativeTokenAt(tokens, text2, offset, range = { start: 0, end: text2.length }) {
26963const start = Math.max(0, Math.min(range.start, text2.length));
26964const end = Math.max(start, Math.min(range.end, text2.length));
26965if (offset < start || offset >= end) return void 0;
26966const token = tokens.find((item) => item.start >= start && item.end <= end && item.start <= offset && offset < item.end);
26967if (!token) return void 0;
26968if (token.card.source !== "fallback") return token;
26969const surface = text2.slice(token.start, token.end);
26970if (standaloneGrammarParticles().has(surface)) return void 0;
26971if (surface.length === 1 && KANJI_CHARACTER_RE.test(surface)) return void 0;
26972return token;
26973}
26974function endsInsideKanaRun(text2, end) {
26975if (end <= 0 || end >= text2.length) return false;
26976if (!KANA_RUN_CHARACTER_RE.test(text2[end - 1]) || !KANA_RUN_CHARACTER_RE.test(text2[end])) return false;
26977if (KANA_CASE_PARTICLE_CONTINUATIONS.some((particle) => text2.startsWith(particle, end))) return false;
26978let cursor = end;
26979let consumedFinal = true;
26980while (consumedFinal && cursor < text2.length) {
26981consumedFinal = false;
26982for (const particle of KANA_FINAL_PARTICLE_CONTINUATIONS) {
26983if (!text2.startsWith(particle, cursor)) continue;
26984cursor += particle.length;
26985consumedFinal = true;
26986break;
26987}
26988}
26989if (cursor > end && (cursor >= text2.length || !KANA_RUN_CHARACTER_RE.test(text2[cursor]))) return false;
26990return true;
26991}
26992function authoritativeCardsFromParsedTerms(terms, parsed, target) {
26993const result = new Map();
26994terms.forEach((term, index) => {
26995const key = target.normalizeText(term);
26996const cards = (parsed[index] ?? []).map((token) => token.card).filter((card) => card.source !== "fallback" && cardMatchesLookupTerm(card, key, target));
26997if (cards.length) result.set(key, cards);
26998});
26999return result;
27000}
27001function cardMatchesLookupTerm(card, term, target) {
27002return [card.spelling, card.reading].some((value) => target.normalizeText(value) === term);
27003}
27004function parserSpanRulesMatch(card, request, target) {
27005if (!request.lookupCandidate.rules.length) return true;
27006const rules = [
27007...card.partOfSpeech,
27008...card.meanings.flatMap((meaning) => meaning.partOfSpeech)
27009].filter(Boolean).join(" ");
27010return target.matchesLookupCandidateRules(rules, request.lookupCandidate.rules);
27011}
27012function authoritativeTokenFromSpan(text2, span, decorations) {
27013if (span.kind === "fallback") {
27014return {
27015card: span.fallback,
27016start: span.start,
27017end: span.end,
27018length: span.end - span.start,
27019rubies: [],
27020pitchClass: "",
27021sentence: text2
27022};
27023}
27024const card = span.match.card;
27025const decoration = decorations.find((token) => cardsShareEvidenceIdentity(token.card, card));
27026if (decoration?.card === card && decoration.start === span.start && decoration.end === span.end && decoration.length === span.end - span.start && (decoration.sentence ?? text2) === text2 && text2.slice(decoration.start, decoration.end) === span.surface) {
27027return decoration;
27028}
27029const rubies = decoration ? authoritativeDecorationRubies(text2, span, decoration) : [];
27030return {
27031...decoration ?? {},
27032card,
27033start: span.start,
27034end: span.end,
27035length: span.end - span.start,
27036rubies: rubies.length ? rubies : authoritativeCardRubies(span, card),
27037pitchClass: getPitchClass(card.pitchAccent, card.reading || card.spelling),
27038sentence: text2
27039};
27040}
27041function authoritativeDecorationRubies(text2, span, decoration) {
27042const decorationSurface = text2.slice(decoration.start, decoration.end);
27043if (decorationSurface !== span.surface) return [];
27044return decoration.rubies.flatMap((ruby) => {
27045const relativeStart = ruby.start - decoration.start;
27046const relativeEnd = ruby.end - decoration.start;
27047if (relativeStart < 0 || relativeEnd <= relativeStart || relativeEnd > span.end - span.start) return [];
27048const start = span.start + relativeStart;
27049const end = span.start + relativeEnd;
27050return [{ ...ruby, start, end, length: end - start }];
27051});
27052}
27053function authoritativeCardRubies(span, card) {
27054const reading = card.reading.trim();
27055if (!reading || reading === span.surface) return [];
27056return inferredInflectedSurfaceRubies(span.surface, card.spelling, reading).map((ruby) => ({
27057...ruby,
27058start: span.start + ruby.start,
27059end: span.start + ruby.end
27060}));
27061}
27062function firstLocalPitchPattern$1(resolution) {
27063return resolution.patterns[0] ?? "";
27064}
27065function remoteParseFallbackTimeoutMs(options) {
27066return options.allowApiTimeoutFallback ?? options.allowJpdbTimeoutFallback ? options.apiTimeoutMs ?? options.jpdbTimeoutMs ?? JPDB_PARSE_FALLBACK_TIMEOUT_MS : 0;
27067}
27068function shouldTryLocalKanjiRubySplit(base, reading) {
27069return Array.from(base).length >= 2 && LOCAL_RUBY_SPLIT_BASE_RE.test(base) && LOCAL_RUBY_SPLIT_KANJI_RE.test(base) && READING_KANA_ONLY_RE.test(reading.trim());
27070}
27071function rubiesEqual(first, second) {
27072return first.length === second.length && first.every((ruby, index) => {
27073const other = second[index];
27074return Boolean(other) && ruby.text === other.text && ruby.start === other.start && ruby.end === other.end && ruby.length === other.length;
27075});
27076}
27077function shouldUseJitenParser(settings2, options, jiten) {
27078return Boolean(hasJitenApiCredential(settings2) && jiten && !shouldSkipApiParser(options));
27079}
27080function shouldPreferJitenParser(settings2, options, jiten) {
27081return shouldUseJitenParser(settings2, options, jiten) && options.requireJpdb !== true && chosenWordGradingService(settings2) !== "jpdb";
27082}
27083function shouldSkipApiParser(options) {
27084return Boolean(options.skipApi ?? options.skipJpdb);
27085}
27086function remoteParseErrorMessage(source, options, canFallback) {
27087if (shouldRequireRemoteParse(options)) return `${source}-first parse failed without local fallback`;
27088return canFallback ? `${source} parse failed; using local or segmented fallback` : `${source} parse failed without fallback`;
27089}
27090function shouldRethrowRemoteParseError(options, canFallback) {
27091return shouldRequireRemoteParse(options) || !canFallback;
27092}
27093function shouldRequireRemoteParse(options) {
27094return options.requireApi === true || options.requireJpdb === true;
27095}
27096function localPitchCacheKey(card, settings2) {
27097return JSON.stringify({
27098spelling: card.spelling,
27099reading: card.reading,
27100dictionaries: settings2.dictionaryPreferences.map((preference) => ({
27101name: preference.name,
27102enabled: preference.enabled,
27103priority: preference.priority
27104}))
27105});
27106}
27107function localParseCacheKey(text2, options, settings2, target) {
27108const localDictionariesEnabled = settings2.localDictionariesEnabled;
27109return JSON.stringify({
27110text: text2,
27111target: target.id,
27112language: target.language,
27113localDictionariesEnabled,
27114allowSegmentedFallback: options.allowSegmentedFallback === true,
27115includeLocalPitch: localDictionariesEnabled && settings2.showPitchAccent && options.includeLocalPitch !== false,
27116dictionaries: localDictionariesEnabled ? settings2.dictionaryPreferences.map((preference) => ({
27117name: preference.name,
27118enabled: preference.enabled,
27119priority: preference.priority
27120})) : []
27121});
27122}
27123function rangesOverlap$1(start, end, otherStart, otherEnd) {
27124return start < otherEnd && otherStart < end;
27125}
27126function compareTokensByOffset(a, b) {
27127return a.start - b.start || b.length - a.length;
27128}
27129function cardUsesReaderLocalCache(card) {
27130return Boolean(card.source && card.source !== "jpdb" || card.vid <= 0 || card.sid <= 0);
27131}
27132function cardWithPreservedCachedEvidence(incoming, cached, surface) {
27133if (!cardsShareEvidenceIdentity(incoming, cached)) return incoming;
27134const evidence = preservedCachedLexicalEvidence(incoming, cached, surface);
27135const preserveCachedState = evidence.preserve && incoming.provisionalState === true && cached.provisionalState !== true;
27136if (!evidence.stronger && !preserveCachedState) return incoming;
27137const preserveCachedSpelling = evidence.suppliesReading || evidence.stronger && incoming.spelling.trim() !== cached.spelling.trim();
27138return {
27139...incoming,
27140spelling: preserveCachedSpelling ? cached.spelling : incoming.spelling,
27141reading: evidence.suppliesReading ? cached.reading : incoming.reading,
27142pitchAccent: evidence.pitchAccent,
27143pitchComponents: evidence.pitchComponents,
27144wordWithReading: evidence.wordWithReading,
27145meanings: incoming.meanings.length || !evidence.preserve ? incoming.meanings : cached.meanings,
27146partOfSpeech: incoming.partOfSpeech.length || !evidence.preserve ? incoming.partOfSpeech : cached.partOfSpeech,
27147frequencyRank: evidence.preserve ? incoming.frequencyRank ?? cached.frequencyRank : incoming.frequencyRank,
27148...preserveCachedState ? {
27149cardState: cached.cardState,
27150provisionalState: cached.provisionalState,
27151reviewSource: cached.reviewSource,
27152dueAt: cached.dueAt,
27153lastReviewAt: cached.lastReviewAt,
27154deckNames: cached.deckNames,
27155sourceDeckName: cached.sourceDeckName
27156} : {}
27157};
27158}
27159function preservedCachedLexicalEvidence(incoming, cached, surface) {
27160const incomingReading = incoming.reading.trim();
27161const cachedReading = cached.reading.trim();
27162const readingsAreCompatible = !incomingReading || !cachedReading || incomingReading === cachedReading;
27163const surfaceAcceptsCachedLexicalEvidence = cachedEvidenceMatchesSurface(incoming, cached, surface);
27164const suppliesReading = !incomingReading && Boolean(cachedReading) && surfaceAcceptsCachedLexicalEvidence;
27165const preserve = readingsAreCompatible && surfaceAcceptsCachedLexicalEvidence;
27166const pitchAccent = preserve ? [...incoming.pitchAccent, ...cached.pitchAccent.filter((pattern) => !incoming.pitchAccent.includes(pattern))] : incoming.pitchAccent;
27167const pitchComponents = preserve ? incoming.pitchComponents?.length ? incoming.pitchComponents : cached.pitchComponents : incoming.pitchComponents;
27168const wordWithReading = preserve ? incoming.wordWithReading || cached.wordWithReading : incoming.wordWithReading;
27169const stronger = preserve && (suppliesReading || pitchAccent.length !== incoming.pitchAccent.length || Boolean(pitchComponents?.length && !incoming.pitchComponents?.length) || Boolean(wordWithReading && !incoming.wordWithReading) || !incoming.meanings.length && cached.meanings.length > 0 || !incoming.partOfSpeech.length && cached.partOfSpeech.length > 0 || incoming.frequencyRank === null && cached.frequencyRank !== null);
27170return { preserve, suppliesReading, pitchAccent, pitchComponents, wordWithReading, stronger };
27171}
27172function cardsShareEvidenceIdentity(first, second) {
27173return normalizedCardSource(first) === normalizedCardSource(second) && normalizedCardLanguage(first) === normalizedCardLanguage(second) && first.vid === second.vid && first.sid === second.sid;
27174}
27175function cachedEvidenceMatchesSurface(incoming, cached, surface) {
27176const visibleSurface = surface?.trim() ?? "";
27177const cachedSpelling = cached.spelling.trim();
27178if (!visibleSurface) return incoming.spelling.trim() === cachedSpelling;
27179if (visibleSurface === cachedSpelling) return true;
27180if (!cached.reading.trim()) return false;
27181return inferredInflectedSurfaceRubies(visibleSurface, cachedSpelling, cached.reading).length > 0;
27182}
27183function normalizedCardSource(card) {
27184return card.source ?? "jpdb";
27185}
27186function normalizedCardLanguage(card) {
27187return card.language ?? "ja";
27188}
27189function cardEvidenceCacheKey(card) {
27190return `${normalizedCardSource(card)}:${normalizedCardLanguage(card)}:${card.vid}:${card.sid}`;
27191}
27192function cardCacheKey(vid, sid) {
27193return `${vid}:${sid}`;
27194}
27195function isCurrentLearningTarget(target, generation) {
27196return activeLearningTarget() === target && activeLearningTargetGeneration() === generation;
27197}
27198function emptyParseResult(paragraphs) {
27199return paragraphs.map(() => []);
27200}
27201function withTimeout(promise, timeoutMs, errorFactory) {
27202let timeoutId = 0;
27203const timeout = new Promise((_resolve, reject) => {
27204timeoutId = window.setTimeout(() => reject(errorFactory()), timeoutMs);
27205});
27206return Promise.race([
27207promise,
27208timeout
27209]).finally(() => window.clearTimeout(timeoutId));
27210}
27211const log$7 = Logger.scope("CardRenderData");
27212const CARD_RENDER_DATA_CACHE_TTL_MS = 3e4;
27213const CARD_RENDER_DATA_CACHE_LIMIT = 120;
27214const CARD_RENDER_LOCAL_TIMEOUT_MS = 2500;
27215const CARD_RENDER_JPDB_DETAIL_TIMEOUT_MS = 4e3;
27216const CARD_RENDER_JITEN_DETAIL_TIMEOUT_MS = 4e3;
27217const CARD_RENDER_FREQUENCY_TIMEOUT_MS = 4e3;
27218const CARD_RENDER_BUNPRO_DETAIL_TIMEOUT_MS = 4e3;
27219const CARD_RENDER_ANKI_TIMEOUT_MS = 4e3;
27220const CARD_RENDER_DECK_TIMEOUT_MS = 1500;
27221const CARD_RENDER_DECK_POOL_TIMEOUT_MS = 4e3;
27222const CARD_RENDER_PITCH_TIMEOUT_MS = 6500;
27223const CARD_RENDER_LOCAL_PITCH_GRACE_MS = 120;
27224const CARD_RENDER_SHARED_DECK_CACHE_TTL_MS = 5 * 60 * 1e3;
27225const CARD_RENDER_COMPONENT_PITCH_TIMEOUT_MS = 4e3;
27226const CARD_RENDER_META_LOOKUP_LIMIT = 12;
27227function cardNeedsCanonicalReading(card) {
27228const spelling = card.spelling.normalize("NFKC").trim();
27229const reading = card.reading.normalize("NFKC").trim();
27230return !reading || reading === spelling;
27231}
27232function loadingCardRenderData(localEntries, ankiLookup, metaEntries = [], jpdbVocabularyInfo = null, jitenVocabularyInfo = null, bunproDefinitionInfo = null, frequencyRanks = {}, bunproDefinitionStatus = { state: "loading" }) {
27233return {
27234localEntries,
27235kanjiEntries: [],
27236metaEntries,
27237ankiLookup,
27238jpdbDecks: [],
27239jitenDecks: [],
27240ankiDecks: [],
27241jpdbVocabularyInfo,
27242jitenVocabularyInfo,
27243frequencyRanks,
27244bunproDefinitionInfo,
27245bunproDefinitionStatus,
27246expressionComponents: [],
27247loading: true
27248};
27249}
27250class CardRenderDataLoader {
27251constructor(dependencies) {
27252this.dependencies = dependencies;
27253}
27254cache = new Map();
27255definitionSourceCache = new Map();
27256jpdbDecksCache;
27257jitenDecksCache;
27258ankiDecksCache;
27259clear() {
27260this.cache.clear();
27261this.definitionSourceCache.clear();
27262this.jpdbDecksCache = void 0;
27263this.jitenDecksCache = void 0;
27264this.ankiDecksCache = void 0;
27265}
27266load(card, options = {}) {
27267const key = this.cacheKey(card, options);
27268const now = Date.now();
27269const cached = this.cache.get(key);
27270if (cached && cached.expiresAt > now) return cached.load;
27271const load = this.fetch(card, options, () => {
27272const entry = this.cache.get(key);
27273if (entry?.load === load) entry.expiresAt = Math.min(entry.expiresAt, Date.now() + 1e3);
27274});
27275void load.all.catch(() => {
27276if (this.cache.get(key)?.load === load) this.cache.delete(key);
27277});
27278this.cache.set(key, { expiresAt: now + CARD_RENDER_DATA_CACHE_TTL_MS, load });
27279pruneExpiringMapEntries(this.cache, CARD_RENDER_DATA_CACHE_LIMIT, now);
27280return load;
27281}
27282loadDefinitionSources(card, options = {}) {
27283const key = this.definitionSourceCacheKey(card, options);
27284const now = Date.now();
27285const cached = this.definitionSourceCache.get(key);
27286if (cached && cached.expiresAt > now) return cached.load;
27287const settings2 = this.settings();
27288const japaneseProviders = usesJapaneseProviders();
27289const localEntriesUncapped = this.loadLocalTermEntriesUncapped(card);
27290const localEntries = this.loadLocalTermEntries(card, localEntriesUncapped);
27291const jpdbVocabularyInfo = japaneseProviders && options.includeJpdbDefinition !== false ? this.loadJpdbVocabularyInfo(card, () => {
27292const entry = this.definitionSourceCache.get(key);
27293if (entry?.load === load) entry.expiresAt = Math.min(entry.expiresAt, Date.now() + 1e3);
27294}) : Promise.resolve(null);
27295const jitenVocabularyInfo = japaneseProviders && options.includeJitenDefinition !== false && settings2.jitenDefinitionsEnabled ? this.loadJitenVocabularyInfo(card, true) : Promise.resolve(null);
27296const bunproDefinitionInfo = japaneseProviders && options.includeBunproDefinition !== false && settings2.bunproDefinitionsEnabled ? this.lookupBunproDataResult(card, true).then((result) => result.info) : Promise.resolve(null);
27297const settled = Promise.allSettled([
27298localEntriesUncapped,
27299jpdbVocabularyInfo,
27300jitenVocabularyInfo,
27301bunproDefinitionInfo
27302]).then(() => void 0);
27303const load = {
27304localEntries,
27305hydrateLocalEntries: () => localEntriesUncapped,
27306jpdbVocabularyInfo,
27307jitenVocabularyInfo,
27308bunproDefinitionInfo,
27309settled
27310};
27311this.definitionSourceCache.set(key, { expiresAt: now + CARD_RENDER_DATA_CACHE_TTL_MS, load });
27312pruneExpiringMapEntries(this.definitionSourceCache, CARD_RENDER_DATA_CACHE_LIMIT, now);
27313return load;
27314}
27315fetch(card, options, onIncomplete) {
27316const settings2 = this.settings();
27317const japaneseProviders = usesJapaneseProviders();
27318const providerEpoch = activeLearningTargetGeneration();
27319const localEntriesUncapped = this.loadLocalTermEntriesUncapped(card);
27320const localEntries = this.loadLocalTermEntries(card, localEntriesUncapped);
27321const localMetaEntries = this.loadLocalMetaEntries(card).then(async (localMeta) => {
27322if (localMeta.completed) {
27323await this.withFallback(card, CARD_RENDER_LOCAL_TIMEOUT_MS, "local pitch accent", this.applyLocalPitchAccent(card, localMeta.entries, providerEpoch), void 0);
27324}
27325return localMeta.entries;
27326});
27327const basePitchAccent = (japaneseProviders ? this.loadPublicPitchAfterLocalPitchGrace(card, localMetaEntries) : localMetaEntries.then(() => [...card.pitchAccent])).then((publicPitch) => {
27328if (!this.isProviderTarget(providerEpoch)) return [];
27329if (!card.pitchAccent.length && publicPitch.length) card.pitchAccent = publicPitch;
27330return publicPitch;
27331});
27332const fastAnkiLookup = this.loadFastAnkiLookup(card);
27333let detailedAnkiLookup;
27334const hydrateAnkiLookup = () => {
27335detailedAnkiLookup ??= this.loadDetailedAnkiLookup(card, fastAnkiLookup);
27336return detailedAnkiLookup;
27337};
27338const jpdbDeckMembership = japaneseProviders ? this.loadJpdbDeckMembership(card) : Promise.resolve(false);
27339const jpdbVocabularyInfo = japaneseProviders ? this.loadJpdbVocabularyInfo(card, onIncomplete) : Promise.resolve(null);
27340const cardRanks = cardFrequencyRanks(card, this.dependencies.isJpdbBackedCard);
27341const seededFrequencyRanks = {};
27342if (japaneseProviders && liveFrequencyEnabled(settings2, "jiten") && cardRanks.jiten) seededFrequencyRanks.jiten = cardRanks.jiten;
27343if (japaneseProviders && liveFrequencyEnabled(settings2, "jpdb") && cardRanks.jpdb) seededFrequencyRanks.jpdb = cardRanks.jpdb;
27344const jitenVocabularyLookup = japaneseProviders ? this.loadJitenVocabularyInfo(card, settings2.jitenDefinitionsEnabled) : Promise.resolve(null);
27345const jitenVocabularyInfo = japaneseProviders && settings2.jitenDefinitionsEnabled ? this.withFallback(card, CARD_RENDER_JITEN_DETAIL_TIMEOUT_MS, "Jiten vocabulary details", jitenVocabularyLookup, null) : Promise.resolve(null);
27346const bunproDefinitionRequested = japaneseProviders && options.includeBunproDefinition !== false && settings2.bunproDefinitionsEnabled;
27347const bunproDataRequested = japaneseProviders && (bunproDefinitionRequested || liveFrequencyEnabled(settings2, "bunpro"));
27348const disabledBunproData = { info: null, status: { state: "disabled", reason: "load-excluded" } };
27349const bunproDataLookup = japaneseProviders ? this.lookupBunproDataResult(card, bunproDataRequested) : Promise.resolve(disabledBunproData);
27350const boundedBunproPitchData = this.withFallback(
27351card,
27352CARD_RENDER_BUNPRO_DETAIL_TIMEOUT_MS,
27353"Bunpro pitch evidence",
27354bunproDataLookup,
27355{ info: null, status: { state: "timeout" } }
27356);
27357const hydratedBunproPitchData = Promise.all([basePitchAccent, bunproDataLookup]).then(([, result]) => {
27358if (this.isProviderTarget(providerEpoch) && settings2.showPitchAccent && cardUsesPitchAccentPronunciation(card)) {
27359applyBunproPitchToCard(card, result.info);
27360}
27361return result;
27362});
27363const bunproDefinitionLookup = bunproDefinitionRequested ? bunproDataLookup : Promise.resolve({
27364info: null,
27365status: {
27366state: "disabled",
27367reason: settings2.bunproDefinitionsEnabled ? "load-excluded" : "definitions-disabled"
27368}
27369});
27370const frequencyRankLoad = japaneseProviders ? this.loadFrequencyRanks(card, jitenVocabularyLookup, seededFrequencyRanks, bunproDataLookup, onIncomplete) : {
27371initial: Promise.resolve(seededFrequencyRanks),
27372hydrated: Promise.resolve(seededFrequencyRanks)
27373};
27374const pitchAccent = Promise.all([basePitchAccent, boundedBunproPitchData]).then(([publicPitch, result]) => {
27375if (!this.isProviderTarget(providerEpoch)) return [];
27376if (!settings2.showPitchAccent || !cardUsesPitchAccentPronunciation(card)) return publicPitch;
27377applyBunproPitchToCard(card, result.info);
27378return [...card.pitchAccent];
27379});
27380const hydratedPitchAccent = hydratedBunproPitchData.then(
27381() => this.isProviderTarget(providerEpoch) ? [...card.pitchAccent] : []
27382);
27383const bunproDefinitionResult = this.withFallback(
27384card,
27385CARD_RENDER_BUNPRO_DETAIL_TIMEOUT_MS,
27386"Bunpro definition",
27387bunproDefinitionLookup,
27388{ info: null, status: { state: "timeout" } }
27389);
27390const bunproDefinitionInfo = bunproDefinitionResult.then((result) => result.info);
27391const bunproDefinitionStatus = bunproDefinitionResult.then((result) => result.status);
27392const expressionComponents = this.withFallback(
27393card,
27394CARD_RENDER_COMPONENT_PITCH_TIMEOUT_MS,
27395"expression components",
27396this.loadExpressionComponents(card, localEntries, jitenVocabularyLookup),
27397[]
27398);
27399const componentPitches = this.withFallback(
27400card,
27401CARD_RENDER_COMPONENT_PITCH_TIMEOUT_MS,
27402"expression component pitch",
27403this.loadExpressionComponentPitches(card, expressionComponents, jitenVocabularyLookup, japaneseProviders),
27404[]
27405);
27406void pitchAccent.catch(() => void 0);
27407void hydratedPitchAccent.catch(() => void 0);
27408void jpdbDeckMembership.catch(() => void 0);
27409void jitenVocabularyInfo.catch(() => void 0);
27410void frequencyRankLoad.initial.catch(() => void 0);
27411void bunproDefinitionInfo.catch(() => void 0);
27412void expressionComponents.catch(() => void 0);
27413void componentPitches.catch(() => void 0);
27414const all = this.loadAll(
27415card,
27416localEntries,
27417localMetaEntries,
27418fastAnkiLookup,
27419jpdbDeckMembership,
27420jpdbVocabularyInfo,
27421jitenVocabularyInfo,
27422frequencyRankLoad.initial,
27423bunproDefinitionInfo,
27424bunproDefinitionStatus,
27425expressionComponents,
27426componentPitches,
27427japaneseProviders,
27428providerEpoch
27429);
27430return {
27431localEntries,
27432localMetaEntries,
27433pitchAccent,
27434hydratePitchAccent: () => hydratedPitchAccent,
27435ankiLookup: fastAnkiLookup,
27436hydrateAnkiLookup,
27437jpdbVocabularyInfo,
27438jitenVocabularyInfo,
27439hydrateJitenVocabularyInfo: () => jitenVocabularyLookup,
27440frequencyRanks: frequencyRankLoad.initial,
27441hydrateFrequencyRanks: () => frequencyRankLoad.hydrated,
27442bunproDefinitionInfo,
27443bunproDefinitionStatus,
27444hydrateLocalEntries: () => localEntriesUncapped,
27445hydrateBunproDefinitionInfo: () => bunproDefinitionLookup.then((result) => result.info),
27446hydrateBunproDefinitionResult: () => bunproDefinitionLookup,
27447all
27448};
27449}
27450isProviderTarget(generation) {
27451return generation === activeLearningTargetGeneration() && usesJapaneseProviders();
27452}
27453withFallback(card, timeoutMs, detail, promise, fallback) {
27454return cardRenderDetailWithFallback(detail, card, promise, fallback, timeoutMs);
27455}
27456loadLocalTermEntriesUncapped(card) {
27457const settings2 = this.settings();
27458if (!settings2.localDictionariesEnabled) return Promise.resolve([]);
27459return this.lookupLocalTermEntries(card, settings2).catch((error) => {
27460log$7.warn("Local term lookup failed", { term: card.spelling }, error);
27461return [];
27462});
27463}
27464loadLocalTermEntries(card, uncapped) {
27465return this.withFallback(card, CARD_RENDER_LOCAL_TIMEOUT_MS, "local term dictionary", uncapped, []);
27466}
27467async lookupLocalTermEntries(card, settings2) {
27468const terms = card.source === "fallback" ? fallbackLookupTermsForCard(card) : [card.spelling];
27469for (const term of terms) {
27470const entries2 = await this.dependencies.dictionaries.lookup(
27471term,
27472term === card.spelling ? card.reading : term,
27473settings2.localDictionaryMaxResults,
27474settings2.dictionaryPreferences
27475);
27476if (entries2.length) return entries2;
27477}
27478return [];
27479}
27480loadLocalKanjiEntries(card) {
27481const settings2 = this.settings();
27482if (!targetUsesCharacterDictionary() || !settings2.localDictionariesEnabled || !settings2.localDictionaryShowKanji || !isLocalKanjiDictionaryCard(card)) return Promise.resolve([]);
27483return this.withFallback(card, CARD_RENDER_LOCAL_TIMEOUT_MS, "local kanji dictionary", this.dependencies.dictionaries.lookupKanji(card.spelling, settings2.localDictionaryMaxResults, settings2.dictionaryPreferences).catch((error) => {
27484log$7.warn("Local kanji lookup failed", { term: card.spelling }, error);
27485return [];
27486}), []);
27487}
27488loadLocalMetaEntries(card) {
27489const settings2 = this.settings();
27490if (!settings2.localDictionariesEnabled) return Promise.resolve({ entries: [], completed: false });
27491const lookup = this.dependencies.dictionaries.lookupTermMeta(card.spelling, CARD_RENDER_META_LOOKUP_LIMIT, settings2.dictionaryPreferences).then((entries2) => ({
27492entries: entries2,
27493completed: true
27494})).catch((error) => {
27495log$7.warn("Local metadata lookup failed", { term: card.spelling }, error);
27496return { entries: [], completed: false };
27497});
27498return cardRenderDetailWithFallback(
27499"local metadata dictionary",
27500card,
27501lookup,
27502{ entries: [], completed: false },
27503CARD_RENDER_LOCAL_TIMEOUT_MS
27504);
27505}
27506loadPublicPitch(card) {
27507const settings2 = this.settings();
27508if (!settings2.showPitchAccent || !cardUsesPitchAccentPronunciation(card) || card.pitchAccent.length) return Promise.resolve([]);
27509return this.withFallback(card, CARD_RENDER_PITCH_TIMEOUT_MS, "JPDB public pitch", this.dependencies.jpdbPublicPitch.lookup(card.spelling, card.reading).catch((error) => {
27510log$7.warn("Public pitch lookup failed", { term: card.spelling }, error);
27511return [];
27512}), []);
27513}
27514async loadPublicPitchAfterLocalPitchGrace(card, localMetaEntries) {
27515await settleBeforeDeadline(localMetaEntries, CARD_RENDER_LOCAL_PITCH_GRACE_MS);
27516return this.loadPublicPitch(card);
27517}
27518loadJpdbVocabularyInfo(card, onIncomplete) {
27519const settings2 = this.settings();
27520if (!settings2.jpdbDefinitionsEnabled) return Promise.resolve(null);
27521const jpdbVid = this.dependencies.isJpdbBackedCard(card) ? card.vid : 0;
27522return this.withFallback(card, CARD_RENDER_JPDB_DETAIL_TIMEOUT_MS, "JPDB vocabulary details", this.dependencies.jpdbVocabulary.lookup(jpdbVid, card.spelling, card.reading).catch((error) => {
27523log$7.warn("JPDB page lookup failed", { term: card.spelling }, error);
27524return null;
27525}), null).then((result) => {
27526if (!result) onIncomplete();
27527return result?.info ?? null;
27528});
27529}
27530loadJitenVocabularyInfo(card, enabled) {
27531if (!enabled || typeof this.dependencies.jiten?.lookupVocabularyInfoFor
27531Card !== "function") return Promise.resolve(null);
27532const providerEpoch = activeLearningTargetGeneration();
27533return this.dependencies.jiten.lookupVocabularyInfoForCard(card).then((info) => {
27534if (!this.isProviderTarget(providerEpoch)) return null;
27535enrichCardFromJitenVocabularyInfo(card, info);
27536return info;
27537}).catch((error) => {
27538log$7.warn("Jiten vocabulary lookup failed", { term: card.spelling }, error);
27539return null;
27540});
27541}
27542loadFrequencyRanks(card, jitenVocabularyLookup, seeded, bunproDefinitionLookup, onIncomplete) {
27543const settings2 = this.settings();
27544const searchJiten = this.dependencies.jiten?.searchVocabulary?.bind(this.dependencies.jiten);
27545const jiten = liveFrequencyEnabled(settings2, "jiten") && !seeded.jiten ? settings2.jitenDefinitionsEnabled ? jitenVocabularyLookup.then((info) => jitenFrequencyRankForCard(card, info)) : searchJiten ? searchJiten(card.spelling, 10).then((candidates) => exactJitenFrequencyRank(card, candidates)).catch((error) => {
27546log$7.warn("Jiten frequency lookup failed", { term: card.spelling }, error);
27547return null;
27548}) : Promise.resolve(null) : Promise.resolve(null);
27549const searchJpdb = this.dependencies.jpdbVocabulary.search?.bind(this.dependencies.jpdbVocabulary);
27550const jpdbIdentityReady = cardNeedsCanonicalReading(card) ? jitenVocabularyLookup.then(() => card, () => card) : Promise.resolve(card);
27551const jpdb = liveFrequencyEnabled(settings2, "jpdb") && !seeded.jpdb && searchJpdb ? Promise.all([searchJpdb(card.spelling, 10), jpdbIdentityReady]).then(([result]) => exactJpdbFrequencyRank(card, result.cards)).catch((error) => {
27552onIncomplete();
27553log$7.warn("JPDB frequency lookup failed", { term: card.spelling }, error);
27554return null;
27555}) : Promise.resolve(null);
27556const bunpro = liveFrequencyEnabled(settings2, "bunpro") ? bunproDefinitionLookup.then((result) => bunproFrequencyRank(card, result.info)).catch(() => null) : Promise.resolve(null);
27557const combine = ([jitenRank, jpdbRank, bunproRank]) => withFrequencyRank(withFrequencyRank(withFrequencyRank(seeded, jitenRank), jpdbRank), bunproRank);
27558return {
27559initial: Promise.all([
27560this.withFallback(card, CARD_RENDER_FREQUENCY_TIMEOUT_MS, "Jiten frequency rank", jiten, null),
27561this.withFallback(card, CARD_RENDER_FREQUENCY_TIMEOUT_MS, "JPDB frequency rank", jpdb, null),
27562this.withFallback(card, CARD_RENDER_FREQUENCY_TIMEOUT_MS, "Bunpro frequency rank", bunpro, null)
27563]).then(combine),
27564hydrated: Promise.all([jiten, jpdb, bunpro]).then(combine)
27565};
27566}
27567lookupBunproDataResult(card, included) {
27568if (!included) return Promise.resolve({ info: null, status: { state: "disabled", reason: "load-excluded" } });
27569if (!this.dependencies.bunpro) return Promise.resolve({ info: null, status: { state: "client-unavailable" } });
27570const lookupBunproDefinitionResult = yomuBunproCompanion()?.lookupBunproDefinitionResult;
27571if (!lookupBunproDefinitionResult) return Promise.resolve({ info: null, status: { state: "client-unavailable" } });
27572const startedAt = performance.now();
27573log$7.debug("Bunpro definition lookup started", { term: card.spelling });
27574return lookupBunproDefinitionResult(this.dependencies.bunpro, card).then((result) => {
27575const resolved = {
27576info: result.info,
27577status: result.state === "success" ? { state: "success" } : { state: "no-match", reason: result.reason }
27578};
27579log$7.debug("Bunpro definition lookup completed", {
27580term: card.spelling,
27581state: resolved.status.state,
27582reason: resolved.status.state === "no-match" ? resolved.status.reason : void 0,
27583durationMs: Math.round(performance.now() - startedAt)
27584});
27585return resolved;
27586}).catch((error) => {
27587log$7.warn("Bunpro definition lookup failed", { term: card.spelling }, error);
27588return { info: null, status: { state: "error" } };
27589});
27590}
27591loadFastAnkiLookup(card) {
27592if (!shouldLookupAnkiStatus(this.settings())) return Promise.resolve(emptyAnkiLookupResult());
27593const fallback = sourceCardAnkiLookupOrEmpty(card);
27594if (typeof this.dependencies.anki.findCachedStatusBatch !== "function") return Promise.resolve(fallback);
27595return this.dependencies.anki.findCachedStatusBatch([card]).then(([lookup]) => lookup ?? fallback).catch((error) => {
27596log$7.warn("Cached Anki status failed", { term: card.spelling }, error);
27597return fallback;
27598});
27599}
27600loadDetailedAnkiLookup(card, fastLookup) {
27601if (!shouldLookupAnkiStatus(this.settings())) return fastLookup;
27602return fastLookup.then((fallback) => this.withFallback(card, CARD_RENDER_ANKI_TIMEOUT_MS, "Anki existing cards", this.loadAnkiLookupWhenAvailable(card, fallback).catch((error) => {
27603log$7.warn("Anki lookup failed", { term: card.spelling }, error);
27604return ankiLookupWithUnavailableDetails(fallback);
27605}), ankiLookupWithUnavailableDetails(fallback)));
27606}
27607async loadAnkiLookupWhenAvailable(card, fallback) {
27608const lookup = await this.dependencies.anki.findExistingCards(card);
27609const resolved = lookup.primary || lookup.trusted !== false ? lookup : fallback;
27610return ankiLookupWithUnavailableDetails(resolved);
27611}
27612loadJpdbDecks(card) {
27613const settings2 = this.settings();
27614if (!settings2.jpdbMiningEnabled || !hasJpdbApiCredential(settings2) || !this.dependencies.isJpdbBackedCard(card)) return Promise.resolve([]);
27615return this.withFallback(card, CARD_RENDER_DECK_TIMEOUT_MS, "JPDB deck list", this.cachedJpdbDecks(settings2).catch((error) => {
27616log$7.warn("JPDB deck list failed", { term: card.spelling }, error);
27617return [];
27618}), []);
27619}
27620loadAnkiDecks(card) {
27621if (!this.settings().ankiEnabled) return Promise.resolve([]);
27622return this.withFallback(card, CARD_RENDER_DECK_TIMEOUT_MS, "Anki deck list", this.cachedAnkiDecks(this.settings()).catch((error) => {
27623log$7.warn("Anki deck list failed", { term: card.spelling }, error);
27624return [];
27625}), []);
27626}
27627loadJitenDecks(card) {
27628const settings2 = this.settings();
27629if (!settings2.jpdbMiningEnabled || !isJitenBackedCard(card) || !hasJitenApiCredential(settings2)) return Promise.resolve([]);
27630return this.withFallback(card, CARD_RENDER_DECK_TIMEOUT_MS, "Jiten deck list", this.cachedJitenDecks(settings2).catch((error) => {
27631log$7.warn("Jiten deck list failed", { term: card.spelling }, error);
27632return [];
27633}), []);
27634}
27635loadAnkiFieldTargetPlan(card) {
27636const settings2 = this.settings();
27637if (!settings2.ankiEnabled || !settings2.ankiSectionEnabled) return Promise.resolve(null);
27638if (typeof this.dependencies.anki.noteFieldTargetPlan !== "function") return Promise.resolve(null);
27639return this.withFallback(card, CARD_RENDER_DECK_TIMEOUT_MS, "Anki field target plan", this.dependencies.anki.noteFieldTargetPlan().catch((error) => {
27640log$7.warn("Anki field target plan failed", { term: card.spelling }, error);
27641return null;
27642}), null);
27643}
27644loadJpdbDeckMembership(card) {
27645const settings2 = this.settings();
27646if (!cardNeedsJpdbDeckPoolLookup(card)) return Promise.resolve(false);
27647if (!settings2.jpdbMiningEnabled || !hasJpdbApiCredential(settings2) || !this.dependencies.isJpdbBackedCard(card)) return Promise.resolve(false);
27648const isInUserDeckPool = this.dependencies.jpdb.isInUserDeckPool?.bind(this.dependencies.jpdb);
27649if (typeof isInUserDeckPool !== "function") return Promise.resolve(false);
27650return this.withFallback(card, CARD_RENDER_DECK_POOL_TIMEOUT_MS, "JPDB pooled deck membership", isInUserDeckPool(card).catch((error) => {
27651log$7.warn("JPDB pool lookup failed", { term: card.spelling }, error);
27652return false;
27653}), false);
27654}
27655loadAll(card, localEntries, localMetaEntries, ankiLookup, jpdbDeckMembership, jpdbVocabularyInfo, jitenVocabularyInfo, frequencyRanks, bunproDefinitionInfo, bunproDefinitionStatus, expressionComponents, componentPitches, japaneseProviders, providerEpoch) {
27656const ankiDecks = ankiLookup.then((lookup) => lookup.primary ? [] : this.loadAnkiDecks(card));
27657const ankiFieldTargetPlan = ankiLookup.then((lookup) => lookup.primary ? null : this.loadAnkiFieldTargetPlan(card));
27658return Promise.all([
27659localEntries,
27660this.loadLocalKanjiEntries(card),
27661localMetaEntries,
27662ankiLookup,
27663japaneseProviders ? this.loadJpdbDecks(card) : Promise.resolve([]),
27664japaneseProviders ? this.loadJitenDecks(card) : Promise.resolve([]),
27665ankiDecks,
27666jpdbDeckMembership,
27667jpdbVocabularyInfo,
27668jitenVocabularyInfo,
27669frequencyRanks,
27670bunproDefinitionInfo,
27671bunproDefinitionStatus,
27672expressionComponents.catch(() => []),
27673componentPitches.catch(() => []),
27674ankiFieldTargetPlan
27675]).then(([localEntriesValue, kanjiEntries, metaEntries, ankiLookup2, jpdbDecks, jitenDecks, ankiDecks2, jpdbDeckMembership2, jpdbVocabularyInfo2, jitenVocabularyInfo2, frequencyRanks2, bunproDefinitionInfo2, bunproDefinitionStatus2, expressionComponentsValue, componentPitchesValue, ankiFieldTargetPlanValue]) => {
27676if (this.isProviderTarget(providerEpoch) && jpdbDeckMembership2) applyPooledJpdbDeckState(card);
27677return { localEntries: localEntriesValue, kanjiEntries, metaEntries, ankiLookup: ankiLookup2, jpdbDecks, jitenDecks, ankiDecks: ankiDecks2, jpdbVocabularyInfo: jpdbVocabularyInfo2, jitenVocabularyInfo: jitenVocabularyInfo2, frequencyRanks: frequencyRanks2, bunproDefinitionInfo: bunproDefinitionInfo2, bunproDefinitionStatus: bunproDefinitionStatus2, expressionComponents: expressionComponentsValue, componentPitches: componentPitchesValue, ankiFieldTargetPlan: ankiFieldTargetPlanValue };
27678});
27679}
27680async loadExpressionComponents(card, localEntries, jitenVocabularyInfo) {
27681if (this.settings().localDictionariesEnabled) {
27682const entries2 = await localEntries.catch(() => []);
27683if (entries2.length || looksComposableExpression(card.spelling)) {
27684const segmented = await this.segmentExpressionComponents(card.spelling);
27685if (segmented.length) return segmented;
27686}
27687}
27688const info = await jitenVocabularyInfo.catch(() => null);
27689return jitenExpressionComponents(info);
27690}
27691async loadExpressionComponentPitches(card, expressionComponents, jitenVocabularyInfo, japaneseProviders) {
27692const settings2 = this.settings();
27693if (!japaneseProviders || !settings2.showPitchAccent || !cardUsesPitchAccentPronunciation(card)) return [];
27694const [components, jitenInfo] = await Promise.all([
27695expressionComponents.catch(() => []),
27696jitenVocabularyInfo.catch(() => null)
27697]);
27698if (!components.length) return [];
27699const pitches = [];
27700for (const component of components) {
27701const jitenWord = jitenInfo?.composedOf.find((word) => {
27702const candidate = jitenExpressionComponent(word);
27703return candidate?.text === component.text && candidate.reading === component.reading;
27704});
27705const jitenPitch = jitenWord?.pitchAccents?.map((position) => pitchPatternFromPosition(component.reading, position)).find(Boolean);
27706if (jitenPitch) {
27707pitches.push({ text: component.text, reading: component.reading, pitch: jitenPitch });
27708continue;
27709}
27710const meta = settings2.localDictionariesEnabled ? await this.dependencies.dictionaries.lookupTermMeta(component.text, CARD_RENDER_META_LOOKUP_LIMIT, settings2.dictionaryPreferences).catch(() => []) : [];
27711const localPitch = localPitchPatternFromMeta(component.text, component.reading, meta);
27712if (localPitch) {
27713pitches.push({ text: component.text, reading: component.reading, pitch: localPitch });
27714continue;
27715}
27716const pitch = await this.dependencies.jpdbPublicPitch.lookup(component.text, component.reading).then((patterns) => patterns[0] ?? "").catch(() => "");
27717if (pitch) pitches.push({ text: component.text, reading: component.reading, pitch });
27718}
27719return pitches;
27720}
27721async segmentExpressionComponents(spelling) {
27722const characters = Array.from(spelling.trim());
27723if (characters.length < 3 || characters.length > 24) return [];
27724const settings2 = this.settings();
27725const components = [];
27726let cursor = 0;
27727let misses = 0;
27728let connectiveSkips = 0;
27729while (cursor < characters.length && components.length < 8 && misses <= 6) {
27730const matched = await this.longestExpressionComponentAt(characters, cursor, settings2);
27731if (matched) {
27732components.push(matched);
27733cursor += Array.from(matched.text).length;
27734continue;
27735}
27736if (EXPRESSION_CONNECTIVE_KANA.has(characters[cursor])) connectiveSkips += 1;
27737else misses += 1;
27738cursor += 1;
27739}
27740if (components.length >= 2) return components;
27741if (components.length === 1 && connectiveSkips > 0 && misses === 0) return components;
27742return [];
27743}
27744async longestExpressionComponentAt(characters, cursor, settings2) {
27745const maxLength = Math.min(8, characters.length - cursor);
27746for (let length = maxLength; length >= 1; length--) {
27747if (cursor === 0 && length === characters.length) continue;
27748const candidate = characters.slice(cursor, cursor + length).join("");
27749if (length === 1 && !isKanjiCharacter(candidate)) return null;
27750const entries2 = await this.dependencies.dictionaries.lookup(candidate, candidate, 3, settings2.dictionaryPreferences).catch(() => []);
27751const exact = entries2.find((entry) => entry.expression === candidate || !entry.expression && entry.reading === candidate || entry.reading === candidate);
27752if (exact) return { text: candidate, reading: exact.reading || candidate };
27753}
27754return null;
27755}
27756async applyLocalPitchAccent(card, metaEntries, providerEpoch = activeLearningTargetGeneration()) {
27757const settings2 = this.settings();
27758if (!settings2.showPitchAccent || !cardUsesPitchAccentPronunciation(card) || !settings2.localDictionariesEnabled) return;
27759const resolution = await localPitchResolutionFromMetaLookup(
27760card.spelling,
27761card.reading,
27762(expression) => this.dependencies.dictionaries.lookupTermMeta(expression, CARD_RENDER_META_LOOKUP_LIMIT, settings2.dictionaryPreferences),
27763{ initialEntries: metaEntries }
27764).catch((error) => {
27765log$7.warn("Local pitch lookup failed", { term: card.spelling }, error);
27766return { patterns: [] };
27767});
27768if (!this.isProviderTarget(providerEpoch)) return;
27769const patterns = resolution.patterns;
27770if (!patterns.length) return;
27771if (!card.pitchAccent.length) {
27772card.pitchAccent = patterns;
27773return;
27774}
27775for (const pattern of patterns) {
27776if (!card.pitchAccent.includes(pattern)) card.pitchAccent.push(pattern);
27777}
27778}
27779cachedJpdbDecks(settings2) {
27780const key = `jpdb:${effectiveJpdbApiKey(settings2)}`;
27781const now = Date.now();
27782if (this.jpdbDecksCache?.key === key && this.jpdbDecksCache.expiresAt > now) return this.jpdbDecksCache.promise;
27783const promise = this.dependencies.jpdb.listDecks().catch((error) => {
27784if (this.jpdbDecksCache?.promise === promise) this.jpdbDecksCache = void 0;
27785throw error;
27786});
27787this.jpdbDecksCache = { key, expiresAt: now + CARD_RENDER_SHARED_DECK_CACHE_TTL_MS, promise };
27788return promise;
27789}
27790cachedJitenDecks(settings2) {
27791if (!this.dependencies.jiten) return Promise.resolve([]);
27792const key = `jiten:${effectiveJitenApiKey(settings2)}`;
27793const now = Date.now();
27794if (this.jitenDecksCache?.key === key && this.jitenDecksCache.expiresAt > now) return this.jitenDecksCache.promise;
27795const promise = this.dependencies.jiten.listReaderStudyDecks().then((decks) => decks.map((deck) => ({ id: String(deck.userStudyDeckId), name: deck.name }))).catch((error) => {
27796if (this.jitenDecksCache?.promise === promise) this.jitenDecksCache = void 0;
27797throw error;
27798});
27799this.jitenDecksCache = { key, expiresAt: now + CARD_RENDER_SHARED_DECK_CACHE_TTL_MS, promise };
27800return promise;
27801}
27802cachedAnkiDecks(settings2) {
27803const key = `anki:${settings2.ankiConnectUrl}`;
27804const now = Date.now();
27805if (this.ankiDecksCache?.key === key && this.ankiDecksCache.expiresAt > now) return this.ankiDecksCache.promise;
27806const promise = this.dependencies.anki.deckNames().catch((error) => {
27807if (this.ankiDecksCache?.promise === promise) this.ankiDecksCache = void 0;
27808throw error;
27809});
27810this.ankiDecksCache = { key, expiresAt: now + CARD_RENDER_SHARED_DECK_CACHE_TTL_MS, promise };
27811return promise;
27812}
27813cacheKey(card, options) {
27814const settings2 = this.settings();
27815return JSON.stringify({
27816card: cardKey(card),
27817cardFrequency: {
27818rank: card.frequencyRank,
27819source: card.source,
27820reviewSource: card.reviewSource,
27821jitenWordId: card.jitenWordId,
27822jitenReadingIndex: card.jitenReadingIndex
27823},
27824local: settings2.localDictionariesEnabled,
27825kanji: settings2.localDictionaryShowKanji,
27826max: settings2.localDictionaryMaxResults,
27827pitch: settings2.showPitchAccent,
27828anki: settings2.ankiEnabled,
27829ankiSection: settings2.ankiSectionEnabled,
27830ankiStatus: shouldLookupAnkiStatus(settings2),
27831ankiConnectUrl: settings2.ankiConnectUrl,
27832ankiMobileHandoff: settings2.ankiMobileHandoff,
27833jpdbDefinitions: settings2.jpdbDefinitionsEnabled,
27834jitenDefinitions: settings2.jitenDefinitionsEnabled,
27835liveFrequency: {
27836jiten: liveFrequencyEnabled(settings2, "jiten"),
27837jpdb: liveFrequencyEnabled(settings2, "jpdb"),
27838bunpro: liveFrequencyEnabled(settings2, "bunpro")
27839},
27840bunproDefinitions: settings2.bunproDefinitionsEnabled,
27841includeBunproDefinition: options.includeBunproDefinition !== false,
27842targetGeneration: activeLearningTargetGeneration(),
27843apiMining: settings2.jpdbMiningEnabled || settings2.bunproMiningEnabled,
27844hasApiKey: hasJpdbApiCredential(settings2),
27845hasJitenApiKey: hasJitenApiCredential(settings2),
27846hasBunproToken: hasBunproFrontendCredential(settings2) && !isBunproFrontendCredentialExpired(settings2),
27847dictionaries: settings2.dictionaryPreferences.map((preference) => ({
27848name: preference.name,
27849enabled: preference.enabled,
27850priority: preference.priority
27851}))
27852});
27853}
27854definitionSourceCacheKey(card, options) {
27855const settings2 = this.settings();
27856return JSON.stringify({
27857card: cardKey(card),
27858local: settings2.localDictionariesEnabled,
27859max: settings2.localDictionaryMaxResults,
27860jpdbDefinitions: settings2.jpdbDefinitionsEnabled && options.includeJpdbDefinition !== false,
27861jitenDefinitions: settings2.jitenDefinitionsEnabled && options.includeJitenDefinition !== false,
27862bunproDefinitions: settings2.bunproDefinitionsEnabled && options.includeBunproDefinition !== false,
27863targetGeneration: activeLearningTargetGeneration(),
27864dictionaries: settings2.dictionaryPreferences.map((preference) => ({
27865name: preference.name,
27866enabled: preference.enabled,
27867priority: preference.priority
27868}))
27869});
27870}
27871settings() {
27872return this.dependencies.getSettings();
27873}
27874}
27875function cardRenderDetailWithFallback(detail, card, promise, fallback, timeoutMs) {
27876let timeoutId = 0;
27877return Promise.race([
27878promise,
27879new Promise((resolve) => {
27880timeoutId = window.setTimeout(() => {
27881log$7.debug(`${detail} timed out while rendering card`, { term: card.spelling, timeoutMs });
27882resolve(fallback);
27883}, timeoutMs);
27884})
27885]).finally(() => window.clearTimeout(timeoutId));
27886}
27887function settleBeforeDeadline(promise, timeoutMs) {
27888let timeoutId = 0;
27889return Promise.race([
27890promise.then(() => void 0),
27891new Promise((resolve) => {
27892timeoutId = window.setTimeout(resolve, timeoutMs);
27893})
27894]).finally(() => window.clearTimeout(timeoutId));
27895}
27896function isKanaCharacter(character) {
27897const code = character.codePointAt(0) ?? 0;
27898return code >= 12352 && code <= 12543;
27899}
27900function looksComposableExpression(spelling) {
27901const characters = Array.from(spelling.trim());
27902if (characters.length < 3) return false;
27903const hasConnective = characters.some((character) => EXPRESSION_CONNECTIVE_KANA.has(character));
27904if (characters.length === 3 && !hasConnective) return false;
27905const kanjiCount = characters.filter(isKanjiCharacter).length;
27906return hasConnective || characters.every(isKanjiCharacter) || isKanjiCharacter(characters[0]) && kanjiCount >= 2 && characters.every((character) => isKanjiCharacter(character) || isKanaCharacter(character));
27907}
27908function jitenExpressionComponents(info) {
27909const seen = new Set();
27910return (info?.composedOf ?? []).flatMap((word) => {
27911const component = jitenExpressionComponent(word);
27912if (!component) return [];
27913const key = `${component.text}
27914${component.reading}`;
27915if (seen.has(key)) return [];
27916seen.add(key);
27917return [component];
27918});
27919}
27920function jitenExpressionComponent(word) {
27921const annotated = word.readingFurigana.trim();
27922const text2 = (word.matchSurface.trim() || cleanJitenAnnotatedSpelling(annotated) || cleanJitenAnnotatedSpelling(word.reading)).trim();
27923if (!text2) return null;
27924const reading = (jitenAnnotatedReading(annotated) || word.reading.trim() || text2).trim();
27925return { text: text2, reading };
27926}
27927function cleanJitenAnnotatedSpelling(value) {
27928return value.replace(/([\u4e00-\u9faf\u3005-\u3007]+)\[[^\]]+]/g, "$1");
27929}
27930function jitenAnnotatedReading(value) {
27931const source = value.trim();
27932const reading = source.replace(/([\u4e00-\u9faf\u3005-\u3007]+)\[([^\]]+)]/g, "$2").trim();
27933return reading === source ? "" : reading;
27934}
27935function isLocalKanjiDictionaryCard(card) {
27936const characters = Array.from(card.spelling.trim());
27937return characters.length === 1 && isKanjiCharacter(characters[0] ?? "") && (card.reading === card.spelling || Boolean(card.kanjiKeyword));
27938}
27939function emptyAnkiLookupResult() {
27940return { state: "not-in-deck", notes: [], primary: null };
27941}
27942function applyBunproPitchToCard(card, info) {
27943if (!info || info.kind !== "vocabulary" || !info.pitchAccentStress) return;
27944const reading = card.reading || info.reading;
27945if (!reading) return;
27946for (const pattern of normalizePitchPatternsForReading([info.pitchAccentStress], reading)) {
27947if (!card.pitchAccent.includes(pattern)) card.pitchAccent = [...card.pitchAccent, pattern];
27948}
27949}
27950const SETTINGS_CHANGE_BUS_SLOT = Symbol.for("yomu.private-settings-change-bus.v1");
27951const log$6 = Logger.scope("SettingsChangeBus");
27952const HOSTED_PUBLIC_SETTINGS_KEYS = [
27953"theme",
27954"accentColor",
27955"interfaceLanguage",
27956"furiganaMode",
27957"showFurigana",
27958"hideKnownFurigana",
27959"showPitchAccent",
27960"parserProvider",
27961"dictionaryPreferences"
27962];
27963function publishSettingsChange(detail) {
27964for (const listener of privateSettingsChangeBus().listeners) {
27965try {
27966listener(detail);
27967} catch (error) {
27968log$6.warn("Private settings change listener failed", error);
27969}
27970}
27971publishPublicSettingsProjection(detail);
27972}
27973function subscribeToSettingsChanges(listener, signal) {
27974if (signal?.aborted) return () => void 0;
27975const listeners2 = privateSettingsChangeBus().listeners;
27976listeners2.add(listener);
27977const unsubscribe = () => {
27978listeners2.delete(listener);
27979};
27980signal?.addEventListener("abort", unsubscribe, { once: true });
27981return unsubscribe;
27982}
27983function privateSettingsChangeBus() {
27984const realm = globalThis;
27985const existing = realm[SETTINGS_CHANGE_BUS_SLOT];
27986if (isSettingsChangeBus(existing)) return existing;
27987const bus = { listeners: new Set() };
27988Object.defineProperty(realm, SETTINGS_CHANGE_BUS_SLOT, {
27989configurable: true,
27990enumerable: false,
27991value: bus,
27992writable: false
27993});
27994return bus;
27995}
27996function isSettingsChangeBus(value) {
27997return Boolean(value && typeof value === "object" && value.listeners instanceof Set);
27998}
27999function publishPublicSettingsProjection(detail) {
28000const settings2 = publicSettingsProjection(detail.settings);
28001try {
28002dispatchWindowEvent(createWindowCustomEvent(SETTINGS_CHANGE_EVENT, {
28003preview: detail.preview === true,
28004remote: detail.remote === true,
28005settings: settings2
28006}));
28007} catch {
28008}
28009}
28010function publicSettingsProjection(settings2) {
28011const projection = {};
28012if (!isHostedYomuOrigin()) return projection;
28013for (const key of HOSTED_PUBLIC_SETTINGS_KEYS) {
28014if (Object.hasOwn(settings2, key)) Object.assign(projection, { [key]: settings2[key] });
28015}
28016return projection;
28017}
28018function installDictionarySourceTracking(popover, remember) {
28019if (popover.dataset.jpdbReaderSourceTrackingInstalled === "true") return;
28020popover.dataset.jpdbReaderSourceTrackingInstalled = "true";
28021popover.addEventListener("click", (event) => {
28022const target = event.target instanceof Element ? event.target : null;
28023const summary = target?.closest("summary.jpdb-reader-local-title");
28024const details = summary?.parentElement instanceof HTMLDetailsElement ? summary.parentElement : null;
28025if (!summary || !details || !popover.contains(summary) || !details.dataset.sourceStateKey) return;
28026if (details.dataset.immersionEmpty !== "true") return;
28027event.preventDefault();
28028event.stopPropagation();
28029});
28030popover.addEventListener("toggle", (event) => {
28031const details = event.target instanceof HTMLDetailsElement ? event.target : null;
28032if (!details?.dataset.sourceStateKey) return;
28033if (details.dataset.immersionEmpty === "true") {
28034if (details.open) details.open = false;
28035return;
28036}
28037remember(details);
28038}, true);
28039}
28040const STORAGE_KEY = "jpdb-reader-source-open-state";
28041class DictionarySourceStateController {
28042constructor(dependencies) {
28043this.dependencies = dependencies;
28044}
28045openOverrides = loadOpenOverrides();
28046clear() {
28047this.openOverrides.clear();
28048gmStorageDeleteSync(STORAGE_KEY);
28049}
28050isOpen(sourceStateKey, initiallyExpanded = this.dependencies.getSettings().dictionarySourcesInitiallyExpanded) {
28051return this.openOverrides.get(sourceStateKey) ?? initiallyExpanded;
28052}
28053attributes(sourceStateKey, initiallyExpanded = this.dependencies.getSettings().dictionarySourcesInitiallyExpanded) {
28054const isOpen = this.isOpen(sourceStateKey, initiallyExpanded);
28055return `data-source-state-key="${escapeHtml(sourceStateKey)}" data-source-initial-open="${String(isOpen)}"${isOpen ? " open" : ""}`;
28056}
28057installTracking(popover) {
28058installDictionarySourceTracking(popover, (details) => this.remember(details));
28059}
28060remember(details) {
28061const sourceStateKey = details.dataset.sourceStateKey;
28062if (!sourceStateKey) return;
28063const persistedOpen = this.openOverrides.get(sourceStateKey) ?? details.dataset.sourceInitialOpen === "true";
28064if (persistedOpen === details.open) return;
28065this.openOverrides.set(sourceStateKey, details.open);
28066saveOpenOverrides(this.openOverrides);
28067this.dependencies.onStateChange();
28068}
28069}
28070function loadOpenOverrides() {
28071const stored = gmStorageGetSync(STORAGE_KEY, {});
28072if (!stored || typeof stored !== "object" || Array.isArray(stored)) return new Map();
28073return new Map(Object.entries(stored).filter((entry) => typeof entry[1] === "boolean"));
28074}
28075function saveOpenOverrides(openOverrides) {
28076gmStorageSetSync(STORAGE_KEY, Object.fromEntries(openOverrides));
28077}
28078const DICTIONARY_STYLE_ID = "jpdb-reader-yomitan-dictionary-styles";
28079class DictionaryStyleController {
28080constructor(options) {
28081this.options = options;
28082}
28083styleElement;
28084refreshGeneration = 0;
28085async refresh() {
28086const generation = ++this.refreshGeneration;
28087const result = await this.loadCss();
28088if (generation !== this.refreshGeneration) return;
28089if ("error" in result) this.options.onUnavailable?.(result.error);
28090this.apply(result.css);
28091}
28092remove() {
28093this.refreshGeneration += 1;
28094this.styleElement?.remove();
28095this.styleElement = void 0;
28096}
28097async loadCss() {
28098try {
28099return { css: await this.options.loadCss() };
28100} catch (error) {
28101return { css: "", error };
28102}
28103}
28104apply(css) {
28105const existing = this.styleElement ?? document.getElementById(DICTIONARY_STYLE_ID);
28106if (!css.trim()) {
28107existing?.remove();
28108this.styleElement = void 0;
28109this.options.onCleared?.();
28110return;
28111}
28112const style = existing ?? document.createElement("style");
28113style.id = DICTIONARY_STYLE_ID;
28114style.textContent = css;
28115if (!style.isConnected) appendToDocumentHead(style);
28116this.styleElement = style;
28117this.options.onRefreshed?.(css.length);
28118}
28119}
28120function createReaderDictionaryStyleController(getSettings, loadDictionaryCss, onUnavailable) {
28121return new DictionaryStyleController({
28122loadCss: () => {
28123const settings2 = getSettings();
28124return settings2.localDictionariesEnabled ? loadDictionaryCss(settings2.dictionaryPreferences) : Promise.resolve("");
28125},
28126onUnavailable
28127});
28128}
28129class OfflineDictionarySetupController {
28130constructor(options) {
28131this.options = options;
28132}
28133running = false;
28134async run() {
28135if (this.running) return;
28136const install = yomuSettingsSurfaceCompanion()?.installOfflineParsingDictionaries;
28137if (!install) return;
28138this.running = true;
28139try {
28140const result = await install({
28141dictionaries: this.options.dictionaries,
28142getSettings: this.options.getSettings,
28143applySettings: this.options.applySettings,
28144onProgress: this.options.notify
28145});
28146await this.finish(result);
28147} finally {
28148this.running = false;
28149}
28150}
28151async finish(result) {
28152if (result.installed.length) await this.options.afterInstalled();
28153const copyKey = offlineDictionarySetupCopyKey(result);
28154if (copyKey) this.options.notify(uiText$1(this.options.getSettings().interfaceLanguage, copyKey));
28155}
28156}
28157function offlineDictionarySetupCopyKey(result) {
28158if (result.failed.length) return "offlineDictionarySetupFailed";
28159if (result.installed.length) return "offlineDictionarySetupComplete";
28160return void 0;
28161}
28162const log$5 = Logger.scope("FactoryReset");
28163const FACTORY_RESET_PREPARE_DELAY_MS = 80;
28164const FACTORY_RESET_REMOTE_GUARD_TIMEOUT_MS = 3e4;
28165const FACTORY_RESET_DICTIONARY_DELETE_TIMEOUT_MS = 6e4;
28166function resetFactoryResetDictionaryDatabase(dictionaries, completedResetId) {
28167return dictionaries.deleteDatabase({ timeoutMs: FACTORY_RESET_DICTIONARY_DELETE_TIMEOUT_MS, ...completedResetId ? { completedResetId } : {} }).then(() => ({ deleted: true }));
28168}
28169function createFactoryResetCoordinator(options) {
28170return new FactoryResetCoordinator({
28171isDestroyed: options.isDestroyed,
28172getLanguage: options.getLanguage,
28173invalidateRuntimeStores: options.invalidateRuntimeStores,
28174resetDictionaryDatabase: (completedResetId) => resetFactoryResetDictionaryDatabase(options.dictionaries, completedResetId),
28175toast: options.toast,
28176reload: options.reload
28177});
28178}
28179class FactoryResetCoordinator {
28180constructor(dependencies) {
28181this.dependencies = dependencies;
28182}
28183unsubscribe;
28184activeResetId = "";
28185handledSignals = new Set();
28186remoteGuardReleaseTimer;
28187bind() {
28188if (this.unsubscribe) return;
28189this.unsubscribe = subscribeToFactoryResetSignals((signal) => {
28190void this.handleSignal(signal);
28191});
28192}
28193destroy() {
28194this.unsubscribe?.();
28195this.unsubscribe = void 0;
28196this.clearRemoteGuardReleaseTimer();
28197}
28198async resetAllData() {
28199const confirmed = window.confirm(this.text("factoryResetConfirm", { appName: APP_NAME }));
28200if (!confirmed) return;
28201const resetSignal = createFactoryResetSignal("prepare");
28202this.activeResetId = resetSignal.id;
28203beginSettingsResetGuard();
28204let epochCommitted = false;
28205try {
28206await publishFactoryResetSignal(resetSignal);
28207await this.dependencies.invalidateRuntimeStores();
28208await delay(FACTORY_RESET_PREPARE_DELAY_MS);
28209await clearManagedStoredValues();
28210await deleteSettingsStorage();
28211await this.assertManagedStateDeleted();
28212await this.dependencies.resetDictionaryDatabase();
28213await commitManagedStateResetEpoch(resetSignal.id);
28214epochCommitted = true;
28215await this.dependencies.resetDictionaryDatabase(resetSignal.id).catch((error) => log$5.warn("Final dictionary reset failed after epoch commit", error));
28216await publishFactoryResetSignal(createFactoryResetSignal("complete", resetSignal.id)).catch((error) => log$5.warn("Factory reset completion signal failed after epoch commit", error));
28217await clearFactoryResetSignal().catch((error) => log$5.warn("Factory reset signal cleanup failed after epoch commit", error));
28218this.dependencies.reload();
28219} catch (error) {
28220if (epochCommitted || managedStateResetEpochMayHaveCommitted(error)) {
28221log$5.warn("Factory reset finalization failed after epoch commit; reloading stale realm", error);
28222await clearFactoryResetSignal().catch((signalError) => log$5.warn("Factory reset signal cleanup failed", signalError));
28223this.dependencies.reload();
28224return;
28225}
28226this.activeResetId = "";
28227await clearFactoryResetSignal().catch((signalError) => log$5.warn("Factory reset signal cleanup failed", signalError));
28228endSettingsResetGuard();
28229log$5.warn("All-data reset failed", error);
28230this.dependencies.toast(userFacingErrorText(this.dependencies.getLanguage(), "factoryResetFailed", error));
28231}
28232}
28233async handleSignal(signal) {
28234if (this.dependencies.isDestroyed() || signal.id === this.activeResetId) return;
28235const handledKey = `${signal.id}:${signal.phase}`;
28236if (this.handledSignals.has(handledKey)) return;
28237this.handledSignals.add(handledKey);
28238beginSettingsResetGuard();
28239await this.dependencies.invalidateRuntimeStores();
28240if (signal.phase === "complete") {
28241this.clearRemoteGuardReleaseTimer();
28242this.dependencies.toast(this.text("factoryResetOtherTabReloading"));
28243window.setTimeout(() => this.dependencies.reload(), 50);
28244} else {
28245this.scheduleRemoteGuardRelease();
28246}
28247}
28248async assertManagedStateDeleted() {
28249const managedKeysStillPresent = await managedStoredKeysLeftAfterReset();
28250if (!managedKeysStillPresent.length) return;
28251log$5.warn("Managed keys remained after reset", { managedKeysStillPresent });
28252throw new ManagedStateResetError(`Managed keys remained after reset: ${managedKeysStillPresent.join(", ")}`);
28253}
28254text(key, values = {}) {
28255return uiText$1(this.dependencies.getLanguage(), key).replace(/\{(\w+)\}/g, (_match, name) => values[name] ?? "");
28256}
28257scheduleRemoteGuardRelease() {
28258this.clearRemoteGuardReleaseTimer();
28259this.remoteGuardReleaseTimer = window.setTimeout(() => {
28260this.remoteGuardReleaseTimer = void 0;
28261endSettingsResetGuard();
28262}, FACTORY_RESET_REMOTE_GUARD_TIMEOUT_MS);
28263}
28264clearRemoteGuardReleaseTimer() {
28265if (this.remoteGuardReleaseTimer === void 0) return;
28266window.clearTimeout(this.remoteGuardReleaseTimer);
28267this.remoteGuardReleaseTimer = void 0;
28268}
28269}
28270const ANNOTATION_SCOPE_ATTRIBUTE = "data-yomu-annotation-scope";
28271const ANNOTATION_SCOPE_SURFACE_ATTRIBUTE = "data-yomu-runtime-surface";
28272const ANNOTATION_SCOPE_SURFACE_VALUE = "surface";
28273const ANNOTATION_SCOPE_SURFACE_SELECTOR = `[${ANNOTATION_SCOPE_SURFACE_ATTRIBUTE}], .yomu-try-me-text`;
28274function annotationScopeActive() {
28275return document.documentElement?.getAttribute(ANNOTATION_SCOPE_ATTRIBUTE) === ANNOTATION_SCOPE_SURFACE_VALUE;
28276}
28277function annotationScopeRoots() {
28278if (!annotationScopeActive()) return null;
28279const matches = Array.from(document.querySelectorAll(ANNOTATION_SCOPE_SURFACE_SELECTOR));
28280const matchSet = new Set(matches);
28281return matches.filter((element) => {
28282const ancestor = element.parentElement?.closest(ANNOTATION_SCOPE_SURFACE_SELECTOR);
28283return !ancestor || !matchSet.has(ancestor);
28284});
28285}
28286function scanScopeRoots(fallback = document.body) {
28287const roots = annotationScopeRoots();
28288if (roots) return roots;
28289return fallback ? [fallback] : [];
28290}
28291function nodeWithinAnnotationScope(node, roots = annotationScopeRoots()) {
28292if (!roots) return true;
28293return roots.some((root) => composedSurfaceContains(root, node));
28294}
28295function mutationMayExpandAnnotationScope(mutation, roots = annotationScopeRoots()) {
28296if (!roots) return false;
28297if (mutation.type === "childList") {
28298return Array.from(mutation.addedNodes).some(
28299(node) => nodeWithinAnnotationScope(node, roots) || node instanceof Element && (node.matches(ANNOTATION_SCOPE_SURFACE_SELECTOR) || Boolean(node.querySelector(ANNOTATION_SCOPE_SURFACE_SELECTOR)))
28300);
28301}
28302if (mutation.type !== "attributes" || mutation.attributeName !== ANNOTATION_SCOPE_SURFACE_ATTRIBUTE && mutation.attributeName !== "class") return false;
28303return mutation.target instanceof Element && mutation.target.matches(ANNOTATION_SCOPE_SURFACE_SELECTOR);
28304}
28305function composedSurfaceContains(surface, node) {
28306let current = node;
28307while (current) {
28308if (current === surface || surface.contains(current)) return true;
28309const root = current.getRootNode();
28310if (!(root instanceof ShadowRoot)) return false;
28311current = root.host;
28312}
28313return false;
28314}
28315function queryWithinAnnotationScope(selector) {
28316const roots = annotationScopeRoots();
28317if (!roots) return Array.from(document.querySelectorAll(selector));
28318const seen = new Set();
28319for (const root of roots) {
28320if (root.matches(selector)) seen.add(root);
28321for (const element of root.querySelectorAll(selector)) seen.add(element);
28322}
28323return [...seen];
28324}
28325function firstComposedEventGeometryMatch(event, resolve) {
28326const candidates = [];
28327const seen = new Set();
28328const append = (candidate) => {
28329if (!(candidate instanceof Element) || seen.has(candidate)) return;
28330seen.add(candidate);
28331candidates.push(candidate);
28332};
28333event.composedPath().forEach(append);
28334append(event.target);
28335for (const candidate of candidates) {
28336const result = resolve(candidate);
28337if (result) return result;
28338}
28339return null;
28340}
28341function isRubyAnnotation(element) {
28342return element.tagName === "RT" || element.tagName === "RP";
28343}
28344function rubyReadingText(element, fallback, rtText = defaultRubyText) {
28345let text2 = "";
28346let base = "";
28347element.childNodes.forEach((child) => {
28348if (child.nodeType === Node.TEXT_NODE) {
28349base += child.textContent ?? "";
28350return;
28351}
28352if (child.nodeType !== Node.ELEMENT_NODE) return;
28353const childElement = child;
28354if (childElement.tagName === "RT") {
28355text2 += rtText(childElement, base);
28356base = "";
28357return;
28358}
28359if (childElement.tagName === "RP") return;
28360base += fallback(childElement);
28361});
28362return text2 + base || fallback(element);
28363}
28364function defaultRubyText(element, base) {
28365return element.textContent || base;
28366}
28367function isJpdbHost() {
28368return location.hostname === "jpdb.io";
28369}
28370function isReviewPage() {
28371return location.pathname.startsWith("/review");
28372}
28373function isKanjiPage() {
28374return location.pathname.startsWith("/kanji/");
28375}
28376function isKanjiReviewFront() {
28377const state = currentReviewCardState();
28378return state.isKanji && state.phase === "before" && !hasReviewAnswerContent();
28379}
28380function isKanjiReviewBack() {
28381const state = currentReviewCardState();
28382return state.isKanji && (state.phase === "after" || hasReviewAnswerContent());
28383}
28384function hasReviewAnswerContent() {
28385return sourceElements(document, ".review-reveal, .result.kanji .kanji, .answer-box .kanji, a.kanji.plain, .subsection-meanings").length > 0;
28386}
28387function isUnrevealedJpdbReview() {
28388return isJpdbHost() && isReviewPage() && !hasReviewAnswerContent();
28389}
28390function isJpdbReviewFrontPrompt(element) {
28391if (!isJpdbHost() || !isReviewPage()) return false;
28392if (!element.closest(".review-card, .prompt, .spelling, .kanji, .vocabulary-spelling")) return false;
28393if (element.closest(".answer-box, .subsection-meanings, .result, .review-reveal")) return false;
28394return isUnrevealedJpdbReview();
28395}
28396function currentReviewCardState() {
28397if (!isReviewPage()) return { kind: "", kanji: "", isKanji: false, phase: "none" };
28398const response = new URLSearchParams(location.search).get("r");
28399const cardValue = new URLSearchParams(location.search).get("c") ?? document.querySelector('input[name="c"]')?.value ?? "";
28400return parseJpdbReviewCardValue(cardValue, response);
28401}
28402function parseJpdbReviewCardValue(value, response = null) {
28403const parts = (value ?? "").split(",");
28404const kind = (parts[0] ?? "").trim();
28405const kanji = firstReviewGlyph(parts.slice(1).join(",")) ?? "";
28406const isKanji = kind.startsWith("k") && Boolean(kanji);
28407const phase = reviewCardPhase(isKanji, response);
28408return { kind, kanji, isKanji, phase };
28409}
28410function reviewCardPhase(isKanji, response) {
28411if (!isKanji) return "none";
28412return response === "1" ? "after" : "before";
28413}
28414function extractCurrentKanji() {
28415return currentKanjiFromPath() || currentKanjiFromHiddenInput() || currentKanjiFromPageText();
28416}
28417function currentKanjiFromPath() {
28418const parts = location.pathname.split("/").filter(Boolean);
28419if (parts[0] === "kanji" && parts[1]) return firstReviewGlyph(decodeURIComponent(parts[1])) ?? "";
28420return "";
28421}
28422function currentKanjiFromHiddenInput() {
28423const hidden = document.querySelector('input[name="c"]')?.value ?? "";
28424const hiddenParts = hidden.split(",");
28425if (hiddenParts[0] === "kb" && hiddenParts[1]) return firstReviewGlyph(hiddenParts[1]) ?? "";
28426return "";
28427}
28428function currentKanjiFromPageText() {
28429return firstReviewGlyph(document.querySelector(".kanji, a.kanji.plain")?.textContent ?? "") ?? "";
28430}
28431function currentJpdbTermTarget() {
28432return currentKanjiTermTarget() ?? currentVocabularyTermTarget();
28433}
28434function currentKanjiTermTarget() {
28435const kanji = extractCurrentKanji();
28436if (!canReadCurrentKanjiTarget(kanji)) return null;
28437const anchor = currentKanjiAddonAnchor();
28438const queries = uniqueLookupValues([kanji, ...kanjiComponentTerms(document)]);
28439return { term: kanji, reading: kanji, queries: queries.length ? queries : [kanji], examples: extractPageExamples(document), anchor: anchor ?? document.body };
28440}
28441function canReadCurrentKanjiTarget(kanji) {
28442return Boolean(kanji && (isKanjiPage() || isKanjiReviewFront() || isKanjiReviewBack()));
28443}
28444function currentVocabularyTermTarget() {
28445if (isUnrevealedJpdbReview()) return null;
28446const pageTerm = extractCurrentTermTarget();
28447const searchQuery = extractSearchQuery();
28448const term = currentVocabularyLookupTerm(pageTerm, searchQuery);
28449if (!term) return null;
28450const queries = vocabularyTermQueries(term, pageTerm, searchQuery);
28451return { term, reading: pageTerm?.reading || term, queries: queries.length ? queries : [term], examples: extractPageExamples(document), anchor: currentVocabularyAddonAnchor() };
28452}
28453function currentVocabularyLookupTerm(pageTerm, searchQuery) {
28454return isSearchPage() && searchQuery ? searchQuery : pageTerm?.term ?? "";
28455}
28456function currentVocabularyAddonAnchor() {
28457return firstElementForSelectors(VOCABULARY_ADDON_ANCHOR_SELECTORS) ?? document.body.firstElementChild ?? document.body;
28458}
28459const VOCABULARY_ADDON_ANCHOR_SELECTORS = [
28460".subsection-meanings",
28461".result.vocabulary",
28462".subsection-used-in",
28463".cross-table",
28464".answer-box",
28465"main"
28466];
28467function firstElementForSelectors(selectors) {
28468for (const selector of selectors) {
28469const element = document.querySelector(selector);
28470if (element) return element;
28471}
28472return null;
28473}
28474function vocabularyTermQueries(term, pageTerm, searchQuery) {
28475const searchTerms = searchResultTerms(8).flatMap((item) => [item.term, item.reading]);
28476return isSearchPage() ? uniqueLookupValues([searchQuery, pageTerm?.term, pageTerm?.reading, ...searchTerms]) : uniqueLookupValues([pageTerm?.term, pageTerm?.reading, term, ...searchTerms]);
28477}
28478function currentLocalDictionaryTargets() {
28479if (isDeckPage() || isSearchPage()) {
28480return sourceElements(document, ".result.vocabulary, .entry").map((section) => {
28481const term = extractTermFromElement(section);
28482const compounds2 = extractPageCompounds(section);
28483return term ? {
28484...term,
28485alternates: uniqueLookupValues([term.reading, ...extractAlternateTerms(section), ...compounds2.flatMap((compound) => [compound.term, compound.reading])]),
28486compounds: compounds2,
28487examples: extractPageExamples(section),
28488anchor: section.querySelector(".subsection-meanings") ?? section
28489} : null;
28490}).filter((item) => item !== null).slice(0, 16);
28491}
28492const target = currentJpdbTermTarget();
28493if (!target) return [];
28494const compounds = extractPageCompounds(document);
28495return [{
28496term: target.term,
28497reading: target.reading,
28498alternates: uniqueLookupValues([...target.queries, ...extractAlternateTerms(document), ...compounds.flatMap((compound) => [compound.term, compound.reading])]),
28499compounds,
28500examples: extractPageExamples(document),
28501anchor: target.anchor
28502}];
28503}
28504function currentKanjiAddonAnchor() {
28505const mnemonic = mnemonicSiblingAnchor();
28506if (mnemonic) return mnemonic;
28507return firstElementForSelectors(KANJI_ADDON_ANCHOR_SELECTORS);
28508}
28509const KANJI_ADDON_ANCHOR_SELECTORS = [
28510".result.kanji .vbox",
28511".result.kanji",
28512".answer-box",
28513".bugfix",
28514"main"
28515];
28516function mnemonicSiblingAnchor() {
28517const labels = Array.from(document.querySelectorAll("h6.subsection-label"));
28518const mnemonic = labels.find((label) => label.textContent?.trim().toLowerCase().startsWith("mnemonic"));
28519return mnemonic?.nextElementSibling instanceof HTMLElement ? mnemonic.nextElementSibling : null;
28520}
28521function kanjiComponentTerms(root) {
28522return sourceElements(root, '.subsection-composed-of-kanji a.plain, a[href^="/kanji/"]').map((element) => cleanText(extractBaseText(element)) || cleanText(element.textContent ?? "")).filter((value) => value && JAPANESE_RE.test(value));
28523}
28524function extractPageCompounds(root) {
28525const entries2 = [];
28526const seen = new Set();
28527for (const row of compoundRows(root)) {
28528const compound = readPageCompound(row);
28529if (!compound || seen.has(compound.term)) continue;
28530seen.add(compound.term);
28531entries2.push(compound);
28532}
28533return entries2.slice(0, 8);
28534}
28535function compoundRows(root) {
28536return compoundSections(root).flatMap(
28537(section) => Array.from(section.querySelectorAll(".subsection > div, .subsection .used-in"))
28538);
28539}
28540function compoundSections(root) {
28541return sourceElements(root, ".subsection-composed-of, .subsection-composed-of-vocabulary, .subsection-composed-of-kanji").filter(isComposedOfSection);
28542}
28543function isComposedOfSection(section) {
28544const label = cleanText(section.querySelector(".subsection-label")?.textContent ?? "").toLowerCase();
28545return !label || label.startsWith("composed of");
28546}
28547function readPageCompound(row) {
28548const link = compoundLink(row);
28549const spelling = compoundSpellingElement(row, link);
28550const term = readCompoundTerm(spelling, link);
28551if (!isJapaneseTerm(term)) return null;
28552return {
28553term,
28554reading: readCompoundReading(spelling, link, term),
28555meaning: cleanText(row.querySelector(".description, .en, .meaning")?.textContent ?? ""),
28556url: link?.getAttribute("href") ?? ""
28557};
28558}
28559function compoundLink(row) {
28560return sourceElements(row, 'a[href^="/vocabulary/"], a[href^="/kanji/"]')[0] ?? null;
28561}
28562function compoundSpellingElement(row, link) {
28563return row.querySelector('.spelling, .jp, .plain, a[href^="/vocabulary/"], a[href^="/kanji/"]') ?? link;
28564}
28565function readCompoundTerm(spelling, link) {
28566return cleanText(link ? jpdbPathTerm(link.pathname) : "") || cleanText(spelling ? extractBaseText(spelling) : "") || cleanText(sourceTextContent(spelling));
28567}
28568function readCompoundReading(spelling, link, term) {
28569return cleanText(link ? vocabularyPathReading(link.pathname) : "") || cleanText(spelling ? extractReadingText(spelling) : "") || term;
28570}
28571function extractPageExamples(root) {
28572const seen = new Set();
28573const examples = [];
28574const sections = Array.from(root.querySelectorAll(".subsection-examples, .subsection-monolingual-examples"));
28575for (const section of sections) {
28576const rows = Array.from(section.querySelectorAll(".subsection > div, .example, li, p"));
28577for (const row of rows) {
28578const example = readPageExampleRow(row, seen);
28579if (example) examples.push(example);
28580}
28581}
28582return examples.slice(0, 5);
28583}
28584function readPageExampleRow(row, seen) {
28585const sentence = pageExampleSentence(row);
28586if (!shouldKeepPageExample(sentence, seen)) return null;
28587seen.add(sentence);
28588const translation = cleanText(row.querySelector(".translation, .en, .english")?.textContent ?? "");
28589return { sentence, translation };
28590}
28591function pageExampleSentence(row) {
28592const sentenceNode = row.querySelector(".sentence, .jp, .japanese, .plain") ?? row;
28593return cleanText(extractBaseText(sentenceNode)) || cleanText(sentenceNode.textContent ?? "");
28594}
28595function shouldKeepPageExample(sentence, seen) {
28596return isJapaneseTerm(sentence) && !seen.has(sentence);
28597}
28598function localDictionaryLookupVariants(target) {
28599const variants = [];
28600const compoundValues = new Set(target.compounds.flatMap((compound) => [
28601cleanText(compound.term),
28602cleanText(compound.reading)
28603]).filter(Boolean));
28604const add = (term, reading = "") => {
28605const cleanTerm = cleanText(term);
28606const cleanReading = cleanText(reading);
28607if (!cleanTerm) return;
28608if (variants.some((item) => item.term === cleanTerm && item.reading === cleanReading)) return;
28609variants.push({ term: cleanTerm, reading: cleanReading });
28610};
28611add(target.term, target.reading);
28612add(target.reading);
28613for (const compound of target.compounds) {
28614add(compound.term, compound.reading);
28615}
28616for (const alternate of target.alternates) {
28617add(alternate, alternate === target.term ? target.reading : "");
28618if (target.reading && alternate !== target.reading && !compoundValues.has(cleanText(alternate))) add(alternate, target.reading);
28619}
28620return variants;
28621}
28622function uniqueLocalDictionaryEntries(entries2) {
28623const seen = new Set();
28624return entries2.filter((entry) => {
28625const key = localDictionaryEntryKey(entry);
28626if (seen.has(key)) return false;
28627seen.add(key);
28628return true;
28629});
28630}
28631function localDictionaryEntryKey(entry) {
28632const glossaryKey = JSON.stringify(entry.glossary);
28633return entry.sequence !== void 0 ? `${entry.dictionary}
28634sequence:${entry.sequence}
28635${glossaryKey}` : `${entry.dictionary}
28636${entry.expression}
28637${entry.reading}
28638${glossaryKey}`;
28639}
28640function dictionaryPreferencePriority(dictionary, settings2) {
28641const preference = settings2.dictionaryPreferences.find((item) => item.name === dictionary);
28642return preference?.priority ?? 999;
28643}
28644function jpdbAudioCard(term, reading) {
28645return {
28646vid: 0,
28647sid: 0,
28648rid: 0,
28649spelling: term,
28650reading: reading || term,
28651frequencyRank: null,
28652partOfSpeech: [],
28653meanings: [],
28654cardState: ["not-in-deck"],
28655pitchAccent: [],
28656wordWithReading: null,
28657source: "local"
28658};
28659}
28660function isDeckPage() {
28661return location.pathname.startsWith("/deck");
28662}
28663function isSearchPage() {
28664return location.pathname.startsWith("/search");
28665}
28666function extractCurrentTermTarget() {
28667const fromPage = extractTermFromElement(document.body);
28668const fromUrl = extractTermFromUrl();
28669if (fromUrl) {
28670const pageReading = fromPage?.term === fromUrl ? fromPage.reading : "";
28671return { term: fromUrl, reading: extractReadingFromUrl() || pageReading || fromUrl };
28672}
28673return fromPage;
28674}
28675function extractSearchQuery() {
28676if (!isSearchPage()) return "";
28677return cleanText(new URLSearchParams(location.search).get("q") ?? "");
28678}
28679function extractTermFromUrl() {
28680return jpdbPathTerm(location.pathname);
28681}
28682function extractReadingFromUrl() {
28683return vocabularyPathReading(location.pathname);
28684}
28685function decodePathPart(value) {
28686try {
28687return decodeURIComponent(value);
28688} catch {
28689return value;
28690}
28691}
28692function extractTermFromElement(root) {
28693for (const selector of TERM_TARGET_SELECTORS) {
28694const element = sourceElements(root, selector)[0];
28695if (!element) continue;
28696const target = termTargetFromElement(element);
28697if (target) return target;
28698}
28699return null;
28700}
28701const TERM_TARGET_SELECTORS = [
28702".vocabulary-spelling a",
28703".vocabulary-spelling",
28704".horizontal-spelling",
28705".subsection-spelling",
28706".answer-box .plain",
28707".plain"
28708];
28709function termTargetFromElement(element) {
28710const term = cleanText(extractBaseText(element)) || cleanText(sourceTextContent(element));
28711if (!isJapaneseTerm(term)) return null;
28712const reading = cleanText(extractReadingText(element)) || term;
28713return { term, reading };
28714}
28715function extractBaseText(root) {
28716if (root.nodeType === Node.TEXT_NODE) return root.textContent ?? "";
28717if (root.nodeType !== Node.ELEMENT_NODE) return "";
28718return extractBaseElementText(root);
28719}
28720function extractBaseElementText(element) {
28721if (isIgnoredSourceTextElement(element)) return "";
28722return Array.from(element.childNodes).map(extractBaseText).join("");
28723}
28724function extractReadingText(root) {
28725if (root.nodeType === Node.TEXT_NODE) return root.textContent ?? "";
28726if (root.nodeType !== Node.ELEMENT_NODE) return "";
28727return extractReadingElementText(root);
28728}
28729function extractReadingElementText(element) {
28730if (isIgnoredSourceTextElement(element)) return "";
28731if (element.tagName === "RUBY") return rubyReadingText(element, extractBaseText, generatedAwareRubyText);
28732return Array.from(element.childNodes).map(extractReadingText).join("");
28733}
28734function isJapaneseTerm(value) {
28735return Boolean(value && JAPANESE_RE.test(value));
28736}
28737function isIgnoredSourceTextElement(element) {
28738return isRubyAnnotation(element) || isGeneratedReaderAnnotation(element) || element.matches("[data-j
28738pdb-reader-root], [data-yomu-jpdb-addon]");
28739}
28740function isGeneratedReaderAnnotation(element) {
28741return element.matches("[data-jpdb-reader-surface-ignore], .jpdb-reader-furi, .jpdb-ocr-furi");
28742}
28743function generatedAwareRubyText(element, base) {
28744return isGeneratedReaderAnnotation(element) ? base : element.textContent || base;
28745}
28746function extractAlternateTerms(root) {
28747return sourceElements(root, '.subsection-other-spellings .alt-spelling, .alt-spelling, a[href^="/vocabulary/"]').flatMap((element) => {
28748const fromText = cleanText(extractBaseText(element)) || cleanText(sourceTextContent(element));
28749const href = element instanceof HTMLAnchorElement ? element : element.querySelector('a[href^="/vocabulary/"]');
28750const fromHref = href ? vocabularyPathTerm(href.pathname) : "";
28751return [fromText, fromHref];
28752}).filter((value) => value && JAPANESE_RE.test(value));
28753}
28754function searchResultTerms(limit) {
28755if (!isSearchPage()) return [];
28756return sourceElements(document, ".result.vocabulary, .entry").map((section) => extractTermFromElement(section)).filter((item) => item !== null).slice(0, limit);
28757}
28758function vocabularyPathTerm(pathname) {
28759const parts = jpdbPathParts(pathname);
28760return parts[0] === "vocabulary" && parts[2] ? decodePathPart(parts[2]) : "";
28761}
28762function vocabularyPathReading(pathname) {
28763const parts = jpdbPathParts(pathname);
28764return parts[0] === "vocabulary" && parts[3] ? decodePathPart(parts[3]) : "";
28765}
28766function jpdbPathTerm(pathname) {
28767const parts = jpdbPathParts(pathname);
28768if (parts[0] === "vocabulary" && parts[2]) return decodePathPart(parts[2]);
28769if (parts[0] === "kanji" && parts[1]) return decodePathPart(parts[1]);
28770return "";
28771}
28772function jpdbPathParts(pathname) {
28773return pathname.split("/").filter(Boolean);
28774}
28775function sourceElements(root, selector) {
28776return Array.from(root.querySelectorAll(selector)).filter(isSourceElement);
28777}
28778function isSourceElement(element) {
28779return !element.closest("[data-jpdb-reader-root], [data-yomu-jpdb-addon]");
28780}
28781function sourceTextContent(element) {
28782return element && isSourceElement(element) ? element.textContent ?? "" : "";
28783}
28784function uniqueLookupValues(values) {
28785const seen = new Set();
28786const result = [];
28787for (const value of values) {
28788const text2 = cleanText(value ?? "");
28789const key = text2.replace(/\s+/g, "").toLowerCase();
28790if (!text2 || seen.has(key)) continue;
28791seen.add(key);
28792result.push(text2);
28793}
28794return result;
28795}
28796const READER_OWNED_SELECTOR$1 = "[data-jpdb-reader-root], [data-yomu-jpdb-addon]";
28797const VOCAB_COLUMN_SELECTOR = "div.flex.flex-col.max-w-2xl";
28798const HEADWORD_SELECTOR = [
28799'.text-3xl[lang="ja"]',
28800".text-3xl.font-noto-sans",
28801'.text-4xl[lang="ja"]',
28802'.text-5xl[lang="ja"]',
28803'.text-6xl[lang="ja"]'
28804].join(", ");
28805const KANJI_GLYPH_SELECTOR = ".text-9xl";
28806function isJitenHost() {
28807return location.hostname === "jiten.moe" || location.hostname.endsWith(".jiten.moe");
28808}
28809function isJitenKanjiPage() {
28810return location.pathname.startsWith("/kanji/");
28811}
28812function isJitenVocabPage() {
28813return location.pathname.startsWith("/vocabulary/") || location.pathname.startsWith("/parse");
28814}
28815function isJitenStudyPage() {
28816return location.pathname.startsWith("/srs/study");
28817}
28818function isJitenEnhanceablePage() {
28819return isJitenKanjiPage() || isJitenVocabPage() || isJitenStudyPage();
28820}
28821function extractCurrentJitenKanji() {
28822const parts = location.pathname.split("/").filter(Boolean);
28823const fromPath = parts[0] === "kanji" && parts[1] ? firstReviewGlyph(decodePathPart(parts[1])) ?? "" : "";
28824return fromPath || (firstReviewGlyph(document.querySelector(KANJI_GLYPH_SELECTOR)?.textContent ?? "") ?? "");
28825}
28826function currentJitenTermTarget() {
28827if (isJitenKanjiPage()) {
28828const kanji = extractCurrentJitenKanji();
28829const anchor2 = jitenKanjiAnchor();
28830return kanji && anchor2 ? { term: kanji, reading: kanji, queries: [kanji], examples: [], anchor: anchor2 } : null;
28831}
28832const headword = jitenHeadword();
28833if (!headword) return null;
28834const anchor = jitenContentAnchor();
28835if (!anchor) return null;
28836return {
28837term: headword.term,
28838reading: headword.reading,
28839queries: uniqueLookupValues([headword.term, headword.reading, ...jitenAlternateForms()]),
28840examples: [],
28841anchor
28842};
28843}
28844function jitenContentAnchor() {
28845if (isJitenStudyPage()) return jitenStudyCardAnchor();
28846return jitenVocabAnchor();
28847}
28848function jitenStudyCardAnchor() {
28849const answer = jitenStudyAnswerRegion();
28850if (!answer) return null;
28851return Array.from(answer.children).reverse().find(
28852(child) => child instanceof HTMLElement && !child.closest(READER_OWNED_SELECTOR$1)
28853) ?? null;
28854}
28855function jitenStudyAnswerRegion() {
28856if (Array.from(document.querySelectorAll("button")).some((button) => !button.closest(READER_OWNED_SELECTOR$1) && /show answer/i.test(button.textContent ?? ""))) return null;
28857const wrap = ownedElement$1(document.querySelector(".relative.touch-pan-y"));
28858return Array.from(wrap?.querySelectorAll('[role="region"][aria-label="Answer"]') ?? []).find((answer) => !answer.closest(READER_OWNED_SELECTOR$1) && !Array.from(answer.classList).some((name) => name.startsWith("reveal-leave-"))) ?? null;
28859}
28860function jitenStudyAnswerHidden() {
28861return jitenStudyAnswerRegion() === null;
28862}
28863function isJitenStudyFrontPrompt(element) {
28864if (!isJitenHost() || !isJitenStudyPage()) return false;
28865if (!element.closest(".relative.touch-pan-y")) return false;
28866return jitenStudyAnswerHidden();
28867}
28868function currentJitenStudyHeadwordText() {
28869if (!isJitenHost() || !isJitenStudyPage()) return "";
28870const element = document.querySelector(HEADWORD_SELECTOR);
28871return element ? cleanText(extractBaseText(element)) : "";
28872}
28873function currentJitenLocalDictionaryTargets() {
28874if (isJitenKanjiPage()) {
28875const kanji = extractCurrentJitenKanji();
28876const anchor2 = jitenKanjiAnchor();
28877return kanji && anchor2 ? [{ term: kanji, reading: kanji, alternates: [kanji], compounds: [], examples: [], anchor: anchor2 }] : [];
28878}
28879const headword = jitenHeadword();
28880if (!headword) return [];
28881const anchor = jitenContentAnchor();
28882if (!anchor) return [];
28883return [{
28884term: headword.term,
28885reading: headword.reading,
28886alternates: uniqueLookupValues([headword.reading, ...jitenAlternateForms()]),
28887compounds: [],
28888examples: [],
28889anchor
28890}];
28891}
28892function jitenHeadword() {
28893const element = ownedElement$1(document.querySelector(HEADWORD_SELECTOR));
28894const domTerm = element ? cleanText(extractBaseText(element)) : "";
28895if (element && domTerm && JAPANESE_RE.test(domTerm)) {
28896return { term: domTerm, reading: cleanText(extractReadingText(element)) || domTerm };
28897}
28898const titleTerm = termFromTitle();
28899return titleTerm ? { term: titleTerm, reading: titleTerm } : null;
28900}
28901function termFromTitle() {
28902const title = cleanText(document.title.replace(/\s*[-–—|].*$/, ""));
28903return JAPANESE_RE.test(title) && !/[A-Za-z]/.test(title) ? title : "";
28904}
28905function jitenAlternateForms() {
28906const heading = Array.from(document.querySelectorAll("h1, h2, h3, h4")).find((node) => /^forms|別の表記|表記/i.test(cleanText(node.textContent ?? "")));
28907if (!heading?.parentElement) return [];
28908return Array.from(heading.parentElement.querySelectorAll('[lang="ja"], ruby')).map((node) => cleanText(extractBaseText(node))).filter((value) => value && JAPANESE_RE.test(value)).slice(0, 8);
28909}
28910function jitenVocabAnchor() {
28911const column = ownedElement$1(document.querySelector(VOCAB_COLUMN_SELECTOR));
28912const lastChild = column?.lastElementChild;
28913if (lastChild instanceof HTMLElement) return lastChild;
28914return column;
28915}
28916function jitenKanjiAnchor() {
28917const header = ownedElement$1(document.querySelector(".text-center"));
28918if (header) return header;
28919return document.querySelector(KANJI_GLYPH_SELECTOR)?.closest(".space-y-2") ?? null;
28920}
28921function ownedElement$1(element) {
28922return element && !element.closest(READER_OWNED_SELECTOR$1) ? element : null;
28923}
28924const READER_OWNED_SELECTOR = "[data-jpdb-reader-root], [data-yomu-jpdb-addon]";
28925const REVIEW_HEADER_SELECTOR = '#js-rev-header h1[id^="rev-id-"]';
28926const QUIZ_ROOT_SELECTOR = "#js-quiz";
28927const QUIZ_QUESTION_SELECTOR = '#js-tour-quiz-question, .bp-quiz-question, [id^="study-question-"]';
28928const QUIZ_ANSWER_RUBY_SELECTOR = '#js-tour-quiz-question button ruby, .bp-quiz-question button ruby, [id^="study-question-"] button ruby';
28929const QUIZ_ANSWER_SELECTOR = "#js-tour-quiz-answer";
28930const BUNPRO_LOCALE_PREFIX$1 = /^\/(?:en|es|fr|id|ja)(?=\/|$)/;
28931function isBunproHost() {
28932return location.hostname === "bunpro.jp" || location.hostname.endsWith(".bunpro.jp");
28933}
28934function isBunproEnhanceablePage() {
28935const pathname = bunproPathname();
28936return pathname === "/learn" || pathname.startsWith("/learn/") || pathname === "/reviews" || pathname.startsWith("/vocabs/") || pathname.startsWith("/grammar_points/");
28937}
28938function currentBunproTermTarget() {
28939if (!isBunproHost() || !isBunproEnhanceablePage() || isBunproQuizAnswerHidden()) return null;
28940const headword = bunproHeadword();
28941const anchor = bunproAddonAnchor();
28942if (!headword || !anchor) return null;
28943return {
28944term: headword.term,
28945reading: headword.reading,
28946queries: uniqueLookupValues([headword.term, headword.reading]),
28947examples: bunproPageExamples(),
28948anchor
28949};
28950}
28951function currentBunproLocalDictionaryTargets() {
28952const target = currentBunproTermTarget();
28953if (!target) return [];
28954return [{
28955term: target.term,
28956reading: target.reading,
28957alternates: uniqueLookupValues(target.queries),
28958compounds: [],
28959examples: target.examples,
28960anchor: target.anchor
28961}];
28962}
28963function isBunproQuizAnswerHidden() {
28964if (!isBunproHost() || !isBunproEnhanceablePage()) return false;
28965const quiz = document.querySelector(QUIZ_ROOT_SELECTOR);
28966if (!quiz) return false;
28967if (document.querySelector(REVIEW_HEADER_SELECTOR)) return false;
28968const manualInput = quiz.querySelector("#js-manual-input");
28969if (manualInput) return !manualInput.readOnly;
28970if (quiz.querySelector(".InputFlashcardReveal, .InputFlashcardSubmit")) return true;
28971return !quiz.querySelector(QUIZ_ANSWER_SELECTOR);
28972}
28973function isBunproReviewFrontPrompt(element) {
28974if (!isBunproHost() || !element.closest(QUIZ_QUESTION_SELECTOR)) return false;
28975if (element.closest(QUIZ_ANSWER_SELECTOR)) return false;
28976return isBunproQuizAnswerHidden();
28977}
28978function bunproPathname() {
28979const stripped = location.pathname.replace(BUNPRO_LOCALE_PREFIX$1, "");
28980return stripped || "/";
28981}
28982function bunproHeadword() {
28983const heading = ownedElement(document.querySelector(REVIEW_HEADER_SELECTOR));
28984const headword = heading?.querySelector('ruby, [lang="ja"]') ?? heading;
28985const domTerm = headword ? cleanText(extractBaseText(headword)) : "";
28986const domReading = headword ? cleanText(extractReadingText(headword)) : "";
28987if (domTerm && isCompactJapaneseTerm(domTerm)) {
28988return {
28989term: domTerm,
28990reading: domReading || domTerm
28991};
28992}
28993const quizHeadword = bunproRevealedQuizHeadword();
28994if (quizHeadword) return quizHeadword;
28995const pathTerm = bunproVocabularyPathTerm();
28996return pathTerm ? { term: pathTerm, reading: pathTerm } : null;
28997}
28998function bunproRevealedQuizHeadword() {
28999const quiz = document.querySelector(QUIZ_ROOT_SELECTOR);
29000const input = quiz?.querySelector("#js-manual-input");
29001if (!quiz || !input?.readOnly) return null;
29002const reading = cleanText(input.value);
29003if (!isCompactJapaneseTerm(reading)) return null;
29004const answerRuby = quiz.querySelector(QUIZ_ANSWER_RUBY_SELECTOR);
29005const base = answerRuby ? cleanText(extractBaseText(answerRuby)) : "";
29006const stemReading = answerRuby ? cleanText(extractReadingText(answerRuby)) : "";
29007const suffix = stemReading && reading.startsWith(stemReading) ? reading.slice(stemReading.length) : "";
29008const term = base && stemReading && suffix ? `${base}${suffix}` : reading;
29009return isCompactJapaneseTerm(term) ? { term, reading } : null;
29010}
29011function bunproVocabularyPathTerm() {
29012const parts = bunproPathname().split("/").filter(Boolean);
29013if (parts[0] !== "vocabs" || !parts[1]) return "";
29014try {
29015const term = cleanText(decodeURIComponent(parts[1]));
29016return isCompactJapaneseTerm(term) ? term : "";
29017} catch {
29018return "";
29019}
29020}
29021function bunproAddonAnchor() {
29022const answer = ownedElement(document.querySelector(QUIZ_ANSWER_SELECTOR));
29023if (answer) return lastOwnedChild(answer) ?? answer;
29024const header = ownedElement(document.querySelector("#js-rev-header"));
29025if (header) {
29026for (const selector of ["#examples", "#about", "#dictionary-definition"]) {
29027const sectionHeader = ownedElement(document.querySelector(selector));
29028const section = sectionHeader?.closest("section");
29029if (section) return section;
29030}
29031return header;
29032}
29033if (!isBunproQuizAnswerHidden()) {
29034return ownedElement(document.querySelector("#js-tour-quiz-question"));
29035}
29036return null;
29037}
29038function bunproPageExamples() {
29039const root = document.querySelector(QUIZ_QUESTION_SELECTOR) ?? document.querySelector("#examples")?.closest("section") ?? document;
29040const seen = new Set();
29041const examples = [];
29042for (const element of Array.from(root.querySelectorAll('[lang="ja"], .bp-quiz-question'))) {
29043if (element.closest(READER_OWNED_SELECTOR)) continue;
29044const sentence = cleanText(extractBaseText(element));
29045if (!sentence || sentence.length > 180 || !JAPANESE_RE.test(sentence) || seen.has(sentence)) continue;
29046seen.add(sentence);
29047examples.push({ sentence, translation: "" });
29048if (examples.length >= 8) break;
29049}
29050return examples;
29051}
29052function isCompactJapaneseTerm(value) {
29053return value.length <= 40 && JAPANESE_RE.test(value) && !/[。!?!?]/.test(value);
29054}
29055function lastOwnedChild(element) {
29056return Array.from(element.children).reverse().find((child) => child instanceof HTMLElement && !child.closest(READER_OWNED_SELECTOR)) ?? null;
29057}
29058function ownedElement(element) {
29059return element && !element.closest(READER_OWNED_SELECTOR) ? element : null;
29060}
29061const STRUCTURAL_EXCLUDE_ENTRIES = [
29062"[data-jpdb-reader-root]",
29063".jpdb-reader-text-mirror",
29064".jpdb-reader-word",
29065"script",
29066"style",
29067"noscript",
29068"input",
29069"select",
29070"textarea",
29071"option",
29072"svg",
29073"use",
29074"canvas",
29075"rt",
29076"rp",
29077"[hidden]",
29078'[aria-hidden="true"]',
29079'[contenteditable="true"]',
29080"details:not([open]) > :not(summary)",
29081"details:not([open]) > :not(summary) *"
29082];
29083const COMMON_EXCLUDE = STRUCTURAL_EXCLUDE_ENTRIES.join(",");
29084const ASBPLAYER_ROOT_SELECTOR = ".asbplayer-offscreen, .asbplayer-subtitles-container-bottom";
29085const DEFAULT_SCAN_TARGET_LIMIT = Number.POSITIVE_INFINITY;
29086const RESIDUAL_VISIBLE_JAPANESE_PARSER_ID = "residual-visible-japanese-parser";
29087const PROFILE_PHASE_GENERIC_RESERVE_THRESHOLD = 40;
29088const PROFILE_PHASE_GENERIC_RESERVE_RATIO = 0.3;
29089const PROFILE_PHASE_GENERIC_RESERVE_MAX = 64;
29090const GENERIC_UI_CHROME_TARGET_MAX = 48;
29091const MOKURO_SCAN_ROOT_LIMIT = 160;
29092const MOKURO_SCAN_MARGIN_VIEWPORTS = 0.75;
29093const GENERIC_PROSE_ROOTS = [
29094"main h1",
29095'[role="main"] h1',
29096"article",
29097"main article",
29098'[role="main"] article',
29099'[role="article"]',
29100".markdown",
29101".markdown-body",
29102".markdown-content",
29103".message",
29104".message-body",
29105".message-content",
29106".messageContent",
29107".chat-message",
29108".conversation-turn",
29109".model-response",
29110".model-response-text",
29111".response-content",
29112"[data-message-author-role]",
29113"[data-message-id]",
29114'[data-testid*="conversation-turn" i]',
29115'[data-testid*="chat-message" i]',
29116'[data-testid*="message-content" i]',
29117'[data-testid*="message-bubble" i]',
29118'[data-test-id*="chat-message" i]',
29119'[data-test-id*="message-content" i]',
29120".article",
29121".post",
29122".entry",
29123".story",
29124".prose",
29125".content",
29126".article-body",
29127".article-content",
29128".entry-content",
29129".post-content",
29130".story-body",
29131'[itemprop="articleBody"]'
29132].join(",");
29133const GENERIC_PROSE_EXCLUDE = [
29134COMMON_EXCLUDE,
29135"nav",
29136"header",
29137"footer",
29138"aside",
29139"button",
29140'a[role="button"]',
29141'[role="complementary"]',
29142"[title]",
29143'[class*="audio" i]',
29144'[class*="aside" i]',
29145'[class*="banner" i]',
29146'[class*="breadcrumb" i]',
29147'[class*="btn" i]',
29148'[class*="button" i]',
29149'[class*="card" i]',
29150'[class*="comment" i]',
29151'[class*="footer" i]',
29152'[class*="header" i]',
29153'[class*="menu" i]',
29154'[class*="meta" i]',
29155'[class*="nav" i]',
29156'[class*="new-article" i]',
29157'[class*="pager" i]',
29158'[class*="popular" i]',
29159'[class*="promo" i]',
29160'[class*="rank" i]',
29161'[class*="recommend" i]',
29162'[class*="related" i]',
29163'[class*="share" i]',
29164'[class*="sidebar" i]',
29165'[class*="sound" i]',
29166'[class*="speaker" i]',
29167'[class*="teaser" i]',
29168'[class*="voice" i]',
29169'[aria-label*="聞"]',
29170'[aria-label*="音声"]',
29171"time"
29172].join(",");
29173const SAFE_UI_CHROME_SCOPE_SELECTORS = [
29174"nav",
29175'[role="navigation"]',
29176"header",
29177"aside",
29178'[role="complementary"]',
29179'[role="tablist"]',
29180'[class*="appearance" i]',
29181'[id*="appearance" i]',
29182'[class*="menu" i]',
29183'[id*="menu" i]',
29184'[class*="pinnable" i]',
29185'[id*="pinnable" i]',
29186'[class*="prefs" i]',
29187'[id*="prefs" i]',
29188'[class*="sidebar" i]',
29189'[id*="sidebar" i]',
29190'[class*="tabs" i]',
29191'[id*="tabs" i]',
29192'[class*="toc" i]',
29193'[id*="toc" i]',
29194'[class*="toolbar" i]',
29195'[id*="toolbar" i]'
29196];
29197const SAFE_UI_CHROME_CONTROL_SELECTORS = [
29198"a[href]",
29199"button",
29200"label",
29201"summary",
29202'[role="button"]',
29203'[role="link"]',
29204'[role="menuitem"]',
29205'[role="menuitemcheckbox"]',
29206'[role="menuitemradio"]',
29207'[role="option"]',
29208'[role="checkbox"]',
29209'[role="radio"]',
29210'[role="switch"]',
29211'[role="tab"]'
29212];
29213const SAFE_UI_CHROME_PEER_TEXT_SELECTORS = [
29214'[role="heading"]',
29215'[class*="label" i]'
29216];
29217const PASSIVE_INTERACTION_ROOTS = [
29218...SAFE_UI_CHROME_CONTROL_SELECTORS,
29219"[aria-controls]",
29220"[aria-expanded]",
29221'[slot="more-button"]',
29222".more-button",
29223"#more",
29224"#less",
29225"[onclick]",
29226'[tabindex]:not([tabindex="-1"])',
29227'[class*="audio" i]',
29228'[class*="button" i]',
29229'[class*="control" i]',
29230'[class*="play" i]',
29231'[class*="sound" i]',
29232'[class*="speaker" i]',
29233'[class*="toggle" i]'
29234].join(",");
29235const SCOPED_SAFE_UI_CHROME_ROOTS = [
29236...SAFE_UI_CHROME_SCOPE_SELECTORS,
29237...SAFE_UI_CHROME_SCOPE_SELECTORS.flatMap((scope) => [...SAFE_UI_CHROME_CONTROL_SELECTORS, ...SAFE_UI_CHROME_PEER_TEXT_SELECTORS].map((control) => `${scope} ${control}`))
29238];
29239const SAFE_UI_CHROME_ROOTS = [
29240...SCOPED_SAFE_UI_CHROME_ROOTS,
29241"nav a[href]",
29242'[role="navigation"] a[href]',
29243'[class*="breadcrumb" i] a[href]',
29244"header a[href]",
29245"aside a[href]",
29246"main a[href]",
29247'[role="main"] a[href]',
29248"article a[href]",
29249"button",
29250"summary",
29251'[role="checkbox"]',
29252'[role="radio"]',
29253'[role="switch"]',
29254'[role="tab"]',
29255'[role="menuitem"]',
29256'[role="menuitemcheckbox"]',
29257'[role="menuitemradio"]',
29258"time",
29259"[datetime]"
29260].join(",");
29261const PROFILE_SAFE_UI_CHROME_ROOTS = SAFE_UI_CHROME_ROOTS;
29262const SAFE_UI_CHROME_ARIA_MENU_ROOTS = [
29263'[role="menuitem"]',
29264'[role="menuitemcheckbox"]',
29265'[role="menuitemradio"]'
29266].join(",");
29267const YT_PLAYER_CHROME_EXCLUDE_ENTRIES = [
29268".ytp-caption-window-container",
29269".caption-window",
29270".captions-text"
29271];
29272const SAFE_UI_CHROME_EXCLUDE_ENTRIES = [
29273...STRUCTURAL_EXCLUDE_ENTRIES,
29274...YT_PLAYER_CHROME_EXCLUDE_ENTRIES,
29275"[disabled]",
29276'[aria-disabled="true"]'
29277];
29278const SAFE_UI_CHROME_EXCLUDE = SAFE_UI_CHROME_EXCLUDE_ENTRIES.join(",");
29279const SAFE_UI_CHROME_ARIA_MENU_EXCLUDE = SAFE_UI_CHROME_EXCLUDE_ENTRIES.filter((entry) => entry !== '[class*="control" i]').join(",");
29280const SAFE_FORM_CHROME_ROOTS = SAFE_UI_CHROME_SCOPE_SELECTORS.flatMap((scope) => [
29281`${scope} form`,
29282`${scope} label`
29283]).join(",");
29284const SAFE_FORM_CHROME_EXCLUDE = [
29285...STRUCTURAL_EXCLUDE_ENTRIES,
29286"[disabled]",
29287'[aria-disabled="true"]'
29288].join(",");
29289const DICTIONARY_SITE_EXCLUDE = [
29290COMMON_EXCLUDE,
29291".pi",
29292".p-button-icon"
29293].join(",");
29294const SAFE_UI_CHROME_MAX_COMPACT_LENGTH = 160;
29295const SAFE_FORM_CHROME_MAX_COMPACT_LENGTH = 80;
29296const YOMU_HOSTED_DOCS_PARSER_ID = "yomu-hosted-docs-parser";
29297const JPDB_PARSER_ID = "jpdb-parser";
29298const YOMU_HOSTED_DOCS_ROOTS = [
29299"[data-yomu-runtime-surface]",
29300".yomu-try-me-text"
29301];
29302const YOMU_HOSTED_DOCS_EXCLUDE = [
29303COMMON_EXCLUDE,
29304".yomu-hosted-overflow-group"
29305].join(",");
29306const YOMU_VIDEO_PLAYER_ROOTS = [
29307".brand strong",
29308"[data-yomu-video-frame] .empty strong",
29309"[data-yomu-video-frame] .empty [data-status]",
29310".file-button",
29311"[data-subtitle-open]",
29312"[data-settings-trigger]",
29313"[data-overflow-menu]"
29314];
29315const YOMU_PDF_READER_ROOTS = [
29316".textLayer",
29317".brand strong",
29318"[data-yomu-pdf-empty] strong",
29319"[data-yomu-pdf-empty] [data-status]",
29320".file-button",
29321"[data-settings-trigger]",
29322"[data-overflow-menu]"
29323];
29324const YOMU_PDF_READER_EXCLUDE = [
29325COMMON_EXCLUDE,
29326".textLayer .endOfContent",
29327'.textLayer span[role="img"]'
29328].join(",");
29329const YOUTUBE_TEXT_EXCLUDE = [
29330COMMON_EXCLUDE,
29331...YT_PLAYER_CHROME_EXCLUDE_ENTRIES
29332].join(",");
29333const YOUTUBE_STABLE_TEXT_HOST_SELECTOR = [
29334"yt-formatted-string",
29335"yt-attributed-string",
29336".ytAttributedStringHost",
29337".yt-core-attributed-string",
29338".yt-core-attributed-string--white-space-pre-wrap"
29339].join(",");
29340const YOUTUBE_VOLATILE_WATCH_METADATA_SELECTOR = [
29341"ytd-watch-metadata #owner-sub-count",
29342"ytd-watch-metadata #owner #subscribe-button",
29343"ytd-watch-metadata #owner button",
29344"ytd-watch-metadata ytd-video-description-transcript-section-renderer",
29345"ytd-watch-metadata ytd-video-description-infocards-section-renderer",
29346"ytd-watch-metadata ytd-video-description-music-section-renderer",
29347"ytd-watch-metadata ytd-video-description-course-section-renderer",
29348"ytd-watch-metadata #description ytd-channel-name",
29349"ytd-watch-metadata #description #owner-sub-count"
29350].join(",");
29351const YOUTUBE_COMMENT_CONTROL_SELECTORS = [
29352"button",
29353'[role="button"]',
29354"[aria-controls]",
29355"[aria-expanded]",
29356'[slot*="button" i]',
29357'[class*="button" i]'
29358];
29359const YOUTUBE_COMMENT_TEXT_AND_ACTION_ROOTS = [
29360"ytd-comment-view-model #content-text",
29361"ytm-comment-renderer #content-text",
29362"ytm-comment-thread-renderer",
29363"ytm-comment-renderer",
29364"ytm-comment-replies-renderer",
29365...YOUTUBE_COMMENT_CONTROL_SELECTORS.map((selector) => `ytd-comment-view-model ${selector}`),
29366...YOUTUBE_COMMENT_CONTROL_SELECTORS.map((selector) => `ytm-comment-renderer ${selector}`)
29367].join(",");
29368const YOUTUBE_COMMENT_HEADER_ROOTS = [
29369"ytd-comments-header-renderer #title",
29370"ytd-comments-header-renderer #count",
29371"ytd-comments-header-renderer .count-text",
29372"ytm-comments-header-renderer",
29373"ytm-comment-section-header-renderer",
29374"ytm-comments-entry-point-header-renderer"
29375].join(",");
29376const YOUTUBE_SYNTHETIC_TEXT_ROOTS = [
29377"ytd-watch-info-text"
29378].join(",");
29379const YOUTUBE_WATCH_INFO_ARIA_PARTS = [
29380"#view-count[aria-label]",
29381"#date-text[aria-label]"
29382].join(",");
29383const YOUTUBE_LIVE_CHAT_TEXT_ROOTS = [
29384"yt-live-chat-text-message-renderer #author-name",
29385"yt-live-chat-text-message-renderer #message",
29386"yt-live-chat-paid-message-renderer #author-name",
29387"yt-live-chat-paid-message-renderer #message",
29388"yt-live-chat-membership-item-renderer #author-name",
29389"yt-live-chat-membership-item-renderer #message",
29390"yt-live-chat-header-renderer #title",
29391"yt-live-chat-header-renderer #primary-content",
29392"yt-live-chat-viewer-engagement-message-renderer #content",
29393"yt-live-chat-viewer-engagement-message-renderer #message",
29394"yt-live-chat-viewer-engagement-message-renderer yt-formatted-string",
29395"yt-live-chat-viewer-engagement-message-renderer a[href]",
29396"yt-live-chat-viewer-engagement-message-renderer button",
29397'yt-live-chat-viewer-engagement-message-renderer [role="button"]',
29398"yt-live-chat-banner-renderer #message",
29399"yt-live-chat-banner-renderer #header",
29400"yt-live-chat-banner-renderer yt-formatted-string",
29401"yt-live-chat-banner-renderer a[href]",
29402"yt-live-chat-banner-renderer button",
29403'yt-live-chat-banner-renderer [role="button"]',
29404"yt-live-chat-restricted-participation-renderer #message",
29405"yt-live-chat-restricted-participation-renderer #subtext",
29406"yt-live-chat-restricted-participation-renderer yt-formatted-string",
29407"yt-live-chat-restricted-participation-renderer a[href]",
29408"yt-live-chat-restricted-participation-renderer button",
29409'yt-live-chat-restricted-participation-renderer [role="button"]',
29410"yt-live-chat-ticker-renderer #text",
29411"yt-live-chat-ticker-renderer #content",
29412"yt-live-chat-ticker-renderer yt-formatted-string",
29413"yt-live-chat-ticker-renderer a[href]",
29414"yt-live-chat-ticker-renderer button",
29415'yt-live-chat-ticker-renderer [role="button"]'
29416];
29417const GOOGLE_SEARCH_ROOTS = [
29418"#botstuff",
29419"#bres",
29420"[data-attrid]",
29421"[data-sokoban-container]",
29422".MjjYud",
29423".g",
29424".VwiC3b",
29425".LC20lb",
29426"#search",
29427"#rso",
29428"#main",
29429"#rcnt",
29430'[role="main"]'
29431];
29432const GOOGLE_SEARCH_EXCLUDE = [
29433COMMON_EXCLUDE,
29434"g-img",
29435"img"
29436].join(",");
29437const BLOOMEE_LANDING_HOSTS = new Set(["bloomeelife.com", "www.bloomeelife.com"]);
29438const BLOOMEE_LANDING_PARSER_ID = "bloomee-landing-parser";
29439const BLOOMEE_LANDING_ROOTS = [
29440".point__itembox-headline",
29441".point__itembox-txt",
29442".cv-step__ttlbox-title",
29443".cv-step__catch",
29444".cv-step__itembox-title",
29445".life-lp-faq h2",
29446".life-lp-faq dt h3",
29447".lp-gift__headline",
29448".lp-gift__txt",
29449".ctaarea p"
29450].join(",");
29451const BOOKWALKER_STOREFRONT_HOSTS = new Set(["bookwalker.jp", "www.bookwalker.jp"]);
29452const BOOKWALKER_READER_PARSER_ID = "bookwalker-reader";
29453const BOOKWALKER_STOREFRONT_PARSER_ID = "bookwalker-storefront";
29454const BOOKWALKER_TEXT_METADATA_ROOTS = [
29455"#bookTitle",
29456"#book-title",
29457"#book_title",
29458"#bookDescription",
29459"[data-book-title]",
29460"[data-book-description]",
29461'[id*="bookTitle"]',
29462'[id*="book-title"]',
29463'[class*="bookTitle"]',
29464'[class*="book-title"]',
29465".book-title",
29466".book-description",
29467".t-o-heading-book-title",
29468".t-o-heading-book-title__link",
29469".t-c-tile-card__title",
29470".t-c-tile-card__catch",
29471".m-bookDetailTitle",
29472".m-bookDetailLead",
29473".m-bookDetailDescription"
29474];
29475const BOOKWALKER_READER_SETTINGS_SELECTOR = '.settings-popover,[class*="setting" i],[id*="setting" i],[aria-label*="設定"],[role="dialog"],[role="menu"]';
29476const MIGAKU_MARKETING_HOSTS = new Set(["migaku.com", "www.migaku.com"]);
29477const MIGAKU_MARKETING_EXCLUDE = [
29478COMMON_EXCLUDE,
29479".WelcomeText__title",
29480".migaku-surface"
29481].join(",");
29482const YOMUYOMU_HOSTS = new Set(["yomuyomu.app", "www.yomuyomu.app"]);
29483const YOMUYOMU_READER_ROOTS = [
29484'#du-reading-screen canvas[lang*="ja" i]',
29485'#du-lesson-container .lesson-canvas-container canvas[lang*="ja" i]',
29486'.lesson-content canvas[lang*="ja" i]'
29487].join(",");
29488const SITE_PARSER_PROFILES = [
29489{
29490id: YOMU_HOSTED_DOCS_PARSER_ID,
29491roots: YOMU_HOSTED_DOCS_ROOTS,
29492exclude: YOMU_HOSTED_DOCS_EXCLUDE,
29493allowUiText: true,
29494heading: true,
29495minLength: 1,
29496disableGenericDomScan: true,
29497suppressResidualVisibleScan: true,
29498includePassiveInteractionRoots: false,
29499visibleOnly: false,
29500matches: (url) => isYomuHostedPassivePage(url.href)
29501},
29502{
29503id: "yomu-video-player-parser",
29504roots: YOMU_VIDEO_PLAYER_ROOTS,
29505exclude: COMMON_EXCLUDE,
29506allowUiText: true,
29507heading: true,
29508minLength: 1,
29509includeUiChrome: true,
29510includeFormChrome: true,
29511matches: (url) => isYomuHostedVideoPlayerPage(url.href)
29512},
29513{
29514id: "yomu-pdf-reader-parser",
29515roots: YOMU_PDF_READER_ROOTS,
29516exclude: YOMU_PDF_READER_EXCLUDE,
29517allowUiText: true,
29518heading: true,
29519minLength: 1,
29520includeUiChrome: true,
29521includeFormChrome: true,
29522providesTextLayer: true,
29523matches: (url) => isYomuHostedPdfReaderPage(url.href)
29524},
29525{
29526id: "google-search-parser",
29527roots: GOOGLE_SEARCH_ROOTS,
29528exclude: GOOGLE_SEARCH_EXCLUDE,
29529allowUiText: true,
29530minLength: 1,
29531includeUiChrome: true,
29532includeGenericPageText: true,
29533plainScan: true,
29534matches: (url) => /(^|\.)google\./i.test(url.hostname) && url.pathname === "/search"
29535},
29536{
29537id: BLOOMEE_LANDING_PARSER_ID,
29538roots: [BLOOMEE_LANDING_ROOTS],
29539exclude: COMMON_EXCLUDE,
29540allowUiText: true,
29541minLength: 1,
29542heading: true,
29543allowShortCenteredHeadings: true,
29544matches: (url) => isBloomeeLandingUrl(url)
29545},
29546{
29547id: BOOKWALKER_READER_PARSER_ID,
29548roots: [...BOOKWALKER_TEXT_METADATA_ROOTS, BOOKWALKER_READER_SETTINGS_SELECTOR],
29549exclude: COMMON_EXCLUDE,
29550allowUiText: true,
29551minLength: 1,
29552disableGenericDomScan: true,
29553suppressResidualVisibleScan: true,
29554includePassiveInteractionRoots: true,
29555matches: (url) => isBookWalkerReaderUrl(url)
29556},
29557{
29558id: BOOKWALKER_STOREFRONT_PARSER_ID,
29559roots: BOOKWALKER_TEXT_METADATA_ROOTS,
29560exclude: COMMON_EXCLUDE,
29561allowUiText: true,
29562minLength: 1,
29563disableGenericDomScan: true,
29564includePassiveInteractionRoots: false,
29565providesTextLayer: true,
29566matches: (url) => isBookWalkerStorefrontUrl(url)
29567},
29568{
29569id: "migaku-marketing-parser",
29570roots: ["main"],
29571exclude: MIGAKU_MARKETING_EXCLUDE,
29572allowUiText: true,
29573minLength: 1,
29574heading: true,
29575suppressResidualVisibleScan: true,
29576matches: (url) => MIGAKU_MARKETING_HOSTS.has(url.hostname.toLowerCase())
29577},
29578{
29579id: "yomuyomu-reader-parser",
29580roots: [YOMUYOMU_READER_ROOTS],
29581exclude: COMMON_EXCLUDE,
29582allowUiText: true,
29583minLength: 1,
29584nonDestructive: true,
29585includeGenericPageText: true,
29586matches: (url) => YOMUYOMU_HOSTS.has(url.hostname.toLowerCase())
29587},
29588{
29589id: JPDB_PARSER_ID,
29590roots: [
29591".subsection-spelling ruby.v",
29592".result.vocabulary",
29593".result.kanji",
29594".results .result",
29595".subsection-composed-of-kanji",
29596".subsection-meanings",
29597".subsection-usages",
29598".subsection-examples",
29599".subsection-pitch-accent",
29600".review-card",
29601".answer",
29602".sentence"
29603],
29604exclude: [
29605COMMON_EXCLUDE,
29606".subsection-headword .subsection-spelling ruby.v",
29607".subsection-spelling",
29608".primary-spelling"
29609].join(","),
29610allowUiText: true,
29611minLength: 1,
29612matches: (url) => url.hostname === "jpdb.io" || url.hostname.endsWith(".jpdb.io")
29613},
29614{
29615id: "jisho-parser",
29616roots: [
29617".concept_light-representation .text",
29618".concept_light-readings .text",
29619".japanese_sentence",
29620".sentence_content",
29621".kanji_light_content",
29622".kanji-details__main-readings",
29623".kanji-details__main-meanings"
29624],
29625exclude: [
29626COMMON_EXCLUDE,
29627".furigana",
29628".concept_light-readings .furigana"
29629].join(","),
29630allowUiText: true,
29631minLength: 1,
29632matches: (url) => url.hostname === "jisho.org" || url.hostname.endsWith(".jisho.org")
29633},
29634{
29635id: "jiten-parser",
29636roots: [
29637'[lang="ja"]',
29638"blockquote",
29639".p-card",
29640".rounded-lg.overflow-hidden",
29641"main",
29642"article"
29643],
29644exclude: DICTIONARY_SITE_EXCLUDE,
29645allowUiText: true,
29646minLength: 1,
29647matches: (url) => url.hostname === "jiten.moe" || url.hostname.endsWith(".jiten.moe")
29648},
29649{
29650id: "weblio-parser",
29651roots: ["#main", "#mainContents", ".mainBlock", ".NetDicBody", ".kiji", "main", "article"],
29652exclude: DICTIONARY_SITE_EXCLUDE,
29653allowUiText: true,
29654minLength: 1,
29655matches: (url) => url.hostname === "weblio.jp" || url.hostname.endsWith(".weblio.jp")
29656},
29657{
29658id: "kotobank-parser",
29659roots: ["main", "article", ".description", ".ex.cf", ".dictype", ".articleBody"],
29660exclude: DICTIONARY_SITE_EXCLUDE,
29661allowUiText: true,
29662minLength: 1,
29663matches: (url) => url.hostname === "kotobank.jp" || url.hostname.endsWith(".kotobank.jp")
29664},
29665{
29666id: "takoboto-parser",
29667roots: ["#SearchResultList", "#results", "#main", ".result", ".entry", "main", "article"],
29668exclude: DICTIONARY_SITE_EXCLUDE,
29669allowUiText: true,
29670minLength: 1,
29671fallbackToWholePage: true,
29672matches: (url) => url.hostname === "takoboto.jp" || url.hostname.endsWith(".takoboto.jp")
29673},
29674{
29675id: "wiktionary-ja-parser",
29676roots: ["#firstHeading", "#mw-content-text .mw-parser-output"],
29677exclude: [
29678DICTIONARY_SITE_EXCLUDE,
29679".thumb"
29680].join(","),
29681allowUiText: true,
29682minLength: 1,
29683heading: true,
29684matches: (url) => url.hostname === "ja.wiktionary.org" || url.hostname === "ja.m.wiktionary.org"
29685},
29686{
29687id: "luna-translator-parser",
29688roots: [".lunatranslator_clickword", ".lunatranslator_text_all", ".origin"],
29689matches: (url) => url.protocol === "file:" && /LunaTranslator.*(?:mainui|transhist)\.html/i.test(decodeURIComponent(url.pathname))
29690},
29691{
29692id: "texthooker-parser",
29693roots: ["#textlog", "main", ".textline", ".line_box", ".my-2.cursor-pointer", "p"],
29694matches: (url) => /^(anacreondjt\.gitlab\.io|learnjapanese\.moe)$/.test(url.hostname) || url.hostname === "renji-xd.github.io" || /\/texthooker\/?$/.test(url.pathname)
29695},
29696{
29697id: "exstatic-parser",
29698roots: [".sentence-entry", "#entry_holder"],
29699matches: (url) => url.hostname === "kamwithk.github.io" && url.pathname.endsWith("/exSTATic/tracker.html")
29700},
29701{
29702id: "readwok-parser",
29703roots: ['div[class*="styles_paragraph_"]', 'div[class*="styles_reader_"]'],
29704matches: (url) => url.hostname === "app.readwok.com"
29705},
29706{
29707id: "ttsu-parser",
29708roots: ["div.book-content", "div.book-content-container", "#book-content"],
29709matches: (url) => url.hostname === "reader.ttsu.app"
29710},
29711{
29712id: "tadoku-parser",
29713roots: [
29714".bd-title h1",
29715".bd-desc-jp",
29716".bd-author",
29717".book-viewer",
29718".book-reader",
29719".tadoku-book",
29720"main article"
29721],
29722exclude: [
29723COMMON_EXCLUDE,
29724".bd-desc-en"
29725].join(","),
29726allowUiText: true,
29727minLength: 1,
29728heading: true,
29729fallbackToWholePage: true,
29730matches: (url) => url.hostname === "tadoku.org" || url.hostname.endsWith(".tadoku.org")
29731},
29732{
29733id: "youtube-live-chat-frame-parser",
29734roots: [
29735...YOUTUBE_LIVE_CHAT_TEXT_ROOTS,
29736"main",
29737'[role="main"]'
29738],
29739exclude: COMMON_EXCLUDE,
29740allowUiText: true,
29741minLength: 1,
29742includeUiChrome: true,
29743singlePassScan: true,
29744nonDestructive: true,
29745disableGenericDomScan: true,
29746suppressResidualVisibleScan: true,
29747includePassiveInteractionRoots: false,
29748scanLimit: 80,
29749matches: (url) =>
29749 isYouTubeAppHostname(url.hostname) && (url.pathname === "/live_chat" || url.pathname === "/live_chat_replay")
29750},
29751{
29752id: "youtube-comments-parser",
29753roots: [
29754"ytd-watch-metadata h1",
29755"ytd-watch-metadata #title",
29756"ytd-watch-metadata #owner ytd-channel-name yt-formatted-string",
29757"ytd-watch-metadata #owner ytd-channel-name .ytAttributedStringHost",
29758"ytd-watch-metadata #owner ytd-channel-name",
29759"ytd-watch-metadata #owner-sub-count",
29760"ytd-watch-metadata #owner #subscribe-button",
29761"ytd-watch-info-text",
29762"ytd-watch-metadata #info-strings",
29763"ytd-watch-metadata #metadata-line",
29764"ytd-watch-metadata #teaser-carousel",
29765"ytd-watch-metadata yt-video-metadata-carousel-view-model",
29766"ytd-watch-metadata yt-carousel-title-view-model",
29767"ytd-watch-metadata yt-text-carousel-item-view-model",
29768"ytd-watch-metadata .ytAttributedStringHost",
29769"ytd-watch-metadata #description-inline-expander",
29770"ytd-watch-metadata #description yt-attributed-string#attributed-snippet-text",
29771"ytd-watch-metadata #description yt-attributed-string#attributed-description-text",
29772"ytd-watch-metadata #description .yt-core-attributed-string:not(#owner-sub-count)",
29773"ytd-watch-metadata #description-text",
29774"ytd-watch-metadata ytd-text-inline-expander",
29775"ytd-watch-metadata #attributed-snippet-text",
29776"ytd-watch-metadata #attributed-description-text",
29777"ytd-watch-metadata yt-attributed-string#attributed-description-text",
29778"ytd-channel-renderer",
29779"ytd-search-sub-menu-renderer",
29780"ytd-shelf-renderer #show-more-button",
29781"ytd-shelf-renderer #expand",
29782"ytd-shelf-renderer yt-button-renderer",
29783"ytd-mini-guide-entry-renderer",
29784"ytm-slim-video-metadata-section-renderer",
29785"ytm-slim-owner-renderer",
29786"ytm-expandable-video-description-body-renderer",
29787"ytm-structured-description-content-renderer",
29788YOUTUBE_COMMENT_HEADER_ROOTS,
29789"ytd-transcript-segment-renderer",
29790"ytm-transcript-segment-renderer",
29791".ytp-ce-element .ytp-ce-video-title",
29792".ytp-ce-element .ytp-ce-playlist-title",
29793".ytp-pause-overlay .ytp-videowall-still-info-title",
29794".ytp-cards-teaser-text",
29795YOUTUBE_COMMENT_TEXT_AND_ACTION_ROOTS,
29796"ytd-watch-next-secondary-results-renderer #video-title",
29797"#secondary ytd-compact-video-renderer #video-title",
29798"#secondary ytd-compact-video-renderer",
29799"ytd-compact-video-renderer #video-title",
29800"ytd-compact-video-renderer",
29801...YOUTUBE_LIVE_CHAT_TEXT_ROOTS,
29802"ytd-live-chat-frame #show-hide-button",
29803"ytd-live-chat-frame #header",
29804"ytd-live-chat-frame #content",
29805"ytd-live-chat-frame #message",
29806"ytd-live-chat-frame #subtext",
29807"ytd-live-chat-frame .yt-core-attributed-string",
29808"ytd-live-chat-frame .yt-core-attributed-string--white-space-pre-wrap",
29809"ytd-live-chat-frame yt-formatted-string",
29810"ytd-live-chat-frame button",
29811'ytd-live-chat-frame [role="button"]',
29812"ytd-watch-next-secondary-results-renderer",
29813"ytd-rich-grid-renderer",
29814"ytd-rich-item-renderer",
29815"ytd-video-renderer",
29816"yt-lockup-view-model",
29817"yt-page-header-view-model",
29818"ytd-c4-tabbed-header-renderer",
29819"grid-shelf-view-model",
29820"ytd-shelf-renderer",
29821"ytd-reel-shelf-renderer",
29822"ytd-grid-video-renderer",
29823"ytd-playlist-panel-video-renderer",
29824"ytd-playlist-video-renderer",
29825"ytd-playlist-header-renderer",
29826"ytd-channel-renderer",
29827"ytd-grid-channel-renderer",
29828"ytm-playlist-panel-video-renderer",
29829"ytm-playlist-video-renderer",
29830"ytm-channel-list-item-renderer",
29831"ytm-compact-channel-renderer",
29832"ytm-rich-grid-renderer",
29833"ytm-video-with-context-renderer",
29834"ytm-shorts-lockup-view-model",
29835"ytm-shorts-lockup-view-model-v2",
29836"ytm-item-section-renderer"
29837],
29838exclude: YOUTUBE_TEXT_EXCLUDE,
29839allowUiText: true,
29840visibleOnly: true,
29841includeUiChrome: true,
29842nonDestructive: true,
29843includePassiveInteractionRoots: true,
29844matches: (url) => isYouTubeAppHostname(url.hostname)
29845},
29846{
29847id: "cijapanese-transcript-parser",
29848roots: [
29849".transcript",
29850'[data-tab-type="transcript"]'
29851],
29852exclude: [
29853COMMON_EXCLUDE,
29854"svg"
29855].join(","),
29856allowUiText: true,
29857minLength: 1,
29858matches: (url) => url.hostname === "cijapanese.com" || url.hostname.endsWith(".cijapanese.com")
29859},
29860{
29861id: "mokuro-parser",
29862roots: [".textBox", "#manga-panel .textBox", "#pagesContainer .textBox", ".volume-card__title"],
29863allowUiText: true,
29864minLength: 1,
29865mergeBlockFragments: true,
29866visibleOnly: false,
29867scanLimit: 80,
29868disableGenericDomScan: true,
29869includePassiveInteractionRoots: false,
29870providesTextLayer: true,
29871matches: (url) => url.hostname === "reader.mokuro.app" || url.hostname === "mokuro.moe" || url.hostname.endsWith(".mokuro.moe") || url.protocol === "file:" && /mokuro/i.test(decodeURIComponent(url.pathname))
29872},
29873{
29874id: "wikipedia-parser",
29875roots: ["#firstHeading", "#mw-content-text", ".mwe-popups-extract"],
29876exclude: [
29877COMMON_EXCLUDE
29878].join(","),
29879allowUiText: true,
29880minLength: 1,
29881heading: true,
29882matches: (url) => url.hostname === "ja.wikipedia.org" || url.hostname === "ja.m.wikipedia.org"
29883},
29884{
29885id: "satori-reader-parser",
29886roots: ["#article-content"],
29887exclude: [COMMON_EXCLUDE, ".fg", ".wpr"].join(","),
29888allowUiText: true,
29889minLength: 1,
29890matches: (url) => url.hostname.endsWith(".satorireader.com") && url.pathname.includes("/articles/")
29891},
29892{
29893id: "nhk-parser",
29894roots: [
29895"body"
29896],
29897exclude: [
29898COMMON_EXCLUDE,
29899"#loading"
29900].join(","),
29901allowUiText: true,
29902minLength: 1,
29903includeUiChrome: true,
29904fallbackToWholePage: true,
29905matches: (url) => url.hostname === "news.web.nhk" && /\/news\/easy\//.test(url.pathname) || url.protocol === "file:" && /NHK.*(?:やさしいことば|NEWS WEB EASY)|(?:やさしいことば|NEWS WEB EASY).*NHK/i.test(decodeURIComponent(url.pathname)) || /NHKやさしいことばニュース|NEWS WEB EASY/i.test(document.title)
29906},
29907{
29908id: "nhk-news-parser",
29909roots: [
29910"#main article",
29911"#main",
29912'[data-testid*="article"]'
29913],
29914exclude: [
29915COMMON_EXCLUDE
29916].join(","),
29917fallbackToWholePage: true,
29918matches: (url) => (url.hostname === "news.web.nhk" || url.hostname.endsWith(".nhk.or.jp")) && /\/news\/html\//.test(url.pathname)
29919},
29920{
29921id: "bunpro-parser",
29922roots: ["article", "div.mx-auto", '[id^="study-question-"]'],
29923matches: (url) => url.hostname === "bunpro.jp" || url.hostname.endsWith(".bunpro.jp")
29924},
29925{
29926id: "asbplayer-parser",
29927roots: [".asbplayer-offscreen", ".asbplayer-subtitles-container-bottom"],
29928matches: () => Boolean(document.querySelector(ASBPLAYER_ROOT_SELECTOR))
29929}
29930];
29931function getMatchingSiteParsers(href = window.location.href) {
29932const url = new URL(href, window.location.href);
29933return SITE_PARSER_PROFILES.filter((profile) => profile.matches(url));
29934}
29935function isBookWalkerReaderPage(href = location.href) {
29936return isBookWalkerReaderUrl(new URL(href, window.location.href));
29937}
29938function isBloomeeLandingUrl(url) {
29939return BLOOMEE_LANDING_HOSTS.has(url.hostname.toLowerCase()) && (url.pathname === "/" || url.pathname === "") && Boolean(document.querySelector(".life-top-page-wrap,.home-index,.point__itembox-headline,.cv-step,.ctaarea"));
29940}
29941function isBookWalkerStorefrontUrl(url) {
29942return BOOKWALKER_STOREFRONT_HOSTS.has(url.hostname.toLowerCase()) && !isBookWalkerReaderUrl(url);
29943}
29944function isBookWalkerReaderUrl(url) {
29945const hostname = url.hostname.toLowerCase();
29946if (!/^(?:[^.]+\.)*bookwalker\.jp$/iu.test(hostname)) return false;
29947if (hostname !== "bookwalker.jp" && hostname !== "www.bookwalker.jp") return true;
29948return Boolean(document.querySelector('canvas, #pageSliderCounter, #viewer, #renderer, #bookContainer, [id^="viewport"]'));
29949}
29950function mokuroDisplayOcrEnabled() {
29951try {
29952if (typeof localStorage === "undefined") return true;
29953const raw = localStorage.getItem("profiles");
29954if (!raw) return true;
29955const profiles = JSON.parse(raw);
29956const currentRaw = localStorage.getItem("currentProfile") ?? "";
29957let current = currentRaw;
29958try {
29959current = JSON.parse(currentRaw);
29960} catch {
29961}
29962const profile = profiles[current] ?? profiles[currentRaw] ?? Object.values(profiles)[0];
29963return profile?.displayOCR !== false;
29964} catch {
29965return true;
29966}
29967}
29968function effectiveSiteScanCollectionLimit(limit, href = window.location.href) {
29969const profiles = getMatchingSiteParsers(href);
29970return profiles.length ? effectiveScanTargetLimit(profiles, limit) : limit;
29971}
29972function collectSiteScanTargets(limit = 40, href = window.location.href, options = {}) {
29973return drainCollectionSteps(siteScanTargetSteps(limit, href, options));
29974}
29975function* siteScanTargetSteps(limit, href, options) {
29976const profiles = getMatchingSiteParsers(href);
29977if (!profiles.length) return null;
29978const context = createSiteScanContext(profiles, limit, options);
29979for (const profile of profiles) yield* profileScanTargetSteps(profile, context);
29980return siteScanResult(profiles, context.targets);
29981}
29982function drainCollectionSteps(steps) {
29983for (; ; ) {
29984const next = steps.next();
29985if (next.done) return next.value;
29986}
29987}
29988function createSiteScanContext(profiles, limit, options = {}) {
29989return {
29990effectiveLimit: effectiveScanTargetLimit(profiles, limit, options.skipTargetCount ?? 0),
29991targets: [],
29992seen: new Set(),
29993skipMirroredHosts: Boolean(options.skipMirroredHosts),
29994mirroredHeadTargetCount: Math.max(0, options.mirroredHeadTargetCount ?? 0),
29995rootQueryCache: new Map()
29996};
29997}
29998function* profileScanTargetSteps(profile, context) {
29999if (profile.id === "youtube-comments-parser") collectYouTubeSyntheticTextTargets(profile, context);
30000for (const root of queryParserRoots(profile, context.rootQueryCache)) {
30001if (!siteScanHasRoom(context)) break;
30002collectRootScanTargets(profile, root, context);
30003yield;
30004}
30005if (profile.includePassiveInteractionRoots !== false) {
30006yield* profilePassiveInteractionTargetSteps(profile, context);
30007}
30008}
30009function collectYouTubeSyntheticTextTargets(profile, context) {
30010const roots = uniqueVisibleRoots(Array.from(document.querySelectorAll(YOUTUBE_SYNTHETIC_TEXT_ROOTS)));
30011for (const root of roots) {
30012if (!siteScanHasRoom(context)) break;
30013if (context.targets.some((target) => target.parent === root)) continue;
30014const text2 = syntheticYouTubeElementText(root);
30015if (!text2 || !isTargetLanguageText(text2)) continue;
30016context.targets.push(siteScanTargetWithProfileOptions(profile, {
30017text: text2,
30018parent: root,
30019fragments: [],
30020layoutSensitive: true
30021}));
30022}
30023}
30024function syntheticYouTubeElementText(root) {
30025if (root.matches("ytd-watch-info-text")) return syntheticYouTubeWatchInfoText(root);
30026for (const text2 of [
30027root.getAttribute("aria-label"),
30028root.getAttribute("title"),
30029root.innerText,
30030root.textContent
30031]) {
30032const normalized = text2?.replace(/\s+/g, " ").trim();
30033if (normalized && isTargetLanguageText(normalized)) return normalized;
30034}
30035return "";
30036}
30037function syntheticYouTubeWatchInfoText(root) {
30038const parts = Array.from(root.querySelectorAll(YOUTUBE_WATCH_INFO_ARIA_PARTS)).map((element) => normalizedAttributeText(element, "aria-label")).filter((text22) => Boolean(text22));
30039const text2 = parts.join(" • ");
30040if (isTargetLanguageText(text2)) return text2;
30041for (const attribute of ["aria-label", "title"]) {
30042const fallback = normalizedAttributeText(root, attribute);
30043if (fallback && isTargetLanguageText(fallback)) return fallback;
30044}
30045return "";
30046}
30047function normalizedAttributeText(element, attribute) {
30048return element.getAttribute(attribute)?.replace(/\s+/g, " ").trim() ?? "";
30049}
30050function collectRootScanTargets(profile, root, context, excludeSelector = siteScanExcludeSelector(profile)) {
30051if (root instanceof HTMLCanvasElement && collectCanvasFallbackTextTarget(profile, root, context)) return;
30052const collected = collectFragmentTextTargetsIn(root, siteScanRemaining(context) + (context.skipMirroredHosts ? context.mirroredHeadTargetCount + 24 : 0), profile.visibleOnly ?? true, excludeSelector, {
30053allowUiText: true,
30054minLength: profile.minLength,
30055includeUiChrome: true,
30056includeFormChrome: true,
30057includeTabChrome: true,
30058includePlayerChrome: isYouTubeSiteParserProfile(profile),
30059includePassiveInteractions: true,
30060mergeBlockFragments: profile.mergeBlockFragments,
30061heading: profile.heading,
30062allowShortCenteredHeadings: profile.allowShortCenteredHeadings
30063});
30064for (const target of collected) {
30065if (context.skipMirroredHosts && profile.nonDestructive && scanTargetAlreadyAnnotated(target)) continue;
30066if (!addUniqueSiteScanTarget(profile, target, context)) continue;
30067if (!siteScanHasRoom(context)) break;
30068}
30069}
30070function collectCanvasFallbackTextTarget(profile, canvas, context) {
30071const text2 = canvasFallbackText(canvas);
30072if (!text2 || !isTargetLanguageText(text2)) return false;
30073context.targets.push(siteScanTargetWithProfileOptions(profile, {
30074text: text2,
30075parent: canvas,
30076fragments: [],
30077layoutSensitive: true,
30078nonDestructive: true
30079}));
30080return true;
30081}
30082function canvasFallbackText(canvas) {
30083return (canvas.textContent ?? "").replace(/\r\n?/gu, "\n").trim();
30084}
30085function* profilePassiveInteractionTargetSteps(profile, context) {
30086if (!siteScanHasRoom(context)) return;
30087for (const root of queryProfilePassiveInteractionRoots(profile)) {
30088if (!siteScanHasRoom(context)) break;
30089collectRootScanTargets(profile, root, context, siteScanPassiveInteractionExcludeSelector(profile));
30090yield;
30091}
30092}
30093function queryProfilePassiveInteractionRoots(profile) {
30094return uniqueSpecificVisibleRoots(queryWithinAnnotationScope(PASSIVE_INTERACTION_ROOTS).filter((root) => isUsefulProfilePassiveInteractionRoot(profile, root)));
30095}
30096function isUsefulProfilePassiveInteractionRoot(profile, root) {
30097const exclude = siteScanPassiveInteractionExcludeSelector(profile);
30098return isUsefulCompactJapaneseRoot(root, exclude, 1, SAFE_UI_CHROME_MAX_COMPACT_LENGTH);
30099}
30100function siteScanExcludeSelector(profile) {
30101return profile.exclude ?? COMMON_EXCLUDE;
30102}
30103function siteScanPassiveInteractionExcludeSelector(profile) {
30104return siteScanExcludeSelector(profile);
30105}
30106function addUniqueSiteScanTarget(profile, target, context) {
30107return appendAdmittedFragmentTarget(context.targets, context.seen, target, {
30108reject: (candidate) => shouldRejectProfileScanTarget(profile, candidate),
30109transform: (candidate) => siteScanTargetWithProfileOptions(profile, volatileYouTubeTargetAsPassiveMirror(profile, candidate))
30110});
30111}
30112function shouldRejectProfileScanTarget(profile, target) {
30113if (!isYouTubeSiteParserProfile(profile)) return false;
30114return targetSpansMultipleYouTubeWatchMetadataTextHosts(target);
30115}
30116function isReviewCardFrontPromptElement(element) {
30117if (!(element instanceof HTMLElement)) return false;
30118return isBunproReviewFrontPrompt(element) || isJitenStudyFrontPrompt(element) || isJpdbReviewFrontPrompt(element);
30119}
30120function volatileYouTubeTargetAsPassiveMirror(profile, target) {
30121if (!isYouTubeSiteParserProfile(profile)) return target;
30122if (!target.parent.closest(YOUTUBE_VOLATILE_WATCH_METADATA_SELECTOR)) return target;
30123return {
30124...target,
30125passiveInteraction: true,
30126nonDestructive: true,
30127fragments: target.fragments.map((fragment) => ({ ...fragment, passiveInteraction: true }))
30128};
30129}
30130function targetSpansMultipleYouTubeWatchMetadataTextHosts(target) {
30131if (!target.parent.closest("ytd-watch-metadata")) return false;
30132const hosts = new Set();
30133for (const fragment of target.fragments) {
30134const parent = fragment.node.parentElement;
30135const host = parent?.closest(YOUTUBE_STABLE_TEXT_HOST_SELECTOR);
30136if (host?.closest("ytd-watch-metadata")) hosts.add(host);
30137}
30138return hosts.size > 1;
30139}
30140function siteScanTargetWithProfileOptions(profile, target) {
30141const suppressRuby = shouldSuppressSiteScanRuby(profile, target);
30142const baseTarget = {
30143...target,
30144parserId: profile.id,
30145...profileDecoration(profile, target),
30146suppressRuby: target.suppressRuby || suppressRuby || void 0,
30147passiveInteraction: target.passiveInteraction || target.suppressRuby || suppressRuby || void 0,
30148singlePassScan: profile.singlePassScan || void 0,
30149nonDestructive: profile.nonDestructive || void 0
30150};
30151return profile.plainScan ? plainScanTarget(baseTarget) : baseTarget;
30152}
30153function isYouTubeSiteParserProfile(profile) {
30154return profile.id.startsWith("youtube-");
30155}
30156function profileDecoration(profile, target) {
30157const sealed = target.decoration ?? classifyDecoration(target.parent);
30158if (sealed === "interactive-passive" && profile.id === YOMU_HOSTED_DOCS_PARSER_ID) {
30159return { decoration: "content-ruby", decorationProfileOverride: true };
30160}
30161return { decoration: sealed, decorationProfileOverride: target.decorationProfileOverride };
30162}
30163function shouldSuppressSiteScanRuby(profile, target) {
30164if (profile.id === BOOKWALKER_READER_PARSER_ID) return isBookWalkerReaderPassiveChromeTarget(target.parent);
30165if (profile.id === JPDB_PARSER_ID) return isJpdbReviewPromptTarget(target.parent, target.text);
30166if (profile.id === "jiten-parser") return isJitenStudyPromptTarget(target.parent, target.text);
30167return false;
30168}
30169function isBookWalkerReaderPassiveChromeTarget(parent) {
30170return Boolean(parent.closest(PASSIVE_INTERACTION_ROOTS) || parent.closest(BOOKWALKER_READER_SETTINGS_SELECTOR));
30171}
30172function isJpdbReviewPromptTarget(parent, text2) {
30173if (location.hostname !== "jpdb.io" || !location.pathname.startsWith("/review")) return false;
30174if (compactTextLength(text2) > 18) return false;
30175const prompt = parent.closest(".review-card, .answer-box, .prompt, .spelling, .kanji, .vocabulary-spelling");
30176if (!prompt) return false;
30177return !parent.closest(".subsection-examples, .subsection-meanings, .subsection-usages, .subsection-immersion-kit");
30178}
30179function isJitenStudyPromptTarget(parent, text2) {
30180if (!isJitenStudyPath() || compactTextLength(text2) > 18) return false;
30181const prompt = parent.closest('[lang="ja"]');
30182if (!prompt || !prompt.classList.contains("font-noto-sans")) return false;
30183if (!hasPromptTextSizeClass(prompt)) return false;
30184return Boolean(prompt.closest(".flex.items-center.justify-center"));
30185}
30186function isJitenStudyPath() {
30187return (location.hostname === "jiten.moe" || location.hostname.endsWith(".jiten.moe")) && location.pathname.startsWith("/srs/study");
30188}
30189function hasPromptTextSizeClass(element) {
30190return Array.from(element.classList).some(
30191(className) => className === "text-4xl" || className === "text-5xl" || className === "text-6xl" || className.endsWith(":text-4xl") || className.endsWith(":text-5xl") || className.endsWith(":text-6xl")
30192);
30193}
30194function compactTextLength(text2) {
30195return text2.replace(/\s+/g, "").length;
30196}
30197function plainScanTarget(target) {
30198return {
30199...target,
30200layoutSensitive: true,
30201fragments: target.fragments.map((fragment) => ({
30202...fragment,
30203layoutSensitive: true
30204}))
30205};
30206}
30207function siteScanRemaining(context) {
30208return context.effectiveLimit - context.targets.length;
30209}
30210function siteScanHasRoom(context) {
30211return siteScanRemaining(context) > 0;
30212}
30213function siteScanResult(profiles, targets) {
30214if (targets.length) return targets;
30215return profiles.some((profile) => profile.id !== "asbplayer-parser") ? [] : null;
30216}
30217function collectScanTargetsInSteps(limit = DEFAULT_SCAN_TARGET_LIMIT, href = window.location.href, options = {}) {
30218return scanTargetCollectionSteps(limit, href, options);
30219}
30220const DEFERRED_SHADOW_SCAN_MAX_ROUNDS = 8;
30221function* scanTargetCollectionSteps(limit, href, options) {
30222const skipTargetCount = Math.max(0, Math.floor(options.skipTargetCount ?? 0));
30223const collectionLimit = limit + skipTargetCount;
30224const matchingProfiles = getMatchingSiteParsers(href);
30225const targets = yield* scanTargetPhaseSteps(collectionLimit, href, options);
30226const withDeferred = yield* withDeferredShadowScanTargets(
30227targets,
30228effectiveScanTargetLimit(matchingProfiles, collectionLimit, skipTargetCount),
30229matchingProfiles
30230);
30231const eligible = options.skipTarget ? withDeferred.filter((target) => !options.skipTarget(target)) : withDeferred;
30232return eligible.slice(0, limit);
30233}
30234function* withDeferredShadowScanTargets(baseTargets, effectiveLimit, profiles) {
30235let targets = baseTargets;
30236const nonDestructive = profiles.some((profile) => profile.nonDestructive);
30237for (let round = 0; round < DEFERRED_SHADOW_SCAN_MAX_ROUNDS; round += 1) {
30238const remaining = effectiveLimit - targets.length;
30239const hosts = drainDepthCappedShadowHosts();
30240if (!hosts.length || remaining <= 0) break;
30241yield;
30242const seen = seenTextNodes(targets);
30243const collected = [];
30244for (const host of hosts) {
30245if (collected.length >= remaining) break;
30246const hostTargets = collectFragmentTextTargetsIn(host, remaining - collected.length, true, residualVisibleJapaneseExcludeSelector(profiles), {
30247allowUiText: true,
30248includeUiChrome: true,
30249includeFormChrome: true,
30250includeTabChrome: true,
30251includePassiveInteractions: true,
30252heading: true,
30253minLength: 1
30254}).filter((target) => target.fragments.every((fragment) => !seen.has(fragment.node)));
30255collected.push(...hostTargets);
30256}
30257if (!collected.length) continue;
30258targets = [...targets, ...markTargetsPassive(collected, { nonDestructive })];
30259}
30260return targets;
30261}
30262function* scanTargetPhaseSteps(limit, href, options) {
30263const matchingProfiles = getMatchingSiteParsers(href);
30264const effectiveLimit = matchingProfiles.length ? effectiveScanTargetLimit(matchingProfiles, limit, options.skipTargetCount ?? 0) : limit;
30265const profilePhaseLimit = profilePhaseTargetLimit(matchingProfiles, effectiveLimit);
30266const siteTargets = yield* completeSiteScanTargetSteps(matchingProfiles, profilePhaseLimit, href, options);
30267const baseTargets = siteTargets ?? [];
30268if (matchingProfiles.some((profile) => profile.disableGenericDomScan)) {
30269if (matchingProfiles.some((profile) => profile.suppressResidualVisibleScan)) {
30270return baseTargets;
30271}
30272yield;
30273const residualTargets = collectResidualVisibleJapaneseTargets(
30274effectiveLimit - baseTargets.length,
30275baseTargets,
30276matchingProfiles,
30277options
30278);
30279return residualTargets.length ? [...baseTargets, ...markTargetsPassive(residualTargets, { nonDestructive: matchingProfiles.some((profile) => profile.nonDestructive) })] : baseTargets;
30280}
30281yield;
30282const profileUiChromeTargets = collectProfileSafeUiChromeTargets(profilePhaseLimit - baseTargets.length, baseTargets, matchingProfiles.length > 0, matchingProfiles, options);
30283if (siteTargets && !hasGenericPageTextFallback(matchingProfiles)) {
30284const profileTargets = [...baseTargets, ...profileUiChromeTargets];
30285if (matchingProfiles.some((profile) => profile.suppressResidualVisibleScan)) return profileTargets;
30286yield;
30287const residualTargets = collectResidualVisibleJapaneseTargets(
30288effectiveLimit - profileTargets.length,
30289profileTargets,
30290matchingProfiles,
30291options
30292);
30293return residualTargets.length ? [...profileTargets, ...markTargetsPassive(residualTargets, { nonDestructive: matchingProfiles.some((profile) => profile.nonDestructive) })] : profileTargets;
30294}
30295yield;
30296const genericPhaseRemaining = effectiveLimit - baseTargets.length - profileUiChromeTargets.length;
30297const uiChromeReserve = genericUiChromeTargetLimit(genericPhaseRemaining);
30298const genericTargets = collectGenericProseTargets(
30299genericPhaseRemaining - uiChromeReserve,
30300[...baseTargets, ...profileUiChromeTargets],
30301options
30302);
30303yield;
30304const uiChromeTargets = collectSafeUiChromeTargets(
30305genericPhaseRemaining - genericTargets.length,
30306[...baseTargets, ...profileUiChromeTargets, ...genericTargets],
30307options
30308);
30309yield;
30310const supplementalGenericTargets = collectGenericProseTargets(
30311genericPhaseRemaining - genericTargets.length - uiChromeTargets.length,
30312[...baseTargets, ...profileUiChromeTargets, ...genericTargets, ...uiChromeTargets],
30313options
30314);
30315const collectedTargets = [
30316...baseTargets,
30317...profileUiChromeTargets,
30318...genericTargets,
30319...uiChromeTargets,
30320...supplementalGenericTargets
30321];
30322yield;
30323const targetsWithResidual = withResidualVisibleJapaneseTargets(collectedTargets, effectiveLimit, matchingProfiles, options);
30324if (targetsWithResidual.length) return targetsWithResidual;
30325yield;
30326const broadTargets = collectWholePageScanTargets(effectiveLimit);
30327const broadWithResidual = withResidualVisibleJapaneseTargets(broadTargets, effectiveLimit, matchingProfiles, options);
30328if (broadWithResidual.length) return broadWithResidual;
30329if (annotationScopeActive()) return [];
30330return collectVisibleTextTargets(effectiveLimit);
30331}
30332function markTargetsPassive(targets, options = {}) {
30333return targets.map((target) => ({
30334...target,
30335passiveInteraction: true,
30336nonDestructive: options.nonDestructive || void 0,
30337..."fragments" in target ? {
30338fragments: target.fragments.map((fragment) => ({
30339...fragment,
30340passiveInteraction: true
30341}))
30342} : {}
30343}));
30344}
30345function profilePhaseTargetLimit(profiles, effectiveLimit) {
30346if (!profiles.length || !Number.isFinite(effectiveLimit) || effectiveLimit < PROFILE_PHASE_GENERIC_RESERVE_THRESHOLD || profiles.some((profile) => profile.disableGenericDomScan || profile.suppressResidualVisibleScan)) return effectiveLimit;
30347const reserve = Math.min(
30348PROFILE_PHASE_GENERIC_RESERVE_MAX,
30349Math.max(1, Math.floor(effectiveLimit * PROFILE_PHASE_GENERIC_RESERVE_RATIO))
30350);
30351return Math.max(1, effectiveLimit - reserve);
30352}
30353function genericUiChromeTargetLimit(remaining) {
30354if (remaining <= 0) return 0;
30355if (!Number.isFinite(remaining)) return GENERIC_UI_CHROME_TARGET_MAX;
30356return Math.min(GENERIC_UI_CHROME_TARGET_MAX, Math.max(1, Math.ceil(remaining * 0.25)));
30357}
30358function withResidualVisibleJapaneseTargets(targets, effectiveLimit, profiles, options = {}) {
30359const remaining = effectiveLimit - targets.length;
30360if (remaining <= 0) return targets;
30361const residual = collectResidualVisibleJapaneseTargets(remaining, targets, profiles, options);
30362return residual.length ? [...targets, ...residual] : targets;
30363}
30364function collectResidualVisibleJapaneseTargets(limit, existingTargets, profiles, options = {}) {
30365if (limit <= 0 || !document.body) return [];
30366const collection = {
30367targets: [],
30368seen: seenTextNodes(existingTargets),
30369limit
30370};
30371const candidateLimit = Number.isFinite(limit) ? Math.max(limit, existingTargets.length + (options.skipMirroredHosts ? options.mirroredHeadTargetCount ?? 0 : 0) + limit + 24) : limit;
30372const nonDestructiveProfile = profiles.some((profile) => profile.nonDestructive);
30373const collected = scanScopeRoots().flatMap((root) => collectFragmentTextTargetsIn(root, candidateLimit, true, residualVisibleJapaneseExcludeSelector(profiles), {
30374allowUiText: true,
30375includeUiChrome: true,
30376includeFormChrome: true,
30377includeTabChrome: true,
30378includePlayerChrome: isYouTubeHost(),
30379includePassiveInteractions: true,
30380heading: true,
30381allowShortCenteredHeadings: nonDestructiveProfile,
30382minLength: 1
30383}));
30384for (const target of collected) {
30385if (options.skipMirroredHosts && scanTargetAlreadyAnnotated(target)) continue;
30386appendResidualVisibleTarget(collection.targets, collection.seen, {
30387...target,
30388parserId: RESIDUAL_VISIBLE_JAPANESE_PARSER_ID
30389});
30390if (genericProseCollectionFull(collection)) break;
30391}
30392return collection.targets;
30393}
30394function residualVisibleJapaneseExcludeSelector(profiles) {
30395const entries2 = [COMMON_EXCLUDE, "ruby"];
30396if (profiles.some(isYouTubeSiteParserProfile)) {
30397entries2.push(...YT_PLAYER_CHROME_EXCLUDE_ENTRIES);
30398}
30399if (profiles.some((profile) => profile.id === JPDB_PARSER_ID)) {
30400entries2.push(".subsection-spelling.with-furigana > :not(.primary-spelling)");
30401}
30402return entries2.join(",");
30403}
30404function* completeSiteScanTargetSteps(profiles, limit, href, options = {}) {
30405if (!profiles.length) return null;
30406const siteTargets = (yield* siteScanTargetSteps(limit, href, options)) ?? [];
30407if (siteTargets.length) return siteTargets;
30408if (hasWholePageFallback(profiles)) {
30409yield;
30410const broadTargets = collectWholePageScanTargets(limit);
30411if (broadTargets.length) return broadTargets;
30412}
30413return hasGenericPageTextFallback(profiles) ? null : siteTargets;
30414}
30415function hasGenericPageTextFallback(profiles) {
30416return profiles.some((profile) => profile.includeGenericPageText);
30417}
30418function hasWholePageFallback(profiles) {
30419return profiles.some((profile) => profile.fallbackToWholePage);
30420}
30421function effectiveScanTargetLimit(profiles, requestedLimit, profileLimitOff
30421set = 0) {
30422const profileLimit = profiles.reduce(
30423(limit, profile) => Math.min(limit, profile.scanLimit === void 0 ? limit : profile.scanLimit + profileLimitOffset),
30424requestedLimit
30425);
30426return Math.max(1, profileLimit);
30427}
30428function collectWholePageScanTargets(limit) {
30429const targets = scanScopeRoots().flatMap((root) => collectFragmentTextTargetsIn(root, limit, true, "", {
30430allowUiText: true,
30431includeUiChrome: true,
30432includeFormChrome: true,
30433includeTabChrome: true,
30434includePassiveInteractions: true,
30435heading: true,
30436minLength: 1
30437})).slice(0, limit);
30438return targets.map((target) => ({ ...target, parserId: target.parserId ?? "whole-page-parser" }));
30439}
30440function collectGenericProseTargets(limit, existingTargets = [], options = {}) {
30441const roots = genericProseRoots();
30442const collection = createGenericProseCollection(limit, existingTargets, options);
30443for (const root of roots) {
30444collectFragmentTargetsFromRoot(root, collection, GENERIC_PROSE_EXCLUDE, { minLength: 2 });
30445if (genericProseCollectionFull(collection)) break;
30446}
30447return collection.targets;
30448}
30449function createGenericProseCollection(limit, existingTargets, options) {
30450return {
30451targets: [],
30452seen: seenTextNodes(existingTargets),
30453limit,
30454skipMirroredHosts: options.skipMirroredHosts,
30455candidateHeadroom: existingTargets.length + (options.skipMirroredHosts ? options.mirroredHeadTargetCount ?? 0 : 0)
30456};
30457}
30458function seenTextNodes(targets) {
30459return new Set(targets.flatMap((target) => {
30460if ("fragments" in target) return textNodesForFragmentTarget(target);
30461return [target.node];
30462}));
30463}
30464function collectProfileSafeUiChromeTargets(limit, existingTargets = [], enabled = true, profiles = [], options = {}) {
30465if (!enabled || limit <= 0) return [];
30466const collection = createGenericProseCollection(limit, existingTargets, options);
30467const extraExclude = profiles.map((p) => p.exclude).filter(Boolean).join(",");
30468const parserId = profiles.length === 1 ? profiles[0].id : "safe-ui-chrome-parser";
30469const nonDestructive = profiles.some((profile) => profile.nonDestructive);
30470collectSafeChromeRootTargets(profileSafeUiChromeRoots(extraExclude), collection, "ui", extraExclude, parserId, nonDestructive);
30471collectSafeChromeRootTargets(safeFormChromeRoots(), collection, "form", "", parserId, nonDestructive);
30472collectSafeFormControlTextTargets(collection, extraExclude);
30473return collection.targets;
30474}
30475function collectSafeUiChromeTargets(limit, existingTargets = [], options = {}) {
30476if (limit <= 0) return [];
30477const collection = createGenericProseCollection(limit, existingTargets, options);
30478collectSafeChromeRootTargets(safeUiChromeRoots(), collection, "ui");
30479collectSafeChromeRootTargets(safeFormChromeRoots(), collection, "form");
30480collectSafeFormControlTextTargets(collection);
30481return collection.targets;
30482}
30483function collectSafeFormControlTextTargets(collection, extraExclude = "") {
30484const targets = scanScopeRoots().flatMap((root) => collectFormControlTextTargetsIn(root, genericProseRemaining(collection), true, {
30485excludeSelector: extraExclude
30486}));
30487for (const target of targets) {
30488collection.targets.push(target);
30489if (genericProseCollectionFull(collection)) break;
30490}
30491}
30492function collectSafeChromeRootTargets(roots, collection, kind, extraExclude = "", parserId = "safe-ui-chrome-parser", nonDestructive = false) {
30493for (const root of roots) {
30494if (kind === "ui") {
30495const baseExclude = safeUiChromeExcludeForRoot(root);
30496collectFragmentTargetsFromRoot(root, collection, extraExclude ? `${baseExclude},${extraExclude}` : baseExclude, {
30497allowUiText: true,
30498includeUiChrome: true,
30499includeTabChrome: true,
30500includePassiveInteractions: true,
30501heading: true,
30502allowShortCenteredHeadings: true,
30503minLength: 1
30504}, parserId, nonDestructive);
30505} else {
30506collectFragmentTargetsFromRoot(root, collection, SAFE_FORM_CHROME_EXCLUDE, {
30507allowUiText: true,
30508includeFormChrome: true,
30509includePassiveInteractions: true,
30510heading: true,
30511minLength: 1
30512}, parserId, nonDestructive);
30513}
30514if (genericProseCollectionFull(collection)) break;
30515}
30516}
30517function safeUiChromeRoots() {
30518return uniqueSpecificVisibleRoots(queryWithinAnnotationScope(SAFE_UI_CHROME_ROOTS).filter((root) => isUsefulSafeUiChromeRoot(root)));
30519}
30520function profileSafeUiChromeRoots(extraExclude = "") {
30521const roots = queryWithinAnnotationScope(PROFILE_SAFE_UI_CHROME_ROOTS).filter((root) => isUsefulSafeUiChromeRoot(root));
30522if (!extraExclude) return uniqueSpecificVisibleRoots(roots);
30523return uniqueSpecificVisibleRoots(roots.filter((root) => !root.closest(extraExclude)));
30524}
30525function safeUiChromeExcludeForRoot(root) {
30526return root.matches(SAFE_UI_CHROME_ARIA_MENU_ROOTS) || root.matches('[role="menubar"],[class*="menubar" i],[id*="menubar" i]') ? SAFE_UI_CHROME_ARIA_MENU_EXCLUDE : SAFE_UI_CHROME_EXCLUDE;
30527}
30528function safeFormChromeRoots() {
30529return uniqueVisibleRoots(queryWithinAnnotationScope(SAFE_FORM_CHROME_ROOTS).filter((root) => isUsefulSafeFormChromeRoot(root)));
30530}
30531function collectFragmentTargetsFromRoot(root, collection, exclude, options, passiveParserId, nonDestructive = false) {
30532const remaining = genericProseRemaining(collection);
30533const collected = collectFragmentTextTargetsIn(root, collection.skipMirroredHosts ? remaining + (collection.candidateHeadroom ?? 0) + 24 : remaining, true, exclude, options);
30534for (const target of collected) {
30535if (collection.skipMirroredHosts && scanTargetAlreadyAnnotated(target)) continue;
30536appendGenericProseTarget(collection.targets, collection.seen, passiveParserId ? {
30537...target,
30538parserId: passiveParserId,
30539passiveInteraction: true,
30540nonDestructive: nonDestructive || void 0
30541} : target);
30542if (genericProseCollectionFull(collection)) break;
30543}
30544}
30545function genericProseRoots() {
30546return queryWithinAnnotationScope(GENERIC_PROSE_ROOTS).filter((root) => isUsefulGenericProseRoot(root));
30547}
30548function genericProseRemaining(collection) {
30549return Math.max(0, collection.limit - collection.targets.length);
30550}
30551function genericProseCollectionFull(collection) {
30552return genericProseRemaining(collection) <= 0;
30553}
30554function appendGenericProseTarget(targets, seen, target, options) {
30555const admissionOptions = { defaultParserId: "generic-prose-parser" };
30556return appendAdmittedFragmentTarget(targets, seen, target, admissionOptions);
30557}
30558function appendResidualVisibleTarget(targets, seen, target) {
30559const nodes = textNodesForFragmentTarget(target);
30560if (!nodes.some((node) => seen.has(node))) {
30561appendGenericProseTarget(targets, seen, target);
30562return;
30563}
30564for (const fragments of unseenFragmentRuns(target, seen)) {
30565const parent = fragments[0]?.node.parentElement;
30566if (!parent) continue;
30567const text2 = fragments.map((fragment) => fragment.node.data.slice(fragment.start, fragment.end)).join("");
30568if (!isTargetLanguageText(text2)) continue;
30569const decoration = classifyDecoration(parent);
30570if (decoration === "skip") continue;
30571appendAdmittedFragmentTarget(targets, seen, {
30572...target,
30573text: text2,
30574parent,
30575fragments,
30576decoration,
30577suppressRuby: decoration === "interactive-passive" || void 0,
30578passiveInteraction: true,
30579proseWrap: false
30580}, { defaultParserId: RESIDUAL_VISIBLE_JAPANESE_PARSER_ID });
30581}
30582}
30583function unseenFragmentRuns(target, seen) {
30584const runs = [];
30585let current = [];
30586const flush = () => {
30587const trimmed = trimFragmentRun(current);
30588if (trimmed.length) runs.push(trimmed);
30589current = [];
30590};
30591for (const fragment of target.fragments) {
30592if (seen.has(fragment.node)) flush();
30593else current.push({ ...fragment });
30594}
30595flush();
30596return runs;
30597}
30598function trimFragmentRun(fragments) {
30599while (fragments.length) {
30600const first = fragments[0];
30601const value = first.node.data.slice(first.start, first.end);
30602first.start += value.match(/^\s*/u)?.[0].length ?? 0;
30603if (first.start < first.end) break;
30604fragments.shift();
30605}
30606while (fragments.length) {
30607const last = fragments[fragments.length - 1];
30608const value = last.node.data.slice(last.start, last.end);
30609last.end -= value.match(/\s*$/u)?.[0].length ?? 0;
30610if (last.start < last.end) break;
30611fragments.pop();
30612}
30613return fragments;
30614}
30615function appendAdmittedFragmentTarget(targets, seen, target, options = {}) {
30616const nodes = textNodesForFragmentTarget(target);
30617if (!nodes.length || nodes.some((node) => seen.has(node))) return false;
30618if (options.reject?.(target)) return false;
30619if (isResidualReaderParticleTarget(target)) return false;
30620if (isResidualJpdbAlternateSpellingTarget(target)) return false;
30621nodes.forEach((node) => seen.add(node));
30622const admittedTarget = options.transform ? options.transform(target) : { ...target, parserId: target.parserId ?? options.defaultParserId };
30623targets.push(admittedTarget);
30624return true;
30625}
30626function isResidualReaderParticleTarget(target) {
30627const text2 = target.text.replace(/\s+/g, "");
30628return /^[のはをがにでへもとやかねよな]$/u.test(text2) && Boolean(target.parent.querySelector(".jpdb-reader-word"));
30629}
30630function isResidualJpdbAlternateSpellingTarget(target) {
30631const spelling = target.parent.closest(".subsection-spelling.with-furigana");
30632return Boolean(spelling && !target.parent.closest(".primary-spelling"));
30633}
30634function textNodesForFragmentTarget(target) {
30635const nodes = [];
30636for (const fragment of target.fragments) {
30637if (!nodes.includes(fragment.node)) nodes.push(fragment.node);
30638}
30639return nodes;
30640}
30641function isUsefulGenericProseRoot(root) {
30642if (root.closest(GENERIC_PROSE_EXCLUDE)) return false;
30643const text2 = compactRootText(root);
30644if (text2.length < 12) return false;
30645return isTargetLanguageText(text2);
30646}
30647function isUsefulSafeUiChromeRoot(root) {
30648return isUsefulCompactJapaneseRoot(root, safeUiChromeExcludeForRoot(root), 2, SAFE_UI_CHROME_MAX_COMPACT_LENGTH);
30649}
30650function isUsefulSafeFormChromeRoot(root) {
30651return isUsefulCompactJapaneseRoot(root, SAFE_FORM_CHROME_EXCLUDE, 1, SAFE_FORM_CHROME_MAX_COMPACT_LENGTH);
30652}
30653function isUsefulCompactJapaneseRoot(root, exclude, minLength, maxLength) {
30654if (exclude && (safeElementMatches(root, exclude) || root.closest(exclude))) return false;
30655if (!isVisibleSafeUiChromeRoot(root)) return false;
30656const text2 = compactRootText(root);
30657return isTargetLanguageText(text2) && text2.length >= minLength && text2.length <= maxLength;
30658}
30659function compactRootText(root) {
30660return root.textContent?.replace(/\s+/g, "").trim() ?? "";
30661}
30662function isVisibleSafeUiChromeRoot(root) {
30663const rect = root.getBoundingClientRect();
30664if (rect.width <= 0 || rect.height <= 0 || rect.bottom < 0 || rect.top > window.innerHeight) return false;
30665const style = getComputedStyle(root);
30666return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0;
30667}
30668function queryParserRoots(profile, rootQueryCache) {
30669const roots = [];
30670for (const selector of profile.roots) {
30671roots.push(...queryRootsBySelector(selector, rootQueryCache));
30672}
30673const unique = uniqueVisibleRoots(roots);
30674return profile.id === "mokuro-parser" ? nearestMokuroRoots(unique) : unique;
30675}
30676function queryRootsBySelector(selector, rootQueryCache) {
30677const cached = rootQueryCache?.get(selector);
30678if (cached) return cached;
30679const elements = queryWithinAnnotationScope(selector);
30680rootQueryCache?.set(selector, elements);
30681return elements;
30682}
30683function nearestMokuroRoots(roots) {
30684const margin = mokuroScanViewportMargin();
30685return roots.filter((root) => isElementNearViewport(root, margin)).sort((a, b) => elementViewportDistance(a) - elementViewportDistance(b) || documentPositionOrder(a, b)).slice(0, MOKURO_SCAN_ROOT_LIMIT);
30686}
30687function mokuroScanViewportMargin() {
30688const width = window.innerWidth || document.documentElement.clientWidth || 0;
30689const height = window.innerHeight || document.documentElement.clientHeight || 0;
30690return Math.max(width, height) * MOKURO_SCAN_MARGIN_VIEWPORTS;
30691}
30692function isElementNearViewport(element, margin) {
30693const rect = element.getBoundingClientRect();
30694if (rect.width <= 0 || rect.height <= 0) return false;
30695return rect.bottom >= -margin && rect.top <= window.innerHeight + margin && rect.right >= -margin && rect.left <= window.innerWidth + margin;
30696}
30697function elementViewportDistance(element) {
30698const rect = element.getBoundingClientRect();
30699if (rect.width <= 0 || rect.height <= 0) return Number.POSITIVE_INFINITY;
30700const dx = rect.right < 0 ? -rect.right : rect.left > window.innerWidth ? rect.left - window.innerWidth : 0;
30701const dy = rect.bottom < 0 ? -rect.bottom : rect.top > window.innerHeight ? rect.top - window.innerHeight : 0;
30702return Math.hypot(dx, dy);
30703}
30704function uniqueVisibleRoots(roots) {
30705const unique = [];
30706for (const root of roots) {
30707if (unique.some((existing) => existing === root || existing.contains(root))) continue;
30708unique.push(root);
30709}
30710return unique;
30711}
30712function uniqueSpecificVisibleRoots(roots) {
30713const unique = [];
30714for (const root of [...roots].sort((a, b) => elementDepth(b) - elementDepth(a))) {
30715if (unique.some((existing) => existing === root || existing.contains(root) || root.contains(existing))) continue;
30716unique.push(root);
30717}
30718return unique.sort((a, b) => documentPositionOrder(a, b));
30719}
30720function elementDepth(element) {
30721let depth = 0;
30722for (let current = element.parentElement; current; current = current.parentElement) depth += 1;
30723return depth;
30724}
30725function documentPositionOrder(a, b) {
30726if (a === b) return 0;
30727return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1;
30728}
30729function safeElementMatches(element, selector) {
30730try {
30731return element.matches(selector);
30732} catch {
30733return false;
30734}
30735}
30736const renderJitenDefinitionSource = (...args) => yomuJitenCompanion()?.renderJitenDefinitionSource?.(...args) ?? "";
30737class DisabledWanikaniSourceController {
30738installDefinitionMounts() {
30739}
30740installKanjiMount() {
30741}
30742}
30743const CompanionBackedWanikaniSourceController = class {
30744constructor(lookup, getSettings, sourceAttributes, onRendered) {
30745const Controller = yomuWanikaniCompanion()?.WanikaniSourceController;
30746return Controller ? new Controller(lookup, getSettings, sourceAttributes, onRendered) : new DisabledWanikaniSourceController();
30747}
30748};
30749const renderWanikaniDefinitionMount = (card, settings2, sourceAttributes) => yomuWanikaniCompanion()?.renderWanikaniDefinitionMount(card, settings2, sourceAttributes) ?? "";
30750function noComponent(reason) {
30751return { availability: "none", reason };
30752}
30753function unsupportedCapabilities() {
30754return {
30755supported: false,
30756reason: "unsupported-target",
30757text: noComponent("unsupported-target"),
30758audio: noComponent("unsupported-target"),
30759image: noComponent("unsupported-target"),
30760corpus: "limited"
30761};
30762}
30763const IMMERSION_KIT_EXAMPLE_SOURCE_ID = "immersion-kit";
30764function immersionKitCapabilitiesFor(targetLanguage) {
30765const base = targetLanguage.trim().toLowerCase().split(/[-_]/u)[0];
30766if (base !== "ja") return unsupportedCapabilities();
30767return {
30768supported: true,
30769text: { availability: "available", scope: "sentence" },
30770audio: { availability: "available", scope: "sentence" },
30771image: { availability: "available", scope: "sentence" },
30772corpus: "ample"
30773};
30774}
30775function installProviderExampleBehaviors(root, options) {
30776installProviderTranslationReveal(root);
30777const translations = Array.from(root.querySelectorAll("[data-provider-example-translation]"));
30778if (!options.blurTranslations) translations.forEach(revealProviderTranslation);
30779translations.filter((translation) => translation.dataset.providerTranslationPending === "true").forEach((translation) => hydrateProviderTranslation(root, translation, options));
30780}
30781function installProviderTranslationReveal(root) {
30782if (root.dataset.providerExampleBehaviorsInstalled === "true") return;
30783root.dataset.providerExampleBehaviorsInstalled = "true";
30784root.addEventListener("click", (event) => {
30785const translation = event.target?.closest("[data-provider-example-translation]");
30786if (translation && root.contains(translation)) revealProviderTranslation(translation);
30787});
30788root.addEventListener("keydown", (event) => {
30789if (event.key !== "Enter" && event.key !== " ") return;
30790const translation = event.target?.closest("[data-provider-example-translation]");
30791if (!translation || !root.contains(translation)) return;
30792event.preventDefault();
30793revealProviderTranslation(translation);
30794});
30795}
30796function hydrateProviderTranslation(root, translation, options) {
30797if (translation.dataset.providerTranslationLoading === "true") return;
30798const sentence = translation.dataset.providerTranslationSentence?.trim() ?? "";
30799if (!sentence) return;
30800translation.dataset.providerTranslationLoading = "true";
30801void options.translate(sentence, options.outputLanguage).then((translated) => {
30802if (!translated.trim() || !translation.isConnected || !isCurrentProviderRoot(root, options)) return;
30803translation.textContent = translated.trim();
30804translation.lang = options.outputLanguage;
30805translation.hidden = false;
30806delete translation.dataset.providerTranslationPending;
30807delete translation.dataset.providerTranslationSentence;
30808}).catch(() => void 0).finally(() => {
30809delete translation.dataset.providerTranslationLoading;
30810});
30811}
30812function isCurrentProviderRoot(root, options) {
30813return options.isCurrentRoot ? options.isCurrentRoot(root) : root.isConnected;
30814}
30815function revealProviderTranslation(translation) {
30816delete translation.dataset.providerTranslationBlurred;
30817translation.removeAttribute("role");
30818translation.removeAttribute("tabindex");
30819translation.removeAttribute("aria-label");
30820}
30821function exampleSourceStateKey(sourceId) {
30822return `definition-source:${sourceId}:examples`;
30823}
30824function renderExampleSourceRow(options) {
30825const { collection, capabilities } = options;
30826const availability = collection.availability;
30827const status = statusChip(options);
30828const components = componentAttribute(capabilities);
30829const open = availability === "loaded";
30830return `
30831        <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-example-source-card"
30832            data-example-source="${escapeHtml(options.sourceId)}"
30833            data-availability="${availability}"
30834            data-example-components="${escapeHtml(components)}"
30835            data-example-target="${escapeHtml(options.targetLanguage)}"
30836            ${options.sourceAttributes(exampleSourceStateKey(options.sourceId), open)}>
30837            <summary class="jpdb-reader-local-title jpdb-reader-example-summary" data-jpdb-reader-surface-ignore>
30838                <span class="jpdb-reader-example-source">${escapeHtml(options.sourceName)}</span>
30839                <span class="jpdb-reader-source-status jpdb-reader-example-count" data-example-status>${escapeHtml(status)}</span>
30840            </summary>
30841            <div class="jpdb-reader-local-glossary">
30842                ${renderRowBody(options)}
30843            </div>
30844        </details>
30845    `;
30846}
30847function renderRowBody(options) {
30848const { collection } = options;
30849switch (collection.availability) {
30850case "unsupported":
30851return reasonBlock(options, "unsupported-target");
30852case "empty":
30853return [
30854reasonBlock(options, "no-results"),
30855options.capabilities.corpus === "limited" ? reasonBlock(options, "limited-corpus") : ""
30856].join("");
30857case "unavailable":
30858return `${reasonBlock(options, collection.reason)}
30859                <button class="jpdb-reader-btn" type="button" data-action="retry-example-source" data-example-source-id="${escapeHtml(options.sourceId)}">${escapeHtml(uiText$1(options.interfaceLanguage, "exampleSourceRetry"))}</button>`;
30860case "loaded":
30861return `
30862                <ul class="jpdb-reader-jpdb-examples">${collection.items.map((record2) => renderExampleRecord(record2, options)).join("")}</ul>
30863                ${mediaNotices(options, collection)}
30864            `;
30865}
30866}
30867function statusChip(options) {
30868const { collection, interfaceLanguage } = options;
30869switch (collection.availability) {
30870case "loaded":
30871return String(collection.items.length);
30872case "empty":
30873return uiText$1(interfaceLanguage, "exampleSourceEmptyShort");
30874case "unsupported":
30875return uiText$1(interfaceLanguage, "exampleSourceUnsupportedShort");
30876case "unavailable":
30877return uiText$1(interfaceLanguage, "exampleSourceFailedShort");
30878}
30879}
30880function mediaNotices(options, collection) {
30881const notices = [];
30882const audio = options.capabilities.audio;
30883const playable = collection.items.some((record2) => record2.audio?.length);
30884if (audio.availability === "none") {
30885notices.push(reasonBlock(options, "no-sentence-audio-source"));
30886} else if (!playable) {
30887notices.push(reasonBlock(options, "no-licensed-audio"));
30888} else if (audio.availability === "per-item") {
30889notices.push(helpBlock("audio-per-item", uiText$1(options.interfaceLanguage, "exampleSourceAudioPerItem")));
30890}
30891if (options.capabilities.image.availability === "none") notices.push(reasonBlock(options, "no-image-source"));
30892return notices.join("");
30893}
30894function componentAttribute(capabilities) {
30895return ["text", "audio", "image"].filter((component) => capabilities[component].availability !== "none").join(",");
30896}
30897function reasonBlock(options, reason) {
30898return helpBlock(reason, reasonText(options, reason));
30899}
30900function helpBlock(reason, message) {
30901return `<p class="jpdb-reader-help" data-example-reason="${escapeHtml(reason)}">${escapeHtml(message)}</p>`;
30902}
30903function reasonText(options, reason) {
30904const language = options.interfaceLanguage;
30905switch (reason) {
30906case "unsupported-target":
30907return formatUiText(language, "exampleSourceUnsupported", {
30908language: languageName(options.targetLanguage, language)
30909});
30910case "limited-corpus":
30911return uiText$1(language, "exampleSourceLimitedCorpus");
30912case "no-results":
30913return uiText$1(language, "exampleSourceEmpty");
30914case "no-licensed-audio":
30915return uiText$1(language, "exampleSourceNoLicensedAudio");
30916case "no-sentence-audio-source":
30917return formatUiText(language, "exampleSourceNoSentenceAudio", {
30918language: languageName(options.targetLanguage, language)
30919});
30920case "no-image-source":
30921return uiText$1(language, "exampleSourceNoImage");
30922case "no-human-translation":
30923return formatUiText(language, "exampleSourceNoTranslation", {
30924language: languageName(options.outputLanguage, language)
30925});
30926case "auth":
30927case "network":
30928case "schema":
30929return uiText$1(language, "exampleSourceFailed");
30930}
30931}
30932function languageName(tag, interfaceLanguage) {
30933const locale = resolveUiLanguage$1(interfaceLanguage);
30934try {
30935return new Intl.DisplayNames([locale], { type: "language" }).of(tag) ?? tag;
30936} catch {
30937return tag;
30938}
30939}
30940function renderExampleRecord(record2, options) {
30941const audio = record2.audio?.[0];
30942return `
30943        <li class="jpdb-reader-jpdb-example" data-provider-example-id="${escapeHtml(record2.id)}">
30944            <div class="jpdb-reader-jpdb-example-row${audio ? " has-audio" : ""}">
30945                ${audio ? renderAudioButton(audio.url, options.interfaceLanguage) : ""}
30946                <div class="jpdb-reader-jpdb-example-text">
30947                    <div class="jpdb-reader-example-sentence" lang="${escapeHtml(record2.text.language)}" dir="auto" data-provider-example-sentence>${escapeHtml(record2.text.value)}</div>
30948                    ${renderTranslation(record2, options)}
30949                    ${renderProvenance(record2, options)}
30950                </div>
30951            </div>
30952        </li>
30953    `;
30954}
30955function renderTranslation(record2, options) {
30956if (!record2.translation) return reasonBlock(options, "no-human-translation");
30957const blurred = options.blurTranslations ?? false;
30958return `<div class="jpdb-reader-example-translation"
30959        lang="${escapeHtml(record2.translation.language)}"
30960        dir="auto"
30961        data-provider-example-translation
30962        data-translation-provenance="${escapeHtml(record2.translation.provenance)}"
30963        ${blurred ? 'data-provider-translation-blurred="true" role="button" tabindex="0" aria-label="' + escapeHtml(uiText$1(options.interfaceLanguage, "revealTranslation")) + '"' : ""}
30964        >${escapeHtml(record2.translation.value)}</div>`;
30965}
30966function renderProvenance(record2, options) {
30967const marks = [];
30968if (record2.translation?.provenance === "machine") marks.push(uiText$1(options.interfaceLanguage, "exampleSourceMachineTranslation"));
30969if (record2.translation && record2.translation.direct === false) marks.push(uiText$1(options.interfaceLanguage, "exampleSourceIndirectTranslation"));
30970const audioCredit = record2.audio?.[0];
30971return `<div class="jpdb-reader-example-provenance" data-example-provenance>
30972        <a href="${escapeHtml(record2.source.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(record2.source.attribution)}</a>
30973        <span data-example-licence>${escapeHtml(record2.source.licence)}</span>
30974        ${audioCredit ? `<span data-example-audio-licence>${escapeHtml(`${audioCredit.attribution} · ${audioCredit.licence.id}`)}</span>` : ""}
30975        ${marks.map((mark) => `<span data-example-translation-mark>${escapeHtml(mark)}</span>`).join("")}
30976    </div>`;
30977}
30978function renderAudioButton(url, interfaceLanguage) {
30979const label = uiText$1(interfaceLanguage, "exampleSourcePlayAudio");
30980return `<button class="jpdb-reader-icon-mini jpdb-reader-jpdb-example-audio" type="button" data-action="play-example-audio" data-example-audio-url="${escapeHtml(url)}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${speakerIcon()}</button>`;
30981}
30982const ALLOWED_LICENCE_FAMILIES = new Map([
30983["cc0", "https://creativecommons.org/publicdomain/zero/1.0/"],
30984["by", "https://creativecommons.org/licenses/by/4.0/"],
30985["by-fr", "https://creativecommons.org/licenses/by/2.0/fr/"],
30986["by-sa", "https://creativecommons.org/licenses/by-sa/4.0/"],
30987["public-domain", ""]
30988]);
30989function decideMediaLicence(raw) {
30990const stated = typeof raw === "string" ? raw.trim() : "";
30991if (!stated) return { allowed: false, withheld: "missing-licence" };
30992const family = licenceFamily(stated);
30993const url = ALLOWED_LICENCE_FAMILIES.get(family);
30994if (url !== void 0) {
30995return { allowed: true, licence: { id: stated, commercialUse: true, derivatives: true, ...url ? { url } : {} } };
30996}
30997if (family.split("-").includes("nd")) return { allowed: false, withheld: "no-derivatives" };
30998if (family.split("-").includes("nc")) return { allowed: false, withheld: "non-commercial" };
30999return { allowed: false, withheld: "unknown-licence" };
31000}
31001function licenceFamily(value) {
31002return value.toLowerCase().replace(/creative\s+commons/gu, "cc").split(/[^a-z0-9]+/u).filter((part) => part && part !== "cc" && !/^\d/u.test(part)).join("-");
31003}
31004const TATOEBA_COVERAGE = Object.freeze({
31005sq: { codes: ["sqi"], sentenceAudioRows: 0, limitedCorpus: true },
31006grc: { codes: ["grc"], sentenceAudioRows: 0, limitedCorpus: true },
31007ar: { codes: ["ara"], sentenceAudioRows: 483 },
31008yue: { codes: ["yue"], sentenceAudioRows: 1784 },
31009zh: { codes: ["cmn"], sentenceAudioRows: 5827 },
31010da: { codes: ["dan"], sentenceAudioRows: 0 },
31011nl: { codes: ["nld"], sentenceAudioRows: 9038 },
31012en: { codes: ["eng"], sentenceAudioRows: 849774 },
31013fi: { codes: ["fin"], sentenceAudioRows: 4253 },
31014fr: { codes: ["fra"], sentenceAudioRows: 9833 },
31015de: { codes: ["deu"], sentenceAudioRows: 32938 },
31016el: { codes: ["ell"], sentenceAudioRows: 0 },
31017hu: { codes: ["hun"], sentenceAudioRows: 6914 },
31018id: { codes: ["ind"], sentenceAudioRows: 1754 },
31019it: { codes: ["ita"], sentenceAudioRows: 1591 },
31020km: { codes: ["khm"], sentenceAudioRows: 0, limitedCorpus: true },
31021ko: { codes: ["kor"], sentenceAudioRows: 0 },
31022lo: { codes: ["lao"], sentenceAudioRows: 0, limitedCorpus: true },
31023la: { codes: ["lat"], sentenceAudioRows: 1064, audioIsReconstruction: true },
31024mn: { codes: ["mon"], sentenceAudioRows: 0, limitedCorpus: true },
31025fa: { codes: ["pes"], sentenceAudioRows: 0 },
31026pl: { codes: ["pol"], sentenceAudioRows: 5618 },
31027pt: { codes: ["por"], sentenceAudioRows: 20957 },
31028ro: { codes: ["ron"], sentenceAudioRows: 52 },
31029ru: { codes: ["rus"], sentenceAudioRows: 10652 },
31030sh: { codes: ["srp", "hrv", "bos"], sentenceAudioRows: 0 },
31031es: { codes: ["spa"], sentenceAudioRows: 119430 },
31032sv: { codes: ["swe"], sentenceAudioRows: 206 },
31033tl: { codes: ["tgl"], sentenceAudioRows: 0 },
31034th: { codes: ["tha"], sentenceAudioRows: 722 },
31035tr: { codes: ["tur"], sentenceAudioRows: 1141 },
31036vi: { codes: ["vie"], sentenceAudioRows: 0 }
31037});
31038const TRANSLATION_ONLY_CODES = Object.freeze({
31039ja: "jpn",
31040he: "heb",
31041uk: "ukr",
31042cs: "ces",
31043nb: "nob",
31044no: "nob",
31045hi: "hin",
31046bn: "ben",
31047ta: "tam",
31048ur: "urd"
31049});
31050function tatoebaTranslationCode(outputLanguage) {
31051const base = outputLanguage.trim().toLowerCase().split(/[-_]/u)[0] ?? "";
31052if (!base) return null;
31053const configured = TATOEBA_COVERAGE[base];
31054if (configured) return configured.codes[0] ?? null;
31055return TRANSLATION_ONLY_CODES[base] ?? null;
31056}
31057const TATOEBA_EXAMPLE_SOURCE_ID = "tatoeba";
31058const TATOEBA_API_BASE = "https://api.tatoeba.org/v1/sentences";
31059const TATOEBA_SENTENCE_URL = "https://tatoeba.org/en/sentences/show";
31060const TATOEBA_AUDIO_URL = "https://tatoeba.org/audio/download";
31061const DEFAULT_RESULT_LIMIT = 8;
31062const MAX_RESULT_LIMIT = 20;
31063const RATE_LIMIT_INITIAL_BACKOFF_MS = 3e4;
31064const RATE_LIMIT_MAX_BACKOFF_MS = 10 * 6e4;
31065function createTatoebaExampleSource(options = {}) {
31066const now = options.now ?? (() => Date.now());
31067let rateLimitedUntil = 0;
31068let backoffMs = RATE_LIMIT_INITIAL_BACKOFF_MS;
31069const fetchJson = options.fetchJson ?? ((url, signal) => requestJson(url, {
31070signal,
31071proxyUrl: options.proxyUrl,
31072timeoutMs: options.timeoutMs,
31073failureLabel: "Tatoeba examples",
31074anonymous: true
31075}));
31076return {
31077id: TATOEBA_EXAMPLE_SOURCE_ID,
31078name: "Tatoeba",
31079supports: (targetLanguage) => tatoebaCapabilitiesFor(targetLanguage),
31080async search(request) {
31081const coverage = coverageFor(request.targetLanguage);
31082const term = request.term.trim();
31083if (!coverage || !term) return { availability: "unsupported", items: [] };
31084if (now() < rateLimitedUntil) return { availability: "unavailable", items: [], reason: "network" };
31085const limit = boundedLimit(request.limit);
31086const urls = coverage.entry.codes.map((code) => searchUrl({
31087code,
31088term,
31089outputLanguage: request.outputLanguage,
31090limit,
31091requestAudio: options.requestAudio ?? true
31092}));
31093const responses = await Promise.all(urls.map((url) => resolveResponse(url, request.signal, fetchJson)));
31094const failure = responses.find((response) => response.kind === "failure");
31095const payloads = responses.filter((response) => response.kind === "payload");
31096if (failure && !payloads.length) {
31097if (failure.reason === "rate-limit") {
31098rateLimitedUntil = now() + backoffMs;
31099backoffMs = Math.min(backoffMs * 2, RATE_LIMIT_MAX_BACKOFF_MS);
31100return { availability: "unavailable", items: [], reason: "network" };
31101}
31102return { availability: "unavailable", items: [], reason: failure.reason };
31103}
31104if (!failure) backoffMs = RATE_LIMIT_INITIAL_BACKOFF_MS;
31105const withheldMedia = [];
31106const items = payloads.flatMap((response) => sentenceRows(response.payload)).map((row) => toExampleRecord(row, coverage, request.outputLanguage, withheldMedia)).filter((record2) => Boolean(record2)).slice(0, limit);
31107if (!items.length) return { availability: "empty", items: [] };
31108return withheldMedia.length ? { availability: "loaded", items, withheldMedia } : { availability: "loaded", items };
31109}
31110};
31111}
31112function tatoebaCapabilitiesFor(targetLanguage) {
31113const coverage = coverageFor(targetLanguage);
31114if (!coverage) return unsupportedCapabilities();
31115const { entry } = coverage;
31116const hasSentenceAudio = entry.sentenceAudioRows > 0;
31117return {
31118supported: true,
31119text: {
31120availability: "available",
31121scope: "sentence",
31122...entry.limitedCorpus ? { reason: "limited-corpus" } : {}
31123},
31124audio: hasSentenceAudio ? { availability: "per-item", scope: "sentence", reason: "no-licensed-audio" } : { availability: "none", scope: "sentence", reason: "no-sentence-audio-source" },
31125image: noComponent("no-image-source"),
31126corpus: entry.limitedCorpus ? "limited" : "ample",
31127sentenceAudioRows: entry.sentenceAudioRows
31128};
31129}
31130function coverageFor(targetLanguage) {
31131const base = targetLanguage.trim().toLowerCase().split(/[-_]/u)[0] ?? "";
31132const id = TATOEBA_LANGUAGE_ALIASES[base] ?? base;
31133const entry = TATOEBA_COVERAGE[id];
31134return entry ? { id, entry } : null;
31135}
31136const TATOEBA_LANGUAGE_ALIASES = Object.freeze({
31137fil: "tl",
31138sr: "sh",
31139hr: "sh",
31140bs: "sh",
31141cmn: "zh",
31142pes: "fa",
31143prs: "fa",
31144nan: "zh"
31145});
31146function boundedLimit(limit) {
31147if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_RESULT_LIMIT;
31148return Math.max(1, Math.min(MAX_RESULT_LIMIT, Math.trunc(limit)));
31149}
31150function searchUrl(params) {
31151const query = new URLSearchParams({
31152lang: params.code,
31153q: `"${params.term}"`,
31154sort: "relevance",
31155limit: String(params.limit)
31156});
31157const translation = tatoebaTranslationCode(params.outputLanguage);
31158if (translation) query.set("trans:lang", translation);
31159if (params.requestAudio) query.set("include", "audios");
31160return `${TATOEBA_API_BASE}?${query.toString()}`;
31161}
31162async function resolveResponse(url, signal, fetchJson) {
31163try {
31164const payload = await fetchJson(url, signal);
31165if (!isRecord(payload)) return { kind: "failure", reason: "schema" };
31166if (!Array.isArray(payload.data)) return { kind: "failure", reason: "schema" };
31167return { kind: "payload", payload };
31168} catch (error) {
31169if (isAbortError$1(error)) throw error;
31170return { kind: "failure", reason: failureReason(error) };
31171}
31172}
31173function failureReason(error) {
31174const message = error instanceof Error ? error.message : String(error ?? "");
31175if (/\b429\b|too many requests/iu.test(message)) return "rate-limit";
31176if (/\b40[13]\b|unauthori[sz]ed|forbidden/iu.test(message)) return "auth";
31177if (/\bjson\b|unexpected token|schema/iu.test(message)) return "schema";
31178return "network";
31179}
31180function sentenceRows(payload) {
31181if (!isRecord(payload) || !Array.isArray(payload.data)) return [];
31182return payload.data.filter(isRecord);
31183}
31184function toExampleRecord(row, coverage, outputLanguage, withheldMedia) {
31185const value = text(row.text);
31186const id = text(row.id);
31187if (!value || !id) return null;
31188const sentenceLicence = text(row.license) || "CC BY 2.0 FR";
31189const owner = text(row.owner);
31190const audio = licensedAudio(row.audios, withheldMedia, coverage);
31191return {
31192id: `${TATOEBA_EXAMPLE_SOURCE_ID}:${id}`,
31193text: {
31194value,
31195language: text(row.lang) || coverage.entry.codes[0] || coverage.id,
31196...text(row.script) ? { script: text(row.script) } : {}
31197},
31198...translationOf(row, outputLanguage) ?? {},
31199...audio.length ? { audio } : {},
31200source: {
31201name: "Tatoeba",
31202url: `${TATOEBA_SENTENCE_URL}/${id}`,
31203licence: sentenceLicence,
31204attribution: owner ? `Tatoeba — ${owner}` : "Tatoeba"
31205},
31206quality: {
31207reviewed: row.is_unapproved !== true,
31208nativeSpeaker: coverage.entry.audioIsReconstruction ? false : Boolean(owner),
31209...coverage.entry.audioIsReconstruction ? { warnings: ["reconstructed-pronunciation"] } : {}
31210}
31211};
31212}
31213function translationOf(row, outputLanguage) {
31214const wanted = tatoebaTranslationCode(outputLanguage);
31215const candidates = Array.isArray(row.translations) ? row.translations.filter(isRecord) : [];
31216const matches = candidates.filter((candidate) => text(candidate.text) && (!wanted || text(candidate.lang) === wanted));
31217const chosen = matches.find((candidate) => candidate.is_direct === true) ?? matches[0];
31218if (!chosen) return null;
31219return {
31220translation: {
31221value: text(chosen.text),
31222language: text(chosen.lang) || wanted || outputLanguage,
31223provenance: "source",
31224direct: chosen.is_direct === true
31225}
31226};
31227}
31228function licensedAudio(raw, withheldMedia, coverage) {
31229if (!Array.isArray(raw)) return [];
31230const assets = [];
31231raw.filter(isRecord).forEach((record2) => {
31232const id = text(record2.id);
31233const decision = decideMediaLicence(record2.license);
31234if (!decision.allowed) {
31235withheldMedia.push({ kind: "audio", licence: text(record2.license) || "", reason: decision.withheld });
31236return;
31237}
31238if (!id) return;
31239const author = text(record2.author);
31240assets.push({
31241kind: "audio",
31242scope: "sentence",
31243url: text(record2.download_url) || `${TATOEBA_AUDIO_URL}/${id}`,
31244licence: decision.licence,
31245attribution: author ? `${author} (Tatoeba${coverage.entry.audioIsReconstruction ? ", reconstructed pronunciation" : ""})` : "Tatoeba",
31246recordUrl: text(record2.attribution_url) || `${TATOEBA_SENTENCE_URL}/${id}`
31247});
31248});
31249return assets;
31250}
31251function isRecord(value) {
31252return Boolean(value) && typeof value === "object" && !Array.isArray(value);
31253}
31254function text(value) {
31255if (typeof value === "string") return value.trim();
31256if (typeof value === "number" && Number.isFinite(value)) return String(value);
31257return "";
31258}
31259function isAbortError$1(error) {
31260return error instanceof Error && error.name === "AbortError";
31261}
31262const ADAPTERS = new Map();
31263function registerExampleSource(adapter) {
31264ADAPTERS.set(adapter.id, adapter);
31265return adapter;
31266}
31267function registeredExampleSources() {
31268return [...ADAPTERS.values()];
31269}
31270function exampleSourcesForTarget(targetLanguage) {
31271return registeredExampleSources().filter((adapter) => adapter.supports(targetLanguage).supported);
31272}
31273function declaredExampleCapabilities(targetLanguage) {
31274return [
31275{ sourceId: IMMERSION_KIT_EXAMPLE_SOURCE_ID, sourceName: "Immersion Kit", capabilities: immersionKitCapabilitiesFor(targetLanguage) },
31276{ sourceId: TATOEBA_EXAMPLE_SOURCE_ID, sourceName: "Tatoeba", capabilities: tatoebaCapabilitiesFor(targetLanguage) }
31277];
31278}
31279registerExampleSource(createTatoebaExampleSource());
31280const DEFAULT_EXAMPLE_LIMIT = 8;
31281function renderTargetExampleSourceMounts(settings2, sourceAttributes) {
31282const targetLanguage = targetLanguageOf(settings2);
31283const outputLanguage = outputLanguageOf(settings2);
31284return declaredExampleCapabilities(targetLanguage).map((row) => {
31285if (!row.capabilities.supported) {
31286return renderExampleSourceRow({
31287sourceId: row.sourceId,
31288sourceName: row.sourceName,
31289interfaceLanguage: settings2.interfaceLanguage,
31290targetLanguage,
31291outputLanguage,
31292capabilities: row.capabilities,
31293collection: { availability: "unsupported", items: [] },
31294sourceAttributes
31295});
31296}
31297return `
31298            <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-example-source-card"
31299                data-example-source="${escapeHtml(row.sourceId)}"
31300                data-availability="pending"
31301                data-example-target="${escapeHtml(targetLanguage)}"
31302                ${sourceAttributes(exampleSourceStateKey(row.sourceId), false)}>
31303                <summary class="jpdb-reader-local-title jpdb-reader-example-summary" data-jpdb-reader-surface-ignore>
31304                    <span class="jpdb-reader-example-source">${escapeHtml(row.sourceName)}</span>
31305                </summary>
31306                <div class="jpdb-reader-local-glossary">
31307                    <p class="jpdb-reader-help">${escapeHtml(uiText$1(settings2.interfaceLanguage, "loadingExamples"))}</p>
31308                </div>
31309            </details>
31310        `;
31311}).join("");
31312}
31313const ROOT_ABORT_CONTROLLERS = new WeakMap();
31314function installTargetExampleSources(root, options) {
31315const targetLanguage = targetLanguageOf(options.settings);
31316const adapters = options.adapters ?? exampleSourcesForTarget(targetLanguage);
31317if (!adapters.length) return;
31318installExampleSourceControls(root, options);
31319adapters.forEach((adapter) => {
31320const controller = replaceSourceController(root, adapter.id);
31321void loadOneSource(root, adapter, options, controller);
31322});
31323}
31324function abortPendingTargetExampleSources(root, sourceId) {
31325const controllers = ROOT_ABORT_CONTROLLERS.get(root);
31326if (!controllers) return;
31327if (sourceId === void 0) {
31328controllers.forEach((controller) => controller.abort());
31329ROOT_ABORT_CONTROLLERS.delete(root);
31330return;
31331}
31332controllers.get(sourceId)?.abort();
31333controllers.delete(sourceId);
31334}
31335function replaceSourceController(root, sourceId) {
31336abortPendingTargetExampleSources(root, sourceId);
31337const controllers = ROOT_ABORT_CONTROLLERS.get(root) ?? new Map();
31338const controller = new AbortController();
31339controllers.set(sourceId, controller);
31340ROOT_ABORT_CONTROLLERS.set(root, controllers);
31341return controller;
31342}
31343async function loadOneSource(root, adapter, options, controller) {
31344const targetLanguage = targetLanguageOf(options.settings);
31345const outputLanguage = outputLanguageOf(options.settings);
31346let collection;
31347try {
31348collection = await adapter.search({
31349term: options.term,
31350targetLanguage,
31351outputLanguage,
31352signal: controller.signal,
31353limit: exampleLimit(options.settings)
31354});
31355} catch (error) {
31356if (controller.signal.aborted || isAbortError(error)) return;
31357collection = { availability: "unavailable", items: [], reason: "network" };
31358}
31359if (controller.signal.aborted) return;
31360if (options.isCurrentRoot && !options.isCurrentRoot(root)) return;
31361const card = root.querySelector(`[data-example-source="${cssEscape(adapter.id)}"]`);
31362if (!card?.isConnected) return;
31363const replacement = htmlToFirstElement(renderExampleSourceRow({
31364sourceId: adapter.id,
31365sourceName: adapter.name,
31366interfaceLanguage: options.settings.interfaceLanguage,
31367targetLanguage,
31368outputLanguage,
31369capabilities: adapter.supports(targetLanguage),
31370collection,
31371sourceAttributes: options.sourceAttributes,
31372blurTranslations: options.settings.immersionKitRevealTranslationOnClick
31373}));
31374if (replacement) card.replaceWith(replacement);
31375}
31376function exampleLimit(settings2) {
31377return settings2.immersionKitLimitEnabled && settings2.immersionKitLimit > 0 ? settings2.immersionKitLimit : DEFAULT_EXAMPLE_LIMIT;
31378}
31379function installExampleSourceControls(root, options) {
31380if (root.dataset.yomuExampleSourceControls === "true") return;
31381root.dataset.yomuExampleSourceControls = "true";
31382root.addEventListener("click", (event) => {
31383const trigger = event.target?.closest("[data-action]");
31384if (!trigger || !root.contains(trigger)) return;
31385if (trigger.dataset.action === "retry-example-source") {
31386event.preventDefault();
31387const sourceId = trigger.dataset.exampleSourceId ?? "";
31388const adapters = (options.adapters ?? exampleSourcesForTarget(targetLanguageOf(options.settings))).filter((adapter) => adapter.id === sourceId);
31389if (adapters.length) installTargetExampleSources(root, { ...options, adapters });
31390return;
31391}
31392if (trigger.dataset.action === "play-example-audio") {
31393event.preventDefault();
31394const url = trigger.dataset.exampleAudioUrl ?? "";
31395if (url) playExampleAudio(url, options, trigger);
31396}
31397});
31398installProviderTranslationReveal(root);
31399}
31400function playExampleAudio(url, options, trigger) {
31401if (options.playAudio) {
31402options.playAudio(url);
31403return;
31404}
31405if (typeof Audio !== "function") return;
31406const audio = new Audio(url);
31407void audio.play().catch(() => {
31408if (!trigger) return;
31409trigger.dataset.yomuAudioFailed = "true";
31410trigger.setAttribute("aria-disabled", "true");
31411});
31412}
31413function isAbortError(error) {
31414return error instanceof Error && error.name === "AbortError";
31415}
31416function cssEscape(value) {
31417return value.replace(/["\\]/gu, "\\$&");
31418}
31419const DEFAULT_OPTION_KEYS = ["includeJpdbSource", "includeJitenSource", "includeBunproSource", "includeStudySources", "includeImmersionSource"];
31420const CORE_DEFINITION_SOURCE_RENDERERS = {
31421[JPDB_DEFINITION_SOURCE_ID]: renderJpdbDefinitionSourceSection,
31422[JITEN_DEFINITION_SOURCE_ID]: renderJitenDefinitionSourceSection,
31423[BUNPRO_DEFINITION_SOURCE_ID]: renderBunproDefinitionSourceSection,
31424[WANIKANI_DEFINITION_SOURCE_ID]: renderWanikaniDefinitionSourceSection,
31425[ANKI_SOURCE_ID]: renderAnkiDefinitionSourceSection,
31426[STUDY_TRANSLATION_SOURCE_ID]: renderTranslationDefinitionSourceSection,
31427[STUDY_GRAMMAR_SOURCE_ID]: renderGrammarDefinitionSourceSection,
31428[IMMERSION_KIT_SOURCE_ID]: renderImmersionDefinitionSourceSection
31429};
31430function renderDefinitionSourcesStack(params) {
31431const context = definitionSourceStackContext(params);
31432const sections = renderDefinitionSourceSections(context, params);
31433return sections.length ? `<div class="jpdb-reader-definition-stack">${sections.join("")}</div>` : params.noDefinitionsHtml();
31434}
31435function renderDefinitionSourceImmersionMount(settings2, sourceAttributes) {
31436if (!immersionKitCapabilitiesFor(targetLanguageOf(settings2)).supported) {
31437return renderTargetExampleSourceMounts(settings2, sourceAttributes);
31438}
31439if (!settings2.immersionKitEnabled) return "";
31440const title = definitionSourceLabel(settings2, IMMERSION_KIT_SOURCE_ID, uiText$1(settings2.interfaceLanguage, "immersionKit"));
31441return `
31442<details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-immersion" data-immersion-kit ${sourceAttributes(definitionSourceStateKey(IMMERSION_KIT_SOURCE_ID), false)}>
31443<summary class="jpdb-reader-local-title" data-jpdb-reader-surface-ignore>${escapeHtml(title)}</summary>
31444<div class="jpdb-reader-help">${uiText$1(settings2.interfaceLanguage, "loadingExamples")}</div>
31445</details>
31446`;
31447}
31448function definitionSourceStackContext(params) {
31449const { options, extraSections } = normalizedDefinitionSourceStackOptions(params);
31450const grouped = groupTermEntriesByDictionary(params.entries);
31451const sourceIds = orderedDefinitionSourceIds(params.settings, [...grouped.keys()]);
31452return {
31453card: params.card,
31454sentence: params.sentence,
31455sourceIds,
31456grouped,
31457extraSections,
31458includeJpdbSource: options.includeJpdbSource ?? !isJpdbHost(),
31459includeJitenSource: options.includeJitenSource ?? !isJitenHost(),
31460includeBunproSource: options.includeBunproSource ?? !isBunproPageHost(),
31461includeStudySources: options.includeStudySources ?? true,
31462includeImmersionSource: options.includeImmersionSource ?? true,
31463jpdbVocabularyInfo: params.jpdbVocabularyInfo ?? null,
31464jitenVocabularyInfo: params.jitenVocabularyInfo ?? null,
31465bunproDefinitionInfo: params.bunproDefinitionInfo ?? null
31466};
31467}
31468function isBunproPageHost() {
31469if (typeof location === "undefined") return false;
31470return location.hostname === "bunpro.jp" || location.hostname.endsWith(".bunpro.jp");
31471}
31472function normalizedDefinitionSourceStackOptions(params) {
31473const optionKeys = params.optionKeys ?? DEFAULT_OPTION_KEYS;
31474const rawOptions = params.extraSectionsOrOptions ?? {};
31475if (isDefinitionSourceStackOptions(rawOptions, optionKeys)) {
31476return { options: rawOptions, extraSections: {} };
31477}
31478return { options: {}, extraSections: rawOptions };
31479}
31480function isDefinitionSourceStackOptions(value, optionKeys) {
31481return optionKeys.some((key) => key in value);
31482}
31483function renderDefinitionSourceSections(context, params) {
31484return context.sourceIds.map((sourceId) => renderDefinitionSourceSection(sourceId, context, params)).filter(Boolean);
31485}
31486function renderDefinitionSourceSection(sourceId, context, params) {
31487const coreSource = renderCoreDefinitionSourceSection(sourceId, context, params);
31488if (coreSource !== null) return coreSource;
31489return context.grouped.has(sourceId) ? renderLocalDefinitionDictionarySource(sourceId, context, params) : "";
31490}
31491function renderCoreDefinitionSourceSection(sourceId, context, params) {
31492return CORE_DEFINITION_SOURCE_RENDERERS[sourceId]?.(context, params) ?? null;
31493}
31494function renderJpdbDefinitionSourceSection(context, params) {
31495if (!context.includeJpdbSource) return "";
31496return renderJpdbDefinitionSource(
31497context.card,
31498params.sourceAttributes,
31499context.jpdbVocabularyInfo,
31500params.jpdbLanguage,
31501definitionSourceLabel(params.settings, JPDB_DEFINITION_SOURCE_ID, "JPDB")
31502);
31503}
31504function renderJitenDefinitionSourceSection(context, params) {
31505if (!context.includeJitenSource) return "";
31506return renderJitenDefinitionSource(
31507context.card,
31508params.sourceAttributes,
31509context.jitenVocabularyInfo,
31510params.jpdbLanguage ?? params.settings.interfaceLanguage,
31511definitionSourceLabel(params.settings, JITEN_DEFINITION_SOURCE_ID, "Jiten")
31512);
31513}
31514function renderBunproDefinitionSourceSection(context, params) {
31515if (!context.includeBunproSource) return "";
31516const renderBunproDefinitionSource = yomuBunproCompanion()?.renderBunproDefinitionSource;
31517if (!renderBunproDefinitionSource) return "";
31518return renderBunproDefinitionSource(
31519context.card,
31520params.sourceAttributes,
31521context.bunproDefinitionInfo,
31522params.jpdbLanguage ?? params.settings.interfaceLanguage,
31523definitionSourceLabel(params.settings, BUNPRO_DEFINITION_SOURCE_ID, "Bunpro")
31524);
31525}
31526function renderWanikaniDefinitionSourceSection(context, params) {
31527return renderWanikaniDefinitionMount(context.card, params.settings, params.sourceAttributes);
31528}
31529function renderAnkiDefinitionSourceSection(context) {
31530return context.extraSections[ANKI_SOURCE_ID] ?? "";
31531}
31532function renderTranslationDefinitionSourceSection(context, params) {
31533return context.includeStudySources ? params.renderTranslationSource(context.sentence) : "";
31534}
31535function renderGrammarDefinitionSourceSection(context, params) {
31536return context.includeStudySources ? params.renderGrammarSource(context.sentence) : "";
31537}
31538function renderImmersionDefinitionSourceSection(context, params) {
31539return context.includeImmersionSource ? renderImmersionSource(params) : "";
31540}
31541function renderLocalDefinitionDictionarySource(dictionary, context, params) {
31542return renderLocalDefinitionSourcesSection(
31543[dictionary],
31544context.grouped,
31545params.settings,
31546params.sourceAttributes,
31547params.dictionaryLabel,
31548context.card
31549);
31550}
31551function renderImmersionSource(params) {
31552return params.renderImmersionSource ? params.renderImmersionSource() : renderDefinitionSourceImmersionMount(params.settings, params.sourceAttributes);
31553}
31554const COMMON_PARTICLES = new Set(["は", "が", "を", "に", "へ", "で", "と", "も", "の", "や", "か", "ね", "よ", "ぞ", "ぜ", "な", "わ", "から", "まで", "だけ", "しか", "より"]);
31555function normalizeImmersionSearchQuery(value) {
31556return value.replace(/\s+/g, " ").trim();
31557}
31558function queryKey(value) {
31559return normalizeImmersionSearchQuery(value).replace(/\s+/g, "").toLowerCase();
31560}
31561function queryLength(value) {
31562return Array.from(queryKey(value)).length;
31563}
31564function isUsefulImmersionPreloadQuery(query) {
31565return isUsefulStandaloneQuery(query);
31566}
31567function isUsefulStandaloneQuery(query) {
31568if (!query || !isTargetLanguageText(query)) return false;
31569if (COMMON_PARTICLES.has(queryKey(query))) return false;
31570return queryLength(query) >= 2;
31571}
31572function waitForIdle(timeoutMs = 75, fallbackDelayMs = 0) {
31573if (timeoutMs <= 0 && fallbackDelayMs <= 0) return Promise.resolve();
31574return new Promise((resolve) => {
31575if (scheduleIdleCallback(() => resolve(), timeoutMs)) return;
31576window.setTimeout(resolve, Math.max(0, fallbackDelayMs));
31577});
31578}
31579function scheduleIdleCallback(callback, timeoutMs = 75) {
31580const requestIdleCallback = window.requestIdleCallback;
31581if (typeof requestIdleCallback !== "function") return false;
31582requestIdleCallback.call(window, callback, { timeout: timeoutMs });
31583return true;
31584}
31585function scopedDocument(scope = {}) {
31586if (scope.document) return scope.document;
31587return typeof document === "undefined" ? null : document;
31588}
31589function isPageDormant(scope = {}) {
31590const owner = scopedDocument(scope);
31591if (!owner?.defaultView) return false;
31592return owner.visibilityState === "hidden";
31593}
31594function onPageActivityChange(listener, scope = {}) {
31595const owner = scopedDocument(scope);
31596if (!owner) return () => void 0;
31597const handler = () => listener(owner.visibilityState === "hidden");
31598owner.addEventListener("visibilitychange", handler, { signal: scope.signal });
31599return () => owner.removeEventListener("visibilitychange", handler);
31600}
31601function isConnectedTarget(target) {
31602return target?.isConnected === true;
31603}
31604class ParkableObserver {
31605targets = new Map();
31606observer;
31607reconcile;
31608unsubscribe;
31609parked;
31610disposed = false;
31611constructor(observer, options = {}) {
31612this.observer = observer;
31613this.reconcile = options.reconcile;
31614this.parked = isPageDormant(options);
31615this.unsubscribe = onPageActivityChange((dormant) => {
31616if (dormant) this.park();
31617else this.wake();
31618}, options);
31619}
31620observe(target, init) {
31621if (this.disposed) return;
31622this.targets.set(target, { init, wasConnected: isConnectedTarget(target) });
31623if (!this.parked) this.observer?.observe(target, init);
31624}
31625/** Native semantics: forget every target, not just detach from them. */
31626disconnect() {
31627this.targets.clear();
31628this.drain();
31629this.observer?.disconnect();
31630}
31631/** Detach for good — drops the visibility subscription with the targets. */
31632dispose() {
31633this.disposed = true;
31634this.unsubscribe();
31635this.disconnect();
31636}
31637get dormant() {
31638return this.parked;
31639}
31640park() {
31641if (this.parked) return;
31642this.parked = true;
31643this.forgetDiscardedTargets();
31644this.drain();
31645this.observer?.disconnect();
31646}
31647wake() {
31648if (!this.parked) return;
31649this.parked = false;
31650this.forgetDiscardedTargets();
31651this.targets.forEach((entry, target) => this.observer?.observe(target, entry.init));
31652this.reconcile?.();
31653}
31654forgetDiscardedTargets() {
31655this.targets.forEach((entry, target) => {
31656if (entry.wasConnected && !isConnectedTarget(target)) this.targets.delete(target);
31657});
31658}
31659drain() {
31660this.observer?.takeRecords?.();
31661}
31662}
31663function parkableMutationObserver(callback, options = {}) {
31664const owner = scopedDocument(options);
31665const Observer = owner?.defaultView?.MutationObserver;
31666if (!Observer) return null;
31667const observer = new Observer(callback);
31668return new ParkableObserver(observer, options);
31669}
31670function mutationNodes(mutation, options = {}) {
31671const nodes = [
31672mutation.target,
31673...Array.from(mutation.addedNodes)
31674];
31675if (options.removed) nodes.push(...Array.from(mutation.removedNodes));
31676return nodes;
31677}
31678const READER_PAINT_CONTAINER_SELECTOR = [
31679"[data-jpdb-reader-root]",
31680".jpdb-reader-text-mirror",
31681".jpdb-reader-control-text-mirror",
31682".jpdb-reader-detached-reading-overlay",
31683"[data-yomu-projected-reading]"
31684].join(",");
31685const READER_PAINT_ATTRIBUTE_SELECTOR = `${READER_PAINT_CONTAINER_SELECTOR},.jpdb-reader-word`;
31686function mutationContainsOnlyReaderPaint(mutation) {
31687if (mutation.type !== "childList") {
31688return nodeMatchesOrIsInside(mutation.target, READER_PAINT_ATTRIBUTE_SELECTOR);
31689}
31690if (nodeMatchesOrIsInside(mutation.target, READER_PAINT_CONTAINER_SELECTOR)) return true;
31691const changed = [...mutation.addedNodes, ...mutation.removedNodes];
31692return changed.length > 0 && changed.every((node) => nodeMatchesOrIsInside(node, READER_PAINT_CONTAINER_SELECTOR));
31693}
31694function nodeMatchesOrIsInside(node, selector) {
31695const element = node instanceof Element ? node : node.parentElement;
31696return Boolean(element?.matches(selector) || element?.closest(selector));
31697}
31698function mutationInsideClosest(mutation, selector) {
31699return mutationNodes(mutation, { removed: true }).every((node) => {
31700const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
31701return Boolean(element?.closest?.(selector));
31702});
31703}
31704const ITEM_EXIT_MS = 180;
31705const PI = Math.PI;
31706const MIN_GAP = 62;
31707const MAX_R = 320;
31708const EDGE = 32;
31709class RadialMenuController {
31710constructor(host) {
31711this.host = host;
31712}
31713backdrop;
31714items = new Map();
31715state = "closed";
31716listeners;
31717closeTimer;
31718isOpen() {
31719return this.state === "open";
31720}
31721toggle() {
31722if (this.state === "open") this.close();
31723else this.show();
31724}
31725show() {
31726const button = this.host.getButton();
31727if (!button || this.state === "open") return;
31728window.clearTimeout(this.closeTimer);
31729this.teardownDom();
31730const actions = this.host.buildActions();
31731if (!actions.length) return;
31732const backdrop = document.createElement("div");
31733backdrop.className = "jpdb-reader-fab-radial";
31734backdrop.dataset.jpdbReaderRoot = "true";
31735backdrop.setAttribute("role", "menu");
31736backdrop.setAttribute("aria-label", this.host.menuLabel());
31737document.body.appendChild(backdrop);
31738applyOverlayPageScale(backdrop);
31739this.backdrop = backdrop;
31740this.layout(button, backdrop, actions);
31741button.classList.add("jpdb-reader-fab--menu-open");
31742this.listeners = new AbortController();
31743const { signal } = this.listeners;
31744backdrop.addEventListener("pointerdown", (event) => {
31745if (event.target === backdrop) this.close();
31746}, { signal });
31747document.addEventListener("keydown", (event) => {
31748if (event.key === "Escape") {
31749event.stopPropagation();
31750this.close();
31751}
31752}, { signal, capture: true });
31753window.addEventListener("scroll", this.close, { signal, passive: true });
31754window.addEventListener("resize", this.close, { signal, passive: true });
31755this.state = "open";
31756requestAnimationFrame(() => requestAnimationFrame(() => {
31757if (this.state === "open") backdrop.classList.add("is-open");
31758}));
31759}
31760close = () => {
31761if (this.state !== "open") return;
31762this.state = "closing";
31763this.host.getButton()?.classList.remove("jpdb-reader-fab--menu-open");
31764this.listeners?.abort();
31765this.listeners = void 0;
31766this.backdrop?.classList.remove("is-open");
31767this.backdrop?.classList.add("is-closing");
31768this.closeTimer = window.setTimeout(() => this.teardownDom(), ITEM_EXIT_MS + 40);
31769};
31770destroy() {
31771window.clearTimeout(this.closeTimer);
31772this.listeners?.abort();
31773this.listeners = void 0;
31774this.host.getButton()?.classList.remove("jpdb-reader-fab--menu-open");
31775this.teardownDom();
31776}
31777teardownDom() {
31778this.backdrop?.remove();
31779this.backdrop = void 0;
31780this.items.clear();
31781this.state = "closed";
31782}
31783layout(button, backdrop, actions) {
31784const rect = sourceRectToOverlay(button.getBoundingClientRect(), button);
31785const cx = rect.left + rect.width / 2;
31786const cy = rect.top + rect.height / 2;
31787const { width: vw, height: vh } = overlayViewport();
31788const vAngle = cy > vh / 2 ? -PI / 2 : PI / 2;
31789let hAngle = cx > vw / 2 ? PI : 0;
31790while (hAngle - vAngle > PI) hAngle -= 2 * PI;
31791while (hAngle - vAngle < -PI) hAngle += 2 * PI;
31792const count = actions.length;
31793const pad = count >= 7 ? 0.01 : count >= 5 ? 0.08 : 0.12;
31794const radius = this.radiusForLayout(cx, cy, vw, vh, vAngle, hAngle, count, pad);
31795actions.forEach((action, index) => {
31796const t = count > 1 ? pad + (1 - 2 * pad) * (index / (count - 1)) : 0.5;
31797const angle = vAngle + (hAngle - vAngle) * t;
31798const x = Math.cos(angle) * radius;
31799const y = Math.sin(angle) * radius;
31800const item = this.createItem(action, index);
31801item.style.left = `${cx}px`;
31802item.style.top = `${cy}px`;
31803item.style.setProperty("--radial-x", `${x.toFixed(1)}px`);
31804item.style.setProperty("--radial-y", `${y.toFixed(1)}px`);
31805item.style.setProperty("--radial-i", String(index));
31806backdrop.appendChild(item);
31807this.items.set(action.id, item);
31808});
31809}
31810radiusForLayout(cx, cy, vw, vh, vAngle, hAngle, count, pad) {
31811const comfortRadius = 116 + count * 11;
31812if (count <= 1) return Math.min(MAX_R, comfortRadius);
31813const usableArc = Math.abs(hAngle - vAngle) * (1 - 2 * pad);
31814const step = usableArc / (count - 1);
31815const targetRadius = Math.max(comfortRadius, Math.min(MAX_R, MIN_GAP / (2 * Math.sin(step / 2))));
31816let maxRadius = MAX_R;
31817for (let index = 0; index < count; index += 1) {
31818const t = count > 1 ? pad + (1 - 2 * pad) * (index / (count - 1)) : 0.5;
31819const angle = vAngle + (hAngle - vAngle) * t;
31820const cos = Math.cos(angle);
31821const sin = Math.sin(angle);
31822maxRadius = Math.min(
31823maxRadius,
31824cos > 0 ? (vw - EDGE - cx) / cos : cos < 0 ? (cx - EDGE) / -cos : Number.POSITIVE_INFINITY,
31825sin > 0 ? (vh - EDGE - cy) / sin : sin < 0 ? (cy - EDGE) / -sin : Number.POSITIVE_INFINITY
31826);
31827}
31828return Math.max(0, Math.min(maxRadius, targetRadius));
31829}
31830createItem(action, index) {
31831const item = document.createElement("button");
31832item.type = "button";
31833item.dataset.radialId = action.id;
31834item.setAttribute("role", "menuitem");
31835item.tabIndex = action.disabled ? -1 : 0;
31836this.applyActionState(item, action);
31837item.addEventListener("click", (event) => {
31838if (!isTrustedReaderInteraction(event)) return;
31839event.preventDefault();
31840event.stopPropagation();
31841if (action.disabled) return;
31842action.run();
31843if (action.keepOpen) this.refresh();
31844else this.close();
31845});
31846item.addEventListener("keydown", (event) => this.handleItemKeydown(event, index));
31847return item;
31848}
31849applyActionState(item, action) {
31850item.className = "jpdb-reader-fab-radial-item";
31851if (action.primary) item.classList.add("is-primary");
31852if (action.disabled) item.classList.add("is-disabled");
31853const tone = action.disabled ? "neutral" : action.tone ?? "neutral";
31854if (tone === "on") item.classList.add("is-on");
31855else if (tone === "off") item.classList.add("is-off");
31856else if (tone === "partial") item.classList.add("is-partial");
31857item.title = action.label;
31858item.setAttribute("aria-label", action.label);
31859item.setAttribute("aria-disabled", String(Boolean(action.disabled)));
31860const iconClass = action.glyph ? "jpdb-reader-fab-radial-icon is-glyph" : "jpdb-reader-fab-radial-icon";
31861const label = `<span class="jpdb-reader-fab-radial-label">${escapeHtml(action.label)}</span>`;
31862if (action.glyph) {
31863setInnerHtml(item, `<span class="${iconClass}">${escapeHtml(action.icon)}</span>${label}`);
31864} else {
31865setInnerHtml(item, `<span class="${iconClass}">${action.icon}</span>${label}`);
31866}
31867}
31868/** Re-derive tone/label/icon for toggles that kept the menu open. */
31869refresh() {
31870if (this.state !== "open") return;
31871const actions = this.host.buildActions();
31872for (const action of actions) {
31873const item = this.items.get(action.id);
31874if (item) this.applyActionState(item, action);
31875}
31876}
31877handleItemKeydown(event, index) {
31878const order = Array.from(this.items.values());
31879if (event.key === "ArrowDown" || event.key === "ArrowRight") {
31880event.preventDefault();
31881this.focusItem(order, index + 1);
31882} else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
31883event.preventDefault();
31884this.focusItem(order, index - 1);
31885} else if (event.key === "Home") {
31886event.preventDefault();
31887this.focusItem(order, 0);
31888} else if (event.key === "End") {
31889event.preventDefault();
31890this.focusItem(order, order.length - 1);
31891}
31892}
31893focusItem(order, index) {
31894if (!order.length) return;
31895const wrapped = (index + order.length) % order.length;
31896order[wrapped]?.focus();
31897}
31898}
31899const SVG_OPEN = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
31900function radialPowerIcon() {
31901return `${SVG_OPEN}<path d="M12 4v8"></path><path d="M7.5 7.5a7 7 0 1 0 9 0"></path></svg>`;
31902}
31903function radialFuriganaHiddenIcon() {
31904return `${SVG_OPEN}<text x="12" y="15.5" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor" stroke="none">ふ</text><path d="M5 19 19 5"></path></svg>`;
31905}
31906function radialPausedIcon() {
31907return `${SVG_OPEN}<path d="M9 5v14"></path><path d="M15 5v14"></path></svg>`;
31908}
31909function radialSettingsIcon() {
31910return `${SVG_OPEN}<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
31911}
31912function radialAudioOnIcon() {
31913return `${SVG_OPEN}<path d="M11 5 6 9H3v6h3l5 4z" fill="currentColor"></path><path d="M15.5 8.5a4.5 4.5 0 0 1 0 7"></path><path d="M18.5 5.5a8.5 8.5 0 0 1 0 13"></path></svg>`;
31914}
31915function radialAudioMutedIcon() {
31916return `${SVG_OPEN}<path d="M11 5 6 9H3v6h3l5 4z" fill="currentColor"></path><path d="m23 9-6 6"></path><path d="m17 9 6 6"></path></svg>`;
31917}
31918function radialOcrIcon() {
31919return `${SVG_OPEN}<rect x="3" y="4" width="18" height="16" rx="2.5"></rect><path d="M7 8h4"></path><path d="M7 12h10"></path><path d="M7 16h7"></path><path d="M15.5 7.5 17 6l1.5 1.5"></path><path d="M17 6v5"></path></svg>`;
31920}
31921function radialOcrOnIcon() {
31922return `${SVG_OPEN}<path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M7 12h10"></path></svg>`;
31923}
31924function radialCaptionsIcon() {
31925return `${SVG_OPEN}<rect x="3" y="5" width="18" height="14" rx="2.5"></rect><path d="M7 10h3"></path><path d="M14 10h3"></path><path d="M7 14h4"></path><path d="M13 14h4"></path></svg>`;
31926}
31927function radialYoutubeIcon() {
31928return `${SVG_OPEN}<rect x="3" y="6" width="18" height="12" rx="3"></rect><path d="M10.2 9.6 14.4 12l-4.2 2.4z" fill="currentColor" stroke="none"></path></svg>`;
31929}
31930const VIDEO_AVOIDANCE_SETTLE_MS = 120;
31931const VIEWPORT_SCALE_SETTLE_MS = 240;
31932function hostHasBottomActionDock() {
31933return location.hostname === "jiten.moe" && location.pathname.startsWith("/srs/");
31934}
31935function puckStateLabel(language, state) {
31936if (state === "no-furigana") return `${APP_NAME}: ${uiText$1(language, "furiganaOffToast")}`;
31937if (state === "paused") return `${APP_NAME}: ${uiText$1(language, "annotationsPausedToast")}`;
31938return APP_NAME;
31939}
31940const JAPANESE_POWER_ACTION = Object.freeze({
31941on: { label: "puckHideFurigana", icon: radialPowerIcon, tone: "on" },
31942"no-furigana": { label: "puckPauseAnnotations", icon: radialFuriganaHiddenIcon, tone: "partial" },
31943paused: { label: "puckResumeAnnotations", icon: radialPausedIcon, tone: "off" }
31944});
31945const GENERIC_POWER_ACTION = Object.freeze({
31946on: { label: "puckPauseAnnotations", icon: radialPowerIcon, tone: "on" },
31947"no-furigana": { label: "puckPauseAnnotations", icon: radialPowerIcon, tone: "on" },
31948paused: { label: "puckResumeAnnotations", icon: radialPausedIcon, tone: "off" }
31949});
31950function floatingButtonRadialActions(settings2, actions, syncButtonState) {
31951const items = [
31952powerRadialAction(settings2, actions, syncButtonState),
31953audioRadialAction(settings2, actions),
31954ocrRadialAction(settings2, actions)
31955];
31956if (usesJapaneseProviders()) items.push(japaneseSiteRadialAction(settings2, actions));
31957items.push(settingsRadialAction(settings2, actions), studyRadialAction(settings2, actions));
31958if (actions.hasSubtitleVideo()) items.push(subtitleRadialAction(settings2, actions));
31959if (actions.isYouTube()) items.push(youtubeRadialAction(settings2, actions));
31960return items;
31961}
31962function powerRadialAction(settings2, actions, syncButtonState) {
31963const state = actions.powerState();
31964const presentation = (usesJapaneseProviders() ? JAPANESE_POWER_ACTION : GENERIC_POWER_ACTION)[state];
31965return {
31966id: "power",
31967label: uiText$1(settings2.interfaceLanguage, presentation.label),
31968icon: presentation.icon(),
31969tone: presentation.tone,
31970primary: true,
31971keepOpen: true,
31972run: () => void actions.cyclePowerState().finally(syncButtonState)
31973};
31974}
31975function audioRadialAction(settings2, actions) {
31976const enabled = actions.isAutoPlayAudioEnabled();
31977return {
31978id: "audio",
31979label: uiText$1(settings2.interfaceLanguage, enabled ? "puckMuteAudio" : "puckUnmuteAudio"),
31980icon: enabled ? radialAudioOnIcon() : radialAudioMutedIcon(),
31981tone: enabled ? "on" : "off",
31982keepOpen: true,
31983run: () => actions.toggleAutoPlayAudio()
31984};
31985}
31986function ocrRadialAction(settings2, actions) {
31987const mode = actions.ocrMode();
31988return {
31989id: "ocr",
31990label: ocrModeLabel(settings2.interfaceLanguage, mode),
31991icon: mode === "manual" ? radialOcrOnIcon() : radialOcrIcon(),
31992tone: ocrRadialTone(mode, actions.powerState()),
31993keepOpen: true,
31994run: () => actions.toggleOcrMode()
31995};
31996}
31997function ocrRadialTone(mode, powerState) {
31998if (powerState === "paused") return "off";
31999return mode === "off" ? "off" : "on";
32000}
32001function japaneseSiteRadialAction(settings2, actions) {
32002return {
32003id: "japanese-site",
32004label: formatUiText(settings2.interfaceLanguage, "preferJapaneseSiteLanguage", {
32005language: targetLanguageDisplayName(settings2)
32006}),
32007icon: "日",
32008glyph: true,
32009tone: settings2.preferJapaneseSiteLanguage ? "on" : "off",
32010keepOpen: true,
32011run: () => actions.toggleJapaneseSiteLanguage()
32012};
32013}
32014function settingsRadialAction(settings2, actions) {
32015return {
32016id: "settings",
32017label: uiText$1(settings2.interfaceLanguage, "settings"),
32018icon: radialSettingsIcon(),
32019run: () => actions.openSettings()
32020};
32021}
32022function studyRadialAction(settings2, actions) {
32023return {
32024id: "study",
32025label: targetActionLabel(settings2, "puckStudyTarget"),
32026icon: "よ",
32027glyph: true,
32028run: () => actions.openStudyPage()
32029};
32030}
32031function subtitleRadialAction(settings2, actions) {
32032const enabled = actions.isAutoSubtitlesEnabled();
32033return {
32034id: "subtitles",
32035label: targetActionLabel(settings2, "puckAutoDetectTargetSubtitles"),
32036icon: radialCaptionsIcon(),
32037tone: enabled ? "on" : "off",
32038keepOpen: true,
32039run: () => actions.toggleAutoSubtitles()
32040};
32041}
32042function youtubeRadialAction(settings2, actions) {
32043const enabled = actions.isYoutubeFilterEnabled();
32044return {
32045id: "youtube",
32046label: targetActionLabel(settings2, "puckFilterYoutubeTarget"),
32047icon: radialYoutubeIcon(),
32048tone: enabled ? "on" : "off",
32049keepOpen: true,
32050run: () => actions.toggleYoutubeFilter()
32051};
32052}
32053function targetActionLabel(settings2, key) {
32054return formatUiText(settings2.interfaceLanguage, key, {
32055language: targetLanguageDisplayName(settings2)
32056});
32057}
32058class FloatingButtonController {
32059button;
32060abortController;
32061radial;
32062settings;
32063actions;
32064save = () => {
32065};
32066install(settings2, saveSettings2, actions) {
32067this.settings = settings2;
32068this.actions = actions;
32069this.save = saveSettings2;
32070if (!shouldShowFloatingButton(settings2)) {
32071this.destroy();
32072return;
32073}
32074document.querySelectorAll("[data-jpdb-reader-root].jpdb-reader-fab").forEach((element) => {
32075if (element !== this.button) element.remove();
32076});
32077if (this.button?.isConnected) {
32078this.syncButtonState();
32079return;
32080}
32081this.build(settings2);
32082}
32083destroy() {
32084this.radial?.destroy();
32085this.radial = void 0;
32086this.abortController?.abort();
32087this.abortController = void 0;
32088this.button?.remove();
32089this.button = void 0;
32090}
32091build(settings2) {
32092const button = document.createElement("button");
32093button.className = "jpdb-reader-fab";
32094button.type = "button";
32095button.textContent = APP_PUCK;
32096button.title = APP_NAME;
32097button.setAttribute("aria-haspopup", "menu");
32098button.dataset.jpdbReaderRoot = "true";
32099restoreButtonPosition(button, settings2);
32100this.button = button;
32101this.syncButtonState();
32102this.radial = new RadialMenuController({
32103getButton: () => this.button,
32104buildActions: () => this.buildRadialActions(),
32105menuLabel: () => uiText$1(this.settings?.interfaceLanguage ?? "en", "puckMenuLabel")
32106});
32107this.installDragHandlers(button);
32108button.addEventListener("click", (event) => {
32109if (!isTrustedReaderInteraction(event)) return;
32110if (button.dataset.jpdbReaderMoved === "true") {
32111event.preventDefault();
32112event.stopPropagation();
32113button.dataset.jpdbReaderMoved = "false";
32114return;
32115}
32116event.preventDefault();
32117event.stopPropagation();
32118this.radial?.toggle();
32119});
32120document.body.appendChild(button);
32121applyOverlayPageScale(button);
32122clampRestoredButtonPosition(button, settings2);
32123this.installVideoAvoidance(button);
32124}
32125syncButtonState() {
32126const button = this.button;
32127if (!button) return;
32128const powerState = this.actions?.powerState() ?? "on";
32129const language = this.settings?.interfaceLanguage ?? "en";
32130const targetName = this.settings ? targetLanguageDisplayName(this.settings) : "";
32131button.classList.toggle("jpdb-reader-fab-raised", hostHasBottomActionDock());
32132button.classList.toggle("jpdb-reader-fab--on", powerState === "on");
32133button.classList.toggle("jpdb-reader-fab--no-furigana", powerState === "no-furigana");
32134button.classList.toggle("jpdb-reader-fab--paused", powerState === "paused");
32135button.dataset.targetLanguage = activeLearningTargetLanguage();
32136button.title = powerState === "on" && targetName ? formatUiText(language, "puckLearningTarget", { language: targetName }) : puckStateLabel(language, powerState);
32137button.setAttribute("aria-label", button.title);
32138}
32139buildRadialActions() {
32140const settings2 = this.settings;
32141const actions = this.actions;
32142if (!settings2 || !actions) return [];
32143return floatingButtonRadialActions(settings2, actions, () => this.syncButtonState());
32144}
32145installVideoAvoidance(button) {
32146this.abortController?.abort();
32147const controller = new AbortController();
32148this.abortController = controller;
32149let settleTimer;
32150let viewportSettleTimer;
32151let frame;
32152const recompute = () => {
32153if (frame !== void 0) return;
32154frame = requestAnimationFrame(() => {
32155frame = void 0;
32156applyOverlayPageScale(button);
32157if (this.settings && avoidanceCouldChange()) {
32158avoidVideoOverlap(button, this.settings, this.save);
32159}
32160});
32161};
32162const avoidanceCouldChange = () => overlayViewport().pageScale !== 1 || hasOverlayPageScale(button) || button.classList.contains("jpdb-reader-fab-over-video") || Boolean(document.querySelector("video"));
32163const scheduleSettle = () => {
32164if (!avoidanceCouldChange()) return;
32165window.clearTimeout(settleTimer);
32166settleTimer = window.setTimeout(recompute, VIDEO_AVOIDANCE_SETTLE_MS);
32167};
32168const handleViewportChange = () => {
32169if (avoidanceCouldChange()) recompute();
32170window.clearTimeout(viewportSettleTimer);
32171viewportSettleTimer = window.setTimeout(recompute, VIEWPORT_SCALE_SETTLE_MS);
32172};
32173window.addEventListener("resize", handleViewportChange, { passive: true, signal: controller.signal });
32174window.addEventListener("orientationchange", handleViewportChange, { passive: true, signal: controller.signal });
32175window.visualViewport?.addEventListener("resize", handleViewportChange, { passive: true, signal: controller.signal });
32176window.addEventListener("scroll", scheduleSettle, { passive: true, signal: controller.signal });
32177document.addEventListener("fullscreenchange", recompute, { signal: controller.signal });
32178controller.signal.addEventListener("abort", () => {
32179window.clearTimeout(settleTimer);
32180window.clearTimeout(viewportSettleTimer);
32181if (frame !== void 0) window.cancelAnimationFrame(frame);
32182});
32183recompute();
32184}
32185installDragHandlers(button) {
32186let dragging = false;
32187let moved = false;
32188let startX = 0;
32189let startY = 0;
32190let originX = 0;
32191let originY = 0;
32192let puckBox = null;
32193let pendingPosition = null;
32194let currentPosition = null;
32195let dragFrame;
32196let dragFramePending = false;
32197let dragPositionAnchored = false;
32198let dragPageScale = 1;
32199const cancelDragFrame = () => {
32200if (dragFrame !== void 0) window.cancelAnimationFrame(dragFrame);
32201dragFrame = void 0;
32202dragFramePending = false;
32203};
32204const scheduleDragFrame = (position) => {
32205pendingPosition = position;
32206if (dragFramePending) return;
32207dragFramePending = true;
32208const frame = window.requestAnimationFrame(() => {
32209dragFramePending = false;
32210dragFrame = void 0;
32211if (!dragging || !pendingPosition) return;
32212currentPosition = pendingPosition;
32213pendingPosition = null;
32214applyPuckDragTransform(button, currentPosition.x - originX, currentPosition.y - originY);
32215});
32216dragFrame = dragFramePending ? frame : void 0;
32217};
32218button.addEventListener("pointerdown", (event) => {
32219if (!isTrustedReaderInteraction(event)) return;
32220if (event.button !== 0) return;
32221dragging = true;
32222moved = false;
32223button.dataset.jpdbReaderMoved = "false";
32224dragPageScale = applyOverlayPageScale(button);
32225const start = layoutPointToOverlay({ x: event.clientX, y: event.clientY }, dragPageScale);
32226startX = start.x;
32227startY = start.y;
32228const rect = overlayPuckRect(button, dragPageScale);
32229originX = rect.left;
32230originY = rect.top;
32231puckBox = { width: rect.width, height: rect.height };
32232pendingPosition = null;
32233currentPosition = { x: originX, y: originY };
32234cancelDragFrame();
32235dragPositionAnchored = false;
32236button.setPointerCapture?.(event.pointerId);
32237});
32238button.addEventListener("pointermove", (event) => {
32239if (!dragging || !puckBox) return;
32240const pointer = layoutPointToOverlay({ x: event.clientX, y: event.clientY }, dragPageScale);
32241const dx = pointer.x - startX;
32242const dy = pointer.y - startY;
32243if (Math.hypot(dx, dy) > 4) moved = true;
32244if (!moved) return;
32245event.preventDefault();
32246if (button.dataset.jpdbReaderMoved !== "true") button.dataset.jpdbReaderMoved = "true";
32247if (!dragPositionAnchored) {
32248dragPositionAnchored = true;
32249applyPuckPosition(button, originX, originY);
32250resetPuckDragTransform(button);
32251button.classList.add("jpdb-reader-fab-dragging");
32252}
32253const position = clampPuckToViewport(puckBox, originX + dx, originY + dy);
32254if (!position) return;
32255scheduleDragFrame(position);
32256}, { passive: false });
32257const finishDrag = (event) => {
32258if (!dragging) return;
32259dragging = false;
32260button.classList.remove("jpdb-reader-fab-dragging");
32261button.releasePointerCapture?.(event.pointerId);
32262cancelDragFrame();
32263if (pendingPosition) {
32264currentPosition = pendingPosition;
32265pendingPosition = null;
32266}
32267resetPuckDragTransform(button);
32268if (!moved || !puckBox || !currentPosition) {
32269puckBox = null;
32270currentPosition = null;
32271dragPositionAnchored = false;
32272return;
32273}
32274const position = clampPuckToViewport(puckBox, currentPosition.x, currentPosition.y);
32275puckBox = null;
32276currentPosition = null;
32277dragPositionAnchored = false;
32278if (!position) return;
32279applyPuckPosition(button, position.x, position.y);
32280if (this.settings) {
32281this.settings.puckPositionX = Math.round(position.x);
32282this.settings.puckPositionY = Math.round(position.y);
32283}
32284this.save();
32285};
32286button.addEventListener("pointerup", finishDrag);
32287button.addEventListener("pointercancel", finishDrag);
32288}
32289}
32290function ocrModeLabel(language, mode) {
32291if (mode === "auto") return uiText$1(language, "puckOcrAuto");
32292if (mode === "manual") return uiText$1(language, "puckOcrManual");
32293return uiText$1(language, "puckOcrOff");
32294}
32295function shouldShowFloatingButton(settings2) {
32296return settings2.showFloatingButton || isCoarsePointerDevice();
32297}
32298function isCoarsePointerDevice() {
32299try {
32300const media = window.matchMedia?.("(pointer: coarse)");
32301if (media) return media.matches;
32302} catch {
32303}
32304return false;
32305}
32306function avoidVideoOverlap(button, settings2, saveSettings2) {
32307if (!canAvoidVideoOverlap(button)) return;
32308const rect = overlayPuckRect(button);
32309const overlap = overlappingVideo(rect);
32310button.classList.toggle("jpdb-reader-fab-over-video", Boolean(overlap));
32311if (!overlap || !shouldMoveAwayFromVideo(button, overlap.video)) return;
32312for (const position of nonOverlappingPuckPositions(rect, overlap.rect)) {
32313movePuck(button, position, settings2, saveSettings2);
32314button.classList.remove("jpdb-reader-fab-over-video");
32315return;
32316}
32317}
32318function canAvoidVideoOverlap(button) {
32319return button.isConnected && !document.fullscreenElement;
32320}
32321function shouldMoveAwayFromVideo(button, video) {
32322return Boolean(video && !button.matches(":hover, :focus, :focus-visible"));
32323}
32324function overlayPuckRect(button, pageScale = overlayViewport().pageScale) {
32325return sourceRectToOverlay(button.getBoundingClientRect(), button, pageScale);
32326}
32327function overlappingVideo(rect) {
32328const { pageScale } = overlayViewport();
32329for (const video of visibleVideos()) {
32330const videoRect = layoutRectToOverlay(video.getBoundingClientRect(), pageScale);
32331if (intersects(rect, videoRect)) return { video, rect: videoRect };
32332}
32333return void 0;
32334}
32335function nonOverlappingPuckPositions(rect, videoRect) {
32336const candidates = [
32337{ x: videoRect.right + 10, y: rect.top },
32338{ x: videoRect.left - rect.width - 10, y: rect.top },
32339{ x: rect.left, y: videoRect.bottom + 10 },
32340{ x: rect.left, y: videoRect.top - rect.height - 10 }
32341];
32342return candidates.map((candidate) => clampPuckToViewport(rect, candidate.x, candidate.y)).filter((position) => Boolean(position)).filter((position) => !intersects(new DOMRect(position.x, position.y, rect.width, rect.height), videoRect));
32343}
32344function movePuck(button, position, settings2, saveSettings2) {
32345applyPuckPosition(button, position.x, position.y);
32346settings2.puckPositionX = Math.round(position.x);
32347settings2.puckPositionY = Math.round(position.y);
32348saveSettings2();
32349}
32350function restoreButtonPosition(button, settings2) {
32351if (settings2.puckPositionX === void 0 || settings2.puckPositionY === void 0) return;
32352applyPuckPosition(button, settings2.puckPositionX, settings2.puckPositionY);
32353}
32354function clampRestoredButtonPosition(button, settings2) {
32355if (settings2.puckPositionX === void 0 || settings2.puckPositionY === void 0) return;
32356requestAnimationFrame(() => {
32357if (!button.isConnected) return;
32358const rect = overlayPuckRect(button);
32359const position = clampPuck(button, rect.left, rect.top);
32360if (!position) return;
32361if (Math.round(rect.left) === Math.round(position.x) && Math.round(rect.top) === Math.round(position.y)) return;
32362applyPuckPosition(button, position.x, position.y);
32363});
32364}
32365function applyPuckPosition(button, x, y) {
32366button.style.setProperty("left", `${x}px`);
32367button.style.setProperty("top", `${y}px`);
32368button.style.setProperty("right", "auto", "important");
32369button.style.setProperty("bottom", "auto", "important");
32370}
32371function applyPuckDragTransform(button, dx, dy) {
32372button.style.setProperty("transform", `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`, "important");
32373}
32374function resetPuckDragTransform(button) {
32375button.style.removeProperty("transform");
32376}
32377function clampPuck(button, x, y) {
32378const rect = overlayPuckRect(button);
32379return clampPuckToViewport(rect, x, y);
32380}
32381function clampPuckToViewport(box, x, y) {
32382const margin = 8;
32383const viewport = overlayViewport();
32384if (!canClampPuck(box, x, y, margin, viewport)) return null;
32385return {
32386x: Math.max(margin, Math.min(viewport.width - box.width - margin, x)),
32387y: Math.max(margin, Math.min(viewport.height - box.height - margin, y))
32388};
32389}
32390function canClampPuck(box, x, y, margin, viewport) {
32391if (!finitePuckPosition(x, y)) return false;
32392if (!finiteViewport(viewport)) return false;
32393if (!hasViewportRoom(viewport, margin)) return false;
32394return hasVisiblePuckRect(box);
32395}
32396function finitePuckPosition(x, y) {
32397return Number.isFinite(x) && Number.isFinite(y);
32398}
32399function finiteViewport(viewport) {
32400return Number.isFinite(viewport.width) && Number.isFinite(viewport.height);
32401}
32402function hasViewportRoom(viewport, margin) {
32403return viewport.width > margin * 2 && viewport.height > margin * 2;
32404}
32405function hasVisiblePuckRect(box) {
32406return box.width > 0 && box.height > 0;
32407}
32408function visibleVideos() {
32409return Array.from(document.querySelectorAll("video")).filter((video) => video instanceof HTMLVideoElement).filter((video) => {
32410const rect = video.getBoundingClientRect();
32411return rect.width > 120 && rect.height > 90;
32412});
32413}
32414function intersects(a, b) {
32415return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
32416}
32417class DisabledJitenPublicVocabularyClient {
32418lookup() {
32419return Promise.resolve(null);
32420}
32421lookupMany() {
32422return Promise.resolve(new Map());
32423}
32424parse() {
32425return Promise.resolve([]);
32426}
32427hydrateCards() {
32428return Promise.resolve(new Map());
32429}
32430clear() {
32431}
32432}
32433const CompanionBackedJitenPublicVocabularyClient = class {
32434constructor(options = {}) {
32435const Client = yomuJitenCompanion()?.JitenPublicVocabularyClient;
32436return Client ? new Client(options) : new DisabledJitenPublicVocabularyClient();
32437}
32438};
32439const JITEN_BACKGROUND_DETAIL_TIMEOUT_MS = 4e3;
32440const parsedCardHydrationKey = (card) => yomuJitenCompanion()?.parsedCardHydrationKey?.(card) ?? `${card.vid}:${card.sid}`;
32441const publicJitenBackoffRemainingMs = () => yomuJitenCompanion()?.publicJitenBackoffRemainingMs?.() ?? 0;
32442const renderJitenKanjiInfo = (...args) => yomuKanjiStudyCompanion()?.renderJitenKanjiInfo?.(...args) ?? "";
32443const renderJitenKanjiKeywordLine = (...args) => yomuKanjiStudyCompanion()?.renderJitenKanjiKeywordLine?.(...args) ?? "";
32444const jitenKanjiOriginFactLabels = (...args) => yomuKanjiStudyCompanion()?.jitenKanjiOriginFactLabels?.(...args) ?? [];
32445const filterJitenKanjiWords = async (...args) => {
32446await yomuKanjiStudyCompanion()?.filterJitenKanjiWords?.(...args);
32447};
32448const loadMoreJitenKanjiWords = async (...args) => {
32449await yomuKanjiStudyCompanion()?.loadMoreJitenKanjiWords?.(...args);
32450};
32451class DisabledJpdbClient {
32452parse() {
32453return Promise.resolve([]);
32454}
32455reviewCard() {
32456return Promise.resolve();
32457}
32458addToDeck() {
32459return Promise.resolve();
32460}
32461listDecks() {
32462return Promise.resolve([]);
32463}
32464ping() {
32465return Promise.resolve(false);
32466}
32467listDeckCards() {
32468return Promise.resolve([]);
32469}
32470isInUserDeckPool() {
32471return Promise.resolve(false);
32472}
32473removeFromDeck() {
32474return Promise.resolve();
32475}
32476getCard() {
32477return void 0;
32478}
32479clear() {
32480}
32481refreshCardState() {
32482return Promise.resolve();
32483}
32484}
32485const CompanionBackedJpdbClient = class {
32486constructor(getApiKey, getProxyUrl = () => "") {
32487const Client = yomuJpdbCompanion()?.JpdbClient;
32488return Client ? new Client(getApiKey, getProxyUrl) : new DisabledJpdbClient();
32489}
32490};
32491class DisabledJpdbPublicPitchClient {
32492lookup() {
32493return Promise.resolve([]);
32494}
32495}
32496const CompanionBackedJpdbPublicPitchClient = class {
32497constructor(getCorsProxyUrl = () => "") {
32498const Client = yomuJpdbCompanion()?.JpdbPublicPitchClient;
32499return Client ? new Client(getCorsProxyUrl) : new DisabledJpdbPublicPitchClient();
32500}
32501};
32502const renderedJpdbRelatedWords = (...args) => yomuJpdbCompanion()?.renderedJpdbRelatedWords?.(...args) ?? [];
32503const BUNPRO_LOCALE_PREFIX = /^\/(?:en|es|fr|id|ja)(?=\/|$)/;
32504function isPageEnhancementHost() {
32505return isJpdbHost() || isJitenHost() || isBunproHost();
32506}
32507function isPageEnhancementReady() {
32508if (isJpdbHost()) return true;
32509if (isBunproHost()) return isBunproEnhanceablePage();
32510return isJitenHost() && isJitenEnhanceablePage();
32511}
32512function currentPageEnhancementLayoutContext() {
32513const pathname = location.pathname;
32514if (isJpdbHost() && pathname.startsWith("/review")) return "review";
32515if (isJitenHost() && pathname.startsWith("/srs/study")) return "review";
32516if (!isBunproHost()) return "entry";
32517const bunproPathname2 = pathname.replace(BUNPRO_LOCALE_PREFIX, "") || "/";
32518const isReviewRoute = bunproPathname2 === "/reviews" || bunproPathname2.startsWith("/reviews/");
32519return isReviewRoute || Boolean(document.querySelector("#js-quiz")) ? "review" : "entry";
32520}
32521function isCurrentKanjiSurface() {
32522if (isBunproHost()) return false;
32523if (isJitenHost()) return isJitenKanjiPage();
32524return isKanjiPage() || isKanjiReviewFront() || isKanjiReviewBack();
32525}
32526function currentPageKanji() {
32527if (isBunproHost()) return "";
32528return isJitenHost() ? extractCurrentJitenKanji() : extractCurrentKanji();
32529}
32530function currentPageTermTarget() {
32531if (isBunproHost()) return currentBunproTermTarget();
32532return isJitenHost() ? currentJitenTermTarget() : currentJpdbTermTarget();
32533}
32534function currentPageLocalDictionaryTargets() {
32535if (isBunproHost()) return currentBunproLocalDictionaryTargets();
32536return isJitenHost() ? currentJitenLocalDictionaryTargets() : currentLocalDictionaryTargets();
32537}
32538class CompanionBackedJpdbVocabularyClient {
32539client;
32540constructor(getCorsProxyUrl = () => "") {
32541const Client = yomuJpdbCompanion()?.JpdbVocabularyClient;
32542this.client = Client ? new Client(getCorsProxyUrl) : void 0;
32543}
32544clear() {
32545this.client?.clear();
32546}
32547async lookup(...args) {
32548if (!this.client) throw new Error("JPDB vocabulary companion is unavailable.");
32549return this.client.lookup(...args);
32550}
32551async search(...args) {
32552if (!this.client) throw new Error("JPDB vocabulary companion is unavailable.");
32553return this.client.search(...args);
32554}
32555}
32556function updateKanjiMiningControlsMount(popover, controls, setMiningControlsExpanded2) {
32557yomuKanjiStudyCompanion()?.updateKanjiMiningControlsMount?.(popover, controls, setMiningControlsExpanded2);
32558}
32559const SINGLE_HIRAGANA_MORA_RE = new RegExp(`^[${HIRAGANA_WITH_PROLONGED}]$`, "u");
32560function normalizedLookupText(text2) {
32561return text2.replace(/\s+/g, " ").trim();
32562}
32563function isLookupableTargetLanguageText(text2) {
32564return activeLearningTarget().isLookupableText(text2);
32565}
32566function lookupCandidateSentence(text2, start = 0, end = text2.length) {
32567const sentence = sentenceAroundRange(text2, start, end) || normalizedLookupText(text2);
32568return isLookupableTargetLanguageText(sentence) ? sentence : "";
32569}
32570function isLowValuePitchEnrichmentToken(token) {
32571return isLowValuePointerTextToken(token);
32572}
32573function isLowValuePointerTextToken(token) {
32574const spelling = token.card.spelling.trim();
32575return SINGLE_HIRAGANA_MORA_RE.test(spelling);
32576}
32577function preferredRenderedWordSentence(nearest, tokenSentence) {
32578const cleanNearest = normalizedLookupText(nearest);
32579const cleanTokenSentence = normalizedLookupText(tokenSentence);
32580if (cleanTokenSentence && shouldPreferTokenSentence(cleanNearest, cleanTokenSentence)) return cleanTokenSentence;
32581if (cleanTokenSentence && cleanTokenSentence.length > cleanNearest.length + 2) return cleanTokenSentence;
32582return cleanNearest || cleanTokenSentence || void 0;
32583}
32584function shouldPreferTokenSentence(nearest, tokenSentence) {
32585if (!nearest) return true;
32586if (!compactLookupText(nearest).includes(compactLookupText(tokenSentence))) return true;
32587return looksLikeNoisyRenderedContext(nearest);
32588}
32589function compactLookupText(text2) {
32590return normalizedLookupText(text2).replace(/\s+/g, "");
32591}
32592function looksLikeNoisyRenderedContext(text2) {
32593const timecodes = text2.match(/\d{1,2}:\d{2}/g)?.length ?? 0;
32594if (timecodes >= 2) return true;
32595const digitish = text2.match(/[0-90-9::]/g)?.length ?? 0;
32596if (digitish >= 12 && digitish / Math.max(1, Array.from(text2).length) > 0.12) return true;
32597return /動画全編を視聴|watch full video|view full video/i.test(text2);
32598}
32599function pitchEnrichmentPriority(token) {
32600if (!token.card.reading.trim()) return 0;
32601return token.card.source === "fallback" ? 1 : 2;
32602}
32603function boundedPublicPitchLookupReservation(candidateCount, perCallLimit, totalLimit) {
32604return Math.min(candidateCount, Math.max(perCallLimit, Math.floor(totalLimit ?? candidateCount)));
32605}
32606function pitchEnrichmentTokenForCard(card) {
32607return {
32608card,
32609start: 0,
32610end: card.spelling.length,
32611length: card.spelling.length,
32612rubies: [],
32613pitchClass: ""
32614};
32615}
32616function annotationPowerState(settings2, hasFuriganaChannel) {
32617if (settings2.annotationsPaused) return "paused";
32618return hasFuriganaChannel && !furiganaEnabled(settings2) ? "no-furigana" : "on";
32619}
32620function planAnnotationPowerTransition(settings2, hasFuriganaChannel, defaultFuriganaMode) {
32621const state = annotationPowerState(settings2, hasFuriganaChannel);
32622if (!hasFuriganaChannel) return { kind: state === "paused" ? "resume" : "pause" };
32623return JAPANESE_POWER_TRANSITIONS[state](settings2, defaultFuriganaMode);
32624}
32625function applyAnnotationPowerTransition(transition, effects) {
32626const actions = {
32627"hide-furigana": () => effects.hideFurigana(
32628transition.kind === "hide-furigana" ? transition.rememberedMode : ""
32629),
32630pause: () => effects.pause(),
32631resume: () => effects.resume(transition.kind === "resume" ? transition.furiganaMode : void 0)
32632};
32633return actions[transition.kind]();
32634}
32635const JAPANESE_POWER_TRANSITIONS = Object.freeze({
32636on: (settings2) => ({
32637kind: "hide-furigana",
32638rememberedMode: settings2.furiganaMode === "off" ? "" : settings2.furiganaMode
32639}),
32640"no-furigana": () => ({ kind: "pause" }),
32641paused: (settings2, defaultMode) => ({
32642kind: "resume",
32643furiganaMode: restoredFuriganaMode(settings2, defaultMode)
32644})
32645});
32646function furiganaEnabled(settings2) {
32647return settings2.showFurigana && settings2.furiganaMode !== "off";
32648}
32649function restoredFuriganaMode(settings2, defaultMode) {
32650if (settings2.puckFuriganaModeBeforeHide) return settings2.puckFuriganaModeBeforeHide;
32651return settings2.furiganaMode === "off" ? defaultMode : settings2.furiganaMode;
32652}
32653function renderedWordLookupText(word) {
32654return normalizedLookupText(word.dataset.expression || readerWordSurfaceText(word));
32655}
32656function renderedWordCacheMatches(word, card) {
32657const expression = normalizedLookupText(word.dataset.expression ?? "");
32658const reading = normalizedLookupText(word.dataset.reading ?? "");
32659if (expression && !cardMatchesRenderedLookupValue(card, expression)) return false;
32660if (reading && card.reading && !cardMatchesRenderedLookupValue(card, reading)) return false;
32661return true;
32662}
32663function cardMatchesRenderedLookupValue(card, value) {
32664return normalizedLookupText(card.spelling) === value || normalizedLookupText(card.reading) === value;
32665}
32666function renderedWordCardForLookup(word, cachedCard) {
32667if (cachedCard && !renderedWordCacheMatches(word, cachedCard)) return void 0;
32668if (!cachedCard) return void 0;
32669const reading = normalizedLookupText(word.dataset.reading ?? "");
32670const pitchAccent = renderedWordPitchAccent(word.dataset.pitchAccent ?? "");
32671const explicitSpelling = normalizedLookupText(word.dataset.expression || "");
32672if (explicitSpelling && explicitSpelling !== cachedCard.spelling) cachedCard.spelling = explicitSpelling;
32673if (reading && reading !== cachedCard.reading) cachedCard.reading = reading;
32674if (pitchAccent.length && !cachedCard.pitchAccent.length) cachedCard.pitchAccent = pitchAccent;
32675return cachedCard;
32676}
32677function renderedWordPitchAccent(value) {
32678return value.split("|").map((pattern) => pattern.trim()).filter((pattern) => /^[HL]+$/u.test(pattern));
32679}
32680const MASS_REVIEW_STATES = new Set(["due", "failed", "learning", "new"]);
32681function visibleJitenReviewableWords() {
32682const seen = new Set();
32683return renderedWordsInRoot(document).filter((word) => {
32684const key = visibleJitenReviewIdentity(word);
32685if (!key || seen.has(key)) return false;
32686if (!elementIsInViewport(word)) return false;
32687seen.add(key);
32688return true;
32689});
32690}
32691function visibleJitenReviewIdentity(word) {
32692if (renderedWordPrivateValue(word, "cardSource") !== "jiten") return null;
32693if (!MASS_REVIEW_STATES.has(String(renderedWordPrivateValue(word, "cardState")))) return null;
32694const vid = Number(renderedWordPrivateValue(word, "vid"));
32695if (!(vid > 0)) return null;
32696return `${vid}:${Number(renderedWordPrivateValue(word, "sid"))}`;
32697}
32698function elementIsInViewport(element) {
32699const rect = element.getBoundingClientRect();
32700return rect.bottom > 0 && rect.top < window.innerHeight && rect.width > 0 && rect.height > 0;
32701}
32702function jitenWordCardForMassReview(word) {
32703const vid = Number(renderedWordPrivateValue(word, "vid"));
32704const sid = Number(renderedWordPrivateValue(word, "sid"));
32705const cardState = renderedWordPrivateValue(word, "cardState");
32706return {
32707vid,
32708sid,
32709rid: 0,
32710spelling: word.dataset.expression || readerWordSurfaceText(word),
32711reading: word.dataset.reading ?? "",
32712frequencyRank: null,
32713partOfSpeech: [],
32714meanings: [],
32715cardState: cardState ? [cardState] : [],
32716pitchAccent: [],
32717wordWithReading: null,
32718source: "jiten",
32719jitenWordId: vid,
32720jitenReadingIndex: sid
32721};
32722}
32723function unconfirmedRenderedWordSpan(word, card, context) {
32724if (card?.source && card.source !== "fallback") return null;
32725const sentence = context.sentence || word.dataset.sentence || "";
32726if (!sentence) return null;
32727const start = Number(word.dataset.tokenStart);
32728const end = Number(word.dataset.tokenEnd);
32729if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
32730if (start < 0 || end <= start || end > sentence.length) return null;
32731return { sentence, start, end };
32732}
32733class PopupNavigationController {
32734constructor(hasActiveKanjiPopover) {
32735this.hasActiveKanjiPopover = hasActiveKanjiPopover;
32736}
32737wordStack = [];
32738currentWord;
32739kanjiStack = [];
32740currentKanji;
32741updateWord(card, sentence, trigger, mode, previousNavigationEntry) {
32742if (trigger !== "modal") {
32743this.clearWord();
32744return;
32745}
32746const next = { card, sentence };
32747if (mode === "reset") this.wordStack = [];
32748else this.pushPreviousWord(mode, next, previousNavigationEntry);
32749this.currentWord = next;
32750}
32751updateKanji(card, kanji, sentence, mode) {
32752const next = { card, kanji, sentence };
32753if (mode === "reset") {
32754this.kanjiStack = [];
32755this.currentKanji = next;
32756return;
32757}
32758const previous = this.previousKanjiToPush(mode, next);
32759if (previous) this.pushDistinctKanjiEntry(previous);
32760this.currentKanji = next;
32761}
32762clearWord() {
32763this.wordStack = [];
32764this.currentWord = void 0;
32765}
32766clearKanji() {
32767this.kanjiStack = [];
32768this.currentKanji = void 0;
32769}
32770activeKanjiEntry() {
32771if (!this.currentKanji || !this.hasActiveKanjiPopover()) return void 0;
32772return this.kanjiEntry(this.currentKanji);
32773}
32774activeWordEntry() {
32775return this.currentWord ? this.wordEntry(this.currentWord) : void 0;
32776}
32777popPreviousWord() {
32778return this.wordStack.pop();
32779}
32780popPreviousKanji() {
32781return this.kanjiStack.pop();
32782}
32783renderWordHistory(language, trigger) {
32784if (trigger !== "modal") return "";
32785const previous = this.wordStack[this.wordStack.length - 1];
32786if (!previous) return "";
32787return renderModalNavigation({
32788backAction: "word-history-back",
32789backTitle: previous.kind === "kanji" ? `${uiText$1(language, "backToKanji")}: ${previous.kanji}` : `${uiText$1(language, "backToWord")}: ${previous.card.spelling}`,
32790label: previous.kind === "kanji" ? previous.kanji : previous.card.spelling
32791});
32792}
32793kanjiModalBack(card, language) {
32794const previousKanji = this.kanjiStack[this.kanjiStack.length - 1];
32795return previousKanji ? {
32796backAction: "kanji-history-back",
32797backTitle: `${uiText$1(language, "backToKanji")}: ${previousKanji.kanji}`,
32798label: previousKanji.kanji
32799} : {
32800backAction: "word-back",
32801backTitle: `${uiText$1(language, "backToWord")}: ${card.spelling}`,
32802label: card.spelling
32803};
32804}
32805pushPreviousWord(mode, next, previousNavigationEntry) {
32806const previous = previousNavigationEntry ?? this.previousWordEntry(next);
32807if (!this.shouldPushPreviousWord(mode, previous, next)) return;
32808this.pushDistinctWordEntry(previous);
32809}
32810shouldPushPreviousWord(mode, previous, next) {
32811if (mode !== "push-current") return false;
32812if (!previous) return false;
32813return !this.isSameEntryAsWord(previous, next);
32814}
32815pushDistinctWordEntry(previous) {
32816const lastStackEntry = this.wordStack[this.wordStack.length - 1];
32817if (!lastStackEntry || !this.isSamePopupEntry(lastStackEntry, previous)) this.wordStack.push(previous);
32818}
32819previousWordEntry(next) {
32820return this.currentWord && !this.isSameCard(this.currentWord, next) ? this.wordEntry(this.currentWord) : void 0;
32821}
32822previousKanjiToPush(mode, next) {
32823const current = this.currentKanji;
32824if (mode !== "push-current") return void 0;
32825if (!current) return void 0;
32826return this.isSameKanji(current, next) ? void 0 : current;
32827}
32828pushDistinctKanjiEntry(entry) {
32829const lastStackEntry = this.kanjiStack[this.kanjiStack.length - 1];
32830if (!lastStackEntry || !this.isSameKanji(lastStackEntry, entry)) this.kanjiStack.push(entry);
32831}
32832wordEntry(entry) {
32833return { kind: "word", card: entry.card, sentence: entry.sentence };
32834}
32835kanjiEntry(entry) {
32836return { kind: "kanji", card: entry.card, sentence: entry.sentence, kanji: entry.kanji };
32837}
32838isSameCard(first, second) {
32839return cardKey(first.card) === cardKey(second.card);
32840}
32841isSameKanji(first, second) {
32842return this.isSameCard(first, second) && first.kanji === second.kanji;
32843}
32844isSameEntryAsWord(entry, word) {
32845return entry.kind === "word" && this.isSameCard(entry, word);
32846}
32847isSamePopupEntry(first, second) {
32848if (first.kind !== second.kind) return false;
32849if (first.kind === "kanji" && second.kind === "kanji") return this.isSameKanji(first, second);
32850return this.isSameCard(first, second);
32851}
32852}
32853function renderModalNavigation(options) {
32854return `
32855<div class="jpdb-reader-modal-nav">
32856<button class="jpdb-reader-icon-mini" type="button" data-action="${escapeHtml(options.backAction)}" title="${escapeHtml(options.backTitle)}" aria-label="${escapeHtml(options.backTitle)}">←</button>
32857<span title="${escapeHtml(options.label)}">${escapeHtml(options.label)}</span>
32858${options.controlsHtml ?? ""}
32859</div>
32860`;
32861}
32862const READER_DOCUMENT_CLICK_IGNORE_SELECTOR = [
32863"[data-jpdb-reader-surface-ignore]",
32864"[data-jpdb-reader-interaction-ignore]",
32865'[data-jpdb-reader-root] [data-action="kanji"][data-kanji]',
32866"[data-yomu-jpdb-addon] [data-action]",
32867"[data-settings-preview-lookup]",
32868".jpdb-reader-settings .jpdb-reader-word",
32869"[data-jpdb-reader-root] .jpdb-reader-example-translation",
32870".jpdb-reader-onboarding"
32871].join(",");
32872const NATIVE_PAGE_LOOKUP_BLOCK_SELECTOR = [
32873"a[href]",
32874"button",
32875"input",
32876"textarea",
32877"select",
32878"summary",
32879'[role="button"]',
32880'[contenteditable="true"]',
32881"[data-audio]",
32882"[onclick]",
32883".subsection-immersion-kit",
32884'[class*="immersion" i]',
32885'[class*="audio" i]',
32886'[class*="sound" i]',
32887'[class*="speaker" i]',
32888'[class~="play" i]',
32889'[class*="-play" i]',
32890'[class*="play-" i]',
32891'[class~="control" i]',
32892'[class*="-control" i]',
32893'[class*="control-" i]',
32894'[class~="button" i]',
32895'[class*="-button" i]',
32896'[class*="button-" i]',
32897'[class~="icon" i]',
32898'[class*="-icon" i]',
32899'[class*="icon-" i]'
32900].join(",");
32901const NATIVE_CLICKABLE_SELECTOR = [
32902"a[href]",
32903"button",
32904"summary",
32905"[onclick]",
32906'[role="button"]',
32907'[role="menuitem"]',
32908'[role="menuitemcheckbox"]',
32909'[role="menuitemradio"]',
32910'[role="option"]',
32911'[role="tab"]',
32912'[role="switch"]',
32913'[role="checkbox"]',
32914'[role="radio"]'
32915].join(",");
32916const READER_SURFACE_SELECTOR = "[data-jpdb-reader-root], .jpdb-reader-popover";
32917function nativeClickableAncestor(target) {
32918const link = navigableLinkAncestor(target);
32919if (link) return link;
32920if (target && isJpdbHost() && isActiveNativePageReaderWord(target)) return null;
32921const clickable = closestTarget(target, NATIVE_CLICKABLE_SELECTOR);
32922if (!clickable || clickable.closest(READER_SURFACE_SELECTOR)) return null;
32923if (clickable instanceof HTMLAnchorElement && !hasNavigableHref(clickable)) return null;
32924return clickable;
32925}
32926function navigableLinkAncestor(target) {
32927const anchor = closestTarget(target, "a[href]");
32928if (!anchor || anchor.closest(READER_SURFACE_SELECTOR) || !hasNavigableHref(anchor)) return null;
32929return anchor;
32930}
32931function hasNavigableHref(anchor) {
32932const href = anchor.getAttribute("href")?.trim() ?? "";
32933return Boolean(href) && href !== "#" && !href.toLowerCase().startsWith("javascript:");
32934}
32935function shouldIgnoreDocumentClickTarget(target) {
32936return Boolean(closestTarget(target, READER_DOCUMENT_CLICK_IGNORE_SELECTOR)) || isNativePageLookupBlocked(target);
32937}
32938function isNativePageLookupBlocked(target) {
32939if (!isJpdbHost() || !target || closestTarget(target, "[data-jpdb-reader-root]")) return false;
32940if (isActiveNativePageReaderWord(target)) return false;
32941return Boolean(closestTarget(target, NATIVE_PAGE_LOOKUP_BLOCK_SELECTOR));
32942}
32943function isActiveNativePageReaderWord(target) {
32944const word = closestTarget(target, ".jpdb-reader-word");
32945return Boolean(word && word.dataset.jpdbReaderPassive !== "true" && !closestTarget(word, "[data-jpdb-reader-root]"));
32946}
32947function closestTarget(target, selector) {
32948const closest = target?.closest;
32949return typeof closest === "function" ? closest.call(target, selector) : null;
32950}
32951function isOcrLineFrameWord(word) {
32952return word.classList.contains("jpdb-ocr-line") && !renderedWordPrivateValue(word, "vid") && !renderedWordPrivateValue(word, "sid");
32953}
32954function ocrLineWordAtPoint(line, x, y) {
32955const words = renderedWordsInRoot(line);
32956const hits = words.map((word) => ocrPointerWordHit(word, x, y)).filter((hit) => hit !== null);
32957return closestOcrPointerWord(hits, true) ?? closestOcrPointerWord(hits, false) ?? null;
32958}
32959function ocrPointerWordHit(word, x, y) {
32960const rect = word.getBoundingClientRect();
32961const exact = pointInsideExpandedRect(rect, x, y, 0);
32962if (!exact && !pointInsideExpandedRect(rect, x, y, 8)) return null;
32963return {
32964word,
32965exact,
32966score: exact ? rectCenterDistance(rect, x, y) : rectEdgeDistance(rect, x, y)
32967};
32968}
32969function closestOcrPointerWord(hits, exact) {
32970return hits.filter((hit) => hit.exact === exact).sort((left, right) => left.score - right.score)[0]?.word;
32971}
32972function singleKanjiOcrLookupCharacter(word) {
32973if (!word.closest(".jpdb-ocr-line")) return "";
32974const surface = normalizedLookupText(readerWordSurfaceText(word) || word.dataset.expression || "");
32975const characters = Array.from(surface);
32976return characters.length === 1 && targetCanLookupCharacter(characters[0] ?? "") ? characters[0] : "";
32977}
32978function canLookupReaderWordElement(word) {
32979if (isOcrLineFrameWord(word)) return false;
32980if (word.dataset.jpdbReaderPassive === "true") return false;
32981if (word.closest(".jpdb-reader-control-text-mirror")) return true;
32982if (isNativePageLookupBlocked(word)) return false;
32983if (!word.closest("[data-jpdb-reader-root]")) return true;
32984return Boolean(word.closest(".jpdb-subtitle-player, .jpdb-subtitle-list, .jpdb-ocr-layer, .jpdb-reader-popover, .yomu-jpdb-page-addon"));
32985}
32986function canClickLookupPassiveReaderWordElement(word) {
32987if (isOcrLineFrameWord(word)) return false;
32988if (word.dataset.jpdbReaderPassive !== "true") return false;
32989if (!word.classList.contains("jpdb-reader-scan-word")) return false;
32990return !isNativePageLookupBlocked(word);
32991}
32992function canHoverLookupReaderWordElement(word, hasHoverLookupShortcut) {
32993if (isOcrLineFrameWord(word)) return false;
32994if (word.closest(".jpdb-reader-popover")) return false;
32995if (word.closest(".jpdb-reader-control-text-mirror")) return !word.closest("[data-jpdb-reader-root]") || hasHoverLookupShortcut;
32996if (isSettingsNativeControlWord(word)) return hasHoverLookupShortcut;
32997if (isSettingsReaderWord(word)) return true;
32998if (isNativePageLookupBlocked(word) && word.dataset.jpdbReaderPassive !== "true") return false;
32999if (!word.closest("[data-jpdb-reader-root]")) return true;
33000if (word.closest(".jpdb-subtitle-player, .jpdb-subtitle-list, .jpdb-ocr-layer, .jpdb-reader-newtab-immersion, .yomu-jpdb-page-addon")) return true;
33001return hasHoverLookupShortcut && Boolean(word.closest(".jpdb-reader-newtab, .jpdb-reader-settings"));
33002}
33003function isSettingsReaderWord(word) {
33004return Boolean(word.closest(".jpdb-reader-settings"));
33005}
33006function isSettingsNativeControlWord(word) {
33007return Boolean(word.closest(".jpdb-reader-settings") && word.closest('a[href],button,input,select,textarea,[role="button"],[role="checkbox"],[role="link"],[role="menuitem"],[role="option"],[role="radio"],[role="switch"],[role="tab"],[data-action]'));
33008}
33009function currentLookupNavigationWord(words, activePopoverAnchor, keyboardActiveWord) {
33010const activeAnchor = activePopoverAnchor?.isConnected ? activePopoverAnchor : void 0;
33011if (activeAnchor && words.includes(activeAnchor)) return activeAnchor;
33012if (keyboardActiveWord?.isConnected && words.includes(keyboardActiveWord)) return keyboardActiveWord;
33013return void 0;
33014}
33015const IMAGE_READING_MIN_AREA = 15e4;
33016const IMAGE_READING_MIN_VIEWPORT_RATIO = 0.35;
33017const IMAGE_READING_MIN_EDGE = 240;
33018function documentLooksLikeImageReadingPage() {
33019if (document.querySelector('canvas[data-yomu-canvas-ocr="on"], [data-yomu-canvas-ocr="on"] canvas')) return true;
33020const images = Array.from(document.images).filter((image) => !image.closest("[data-jpdb-reader-root]"));
33021if (images.length === 1 && isStandaloneImageDocument(images[0])) return true;
33022return images.some(imageLooksLikeReadableSurface);
33023}
33024function isStandaloneImageDocument(image) {
33025const bodyText = document.body?.textContent?.replace(/\s+/g, "").trim() ?? "";
33026return !bodyText && Boolean(image.currentSrc || image.src);
33027}
33028function imageLooksLikeReadableSurface(image) {
33029if (!image.currentSrc && !image.src) return false;
33030const rect = image.getBoundingClientRect();
33031const visibleWidth = Math.max(0, Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0));
33032const visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
33033const visibleArea = visibleWidth * visibleHeight;
33034const viewportArea = Math.max(1, window.innerWidth * window.innerHeight);
33035return visibleArea >= IMAGE_READING_MIN_AREA && visibleArea / viewportArea >= IMAGE_READING_MIN_VIEWPORT_RATIO && Math.min(visibleWidth, visibleHeight) >= IMAGE_READING_MIN_EDGE;
33036}
33037function replaceOptionalElement(parent, selector, html, before = null) {
33038const existing = parent.querySelector(selector);
33039const next = htmlToFirstElement(html);
33040if (existing && next) {
33041existing.replaceWith(next);
33042return;
33043}
33044if (existing) {
33045existing.remove();
33046return;
33047}
33048if (next) parent.insertBefore(next, before);
33049}
33050function updateRenderedPitch(popover, card, metaEntries, settings2, dictionaryLabel = (name) => name) {
33051if (!settings2.showPitchAccent) return;
33052const spelling = popover.querySelector(".jpdb-reader-spelling");
33053if (spelling && cardUsesPitchAccentPronunciation(card)) {
33054const reading = cardPronunciationReading(card) || card.reading;
33055setRenderedWordPitchClass(spelling, getPitchClass(card.pitchAccent, reading));
33056}
33057const tools = popover.querySelector(".jpdb-reader-card-tools");
33058if (!tools) return;
33059replaceOptionalElement(
33060tools,
33061".jpdb-reader-pronunciation, .jpdb-reader-pitch",
33062renderPronunciation({ card, settings: settings2, metaEntries, dictionaryLabel }),
33063tools.firstElementChild
33064);
33065}
33066function normalizeOcrScannerLinesInRoot(root, settings2) {
33067if (!(root instanceof HTMLElement)) return;
33068const lineText = root.closest(".jpdb-ocr-line-text");
33069if (!lineText?.closest(".jpdb-ocr-line")) return;
33070const normalize = yomuNormalizeOcrRenderedText();
33071if (!normalize) return;
33072normalize(lineText, isPopupLookupEnabled(settings2));
33073}
33074function applyPublicVocabularyFurigana(word, card, settings2) {
33075if (word.closest("ruby")) return false;
33076const ocrLine = word.closest(".jpdb-ocr-line");
33077const surface = readerWordSurfaceText(word).trim() || word.dataset.expression || card.spelling;
33078const renderSettings = publicVocabularyFuriganaSettings(word, settings2);
33079if (shouldHideFuriganaForCardState(renderSettings, publicVocabularyFuriganaCardState(word, card))) {
33080return clearPublicVocabularyFurigana(word, surface, ocrLine, isPopupLookupEnabled(settings2));
33081}
33082if (rendersWholeCardReading(word, card)) {
33083if (ocrLine) yomuNormalizeOcrRenderedText()?.(word, isPopupLookupEnabled(settings2));
33084if (ocrLine) ocrLine.dataset.hasFuri = "true";
33085return false;
33086}
33087const rubies = inferredInflectedSurfaceRubies(surface, card.spelling, card.reading);
33088const token = {
33089card,
33090start: 0,
33091end: surface.length,
33092length: surface.length,
33093rubies,
33094pitchClass: word.dataset.pitchClass ?? "",
33095sentence: word.dataset.sentence
33096};
33097if (!shouldApplyPublicVocabularyFurigana(card, surface, token, renderSettings, rubies)) return false;
33098if (!replaceRenderedWordFurigana(word, surface, token)) return false;
33099if (ocrLine) yomuNormalizeOcrRenderedText()?.(word, isPopupLookupEnabled(settings2));
33100if (ocrLine) ocrLine.dataset.hasFuri = "true";
33101return true;
33102}
33103function publicVocabularyFuriganaCardState(word, card) {
33104if (card.provisionalState === true && renderedWordPrivateValue(word, "stateProvenance") === "authoritative" && renderedWordPrivateValue(word, "cardState")) {
33105return primaryCardState([renderedWordPrivateValue(word, "cardState")]);
33106}
33107return primaryCardState(card.cardState);
33108}
33109function rendersWholeCardReading(word, card) {
33110const rendered = Array.from(word.querySelectorAll("rt")).map((rt) => rt.textContent ?? "").join("");
33111if (!rendered.trim()) return false;
33112return comparableReading(rendered) === comparableReading(card.reading);
33113}
33114function comparableReading(value) {
33115return value.replace(/\s+/g, "").replace(/[ァ-ヶ]/g, (character) => String.fromCharCode(character.charCodeAt(0) - 96));
33116}
33117function clearPublicVocabularyFurigana(word, surface, ocrLine, isolatePageScanners) {
33118if (!word.classList.contains("jpdb-reader-has-furi") && !word.querySelector(".jpdb-reader-furi, rt")) return false;
33119clearRenderedWordFurigana(word, surface);
33120if (!ocrLine) return true;
33121yomuNormalizeOcrRenderedText()?.(word, isolatePageScanners);
33122if (!ocrLine.querySelector(".jpdb-reader-word.jpdb-reader-has-furi")) delete ocrLine.dataset.hasFuri;
33123return true;
33124}
33125function publicVocabularyFuriganaSettings(word, settings2) {
33126if (!word.closest('[data-yomu-furigana-mode="all"]')) return settings2;
33127if (settings2.showFurigana && settings2.furiganaMode === "all") return settings2;
33128return { ...settings2, showFurigana: true, furiganaMode: "all" };
33129}
33130function applyAnkiLookupToRenderedWord(word, ankiLookup, language, options = {}) {
33131if (!ankiLookup.primary) return applyEmptyAnkiLookupToRenderedWord(word, ankiLookup, language, options);
33132applyExistingAnkiLookupToRenderedWord(word, ankiLookup.state, ankiLookup.primary, language);
33133}
33134function applyEmptyAnkiLookupToRenderedWord(word, ankiLookup, language, options) {
33135if (ankiLookup.trusted === false) return;
33136if ([options.preserveExistingEmpty, renderedWordHasAnkiState(word)].every(Boolean)) {
33137preserveAnkiContrastOnNextRefresh(word);
33138return;
33139}
33140clearRenderedWordAnkiState(word);
33141applyRenderedWordAnkiState(word, ankiLookup.state, language);
33142}
33143function applyExistingAnkiLookupToRenderedWord(word, state, primary, language) {
33144const deckNames = primary.deckNames ?? [];
33145clearRenderedWordAnkiState(word);
33146applyRenderedWordAnkiState(word, state, language, deckNames);
33147applyAnkiDeckMembershipToRenderedWord(word, deckNames);
33148}
33149function applyRenderedWordAnkiState(word, state, language, deckNames = []) {
33150updateRenderedWordPrivateState(word, { ankiState: state, ankiDecks: deckNames.join(", ") || void 0 });
33151if (!currentAccountDataSurfaceIsTrusted()) {
33152word.classList.add(`jpdb-${state}`);
33153syncWordReviewLane(word);
33154word.removeAttribute("title");
33155return;
33156}
33157word.classList.add(`anki-${state}`);
33158word.title = `Anki: ${cardStateLabel$1(state, language)}${deckNames.length ? ` (${deckNames.join(", ")})` : ""}`;
33159}
33160function applyAnkiDeckMembershipToRenderedWord(word, deckNames) {
33161if (!deckNames.length) return;
33162const classes = cardDeckMembershipClassNames({
33163cardState: ["in-deck"],
33164source: "anki",
33165ankiDeckNames: deckNames
33166});
33167word.classList.add(...currentAccountDataSurfaceIsTrusted() ? classes.filter((className) => !className.startsWith("yomu-")) : classes);
33168}
33169function shouldApplyPublicVocabularyFurigana(card, surface, token, settings2, rubies = []) {
33170const surfaceMatchesSpelling = surface.trim() === card.spelling.trim();
33171if (!surfaceMatchesSpelling && !rubies.length) return false;
33172if (!card.reading.trim() || card.reading.trim() === card.spelling.trim()) return false;
33173if (!shouldRenderRuby(surface, token, settings2)) return false;
33174return !surfaceMatchesSpelling || Array.from(card.spelling).some(isKanjiCharacter);
33175}
33176function pointInsideExpandedRect(rect, x, y, pad) {
33177const right = rect.right || rect.left + rect.width;
33178const bottom = rect.bottom || rect.top + rect.height;
33179return x >= rect.left - pad && x <= right + pad && y >= rect.top - pad && y <= bottom + pad;
33180}
33181function rectCenterDistance(rect, x, y) {
33182const right = rect.right || rect.left + rect.width;
33183const bottom = rect.bottom || rect.top + rect.height;
33184return Math.hypot(x - (rect.left + right) / 2, y - (rect.top + bottom) / 2);
33185}
33186function rectEdgeDistance(rect, x, y) {
33187const right = rect.right || rect.left + rect.width;
33188const bottom = rect.bottom || rect.top + rect.height;
33189const dx = Math.max(rect.left - x, 0, x - right);
33190const dy = Math.max(rect.top - y, 0, y - bottom);
33191return Math.hypot(dx, dy);
33192}
33193function renderWordPills(options) {
33194const context = wordPillContext(options.card, options.overrideQuery);
33195const query = context.query;
33196const language = options.settings.interfaceLanguage;
33197const enabledLinks = options.settings.dictionaryLookupLinks.filter((link) => link.enabled);
33198const { pills: frequencyPills, mergedLiveRanks } = frequencyPillsByLookupId(options);
33199const linkPills = enabledLinks.map((link) => renderConfiguredLookupPill(options, context, language, query, link, frequencyPills, mergedLiveRanks)).filter(Boolean);
33200const ankiPill = renderAnkiPill(options, language, query);
33201const configuredFrequencyIds = new Set(enabledLinks.filter((link) => isFrequencyLookupPill(link)).map((link) => link.id));
33202const leftoverFrequencyPills = Array.from(frequencyPills).filter(([id]) => !configuredFrequencyIds.has(id)).map(([, html]) => html);
33203const pills = [...linkPills, ankiPill, ...leftoverFrequencyPills].filter(Boolean);
33204return pills.length ? `<div class="jpdb-reader-word-pills">${pills.join("")}</div>` : "";
33205}
33206function renderSelectionLookupPills(selected, settings2) {
33207const query = selected.trim();
33208if (!query) return "";
33209const context = {
33210query,
33211word: query,
33212reading: query,
33213vid: "0",
33214sid: "0"
33215};
33216const language = settings2.interfaceLanguage;
33217const pills = settings2.dictionaryLookupLinks.filter((link) => link.enabled).map((link) => renderSelectionLookupPill(context, language, link)).filter(Boolean);
33218return pills.length ? `<div class="jpdb-reader-word-pills jpdb-reader-selection-pills">${pills.join("")}</div>` : "";
33219}
33220function updateHeadingWordPills(popover, options) {
33221const heading = popover.querySelector(".jpdb-reader-heading");
33222if (!heading) return;
33223replaceOptionalElement(heading, ".jpdb-reader-word-pills", renderWordPills(options));
33224}
33225function renderSelectionLookupPill(context, language, link) {
33226const style = lookupPillStyle(link.id || link.label);
33227if (link.action === "copy" || link.id === "copy") return renderSelectionCopyPill(language, context.query, style);
33228const url = formatLookupUrl(link.urlTemplate, context);
33229if (!url) return "";
33230const title = lookupSelectionPillTitle(language, link);
33231return `<a class="${lookupLinkPillClass(link.id)}" href="${escapeHtml(url)}" target="_blank" rel="noopener"${lookupPillStyleAttribute(style)} title="${escapeHtml(title)}" aria-label="${escapeHtml(`${title}: ${context.query}`)}">${escapeHtml(link.label)} ${externalLinkIcon()}</a>`;
33232}
33233function lookupSelectionPillTitle(language, link) {
33234return link.id === "jpdb" ? uiText$1(language, "openOnJpdb") : uiText$1(language, "openOnLookup").replace("{label}", link.label);
33235}
33236function renderLookupLinkPill(options, context, language, query, link, mergedLiveRanks) {
33237const style = lookupPillStyle(link.id || link.label);
33238if (link.action === "copy" || link.id === "copy") return renderCopyPill(language, query, style, options.inert);
33239const url = lookupLinkPillUrl(options, context, link);
33240if (!url) return "";
33241const rank = linkPillLiveRank(link, mergedLiveRanks);
33242const baseTitle = lookupLinkPillTitle(options, language, link);
33243const title = rank?.detail ? `${baseTitle}
33244${rank.detail}` : baseTitle;
33245const label = rank ? `${link.label} ${rank.display ?? `#${rank.rank}`}` : link.label;
33246if (options.inert) {
33247return `<span class="${lookupLinkPillClass(link.id)}" role="link" aria-disabled="true" tabindex="-1"${lookupPillStyleAttribute(style)} title="${escapeHtml(title)}" aria-label="${escapeHtml(`${title}: ${query}`)}">${escapeHtml(label)} ${externalLinkIcon()}</span>`;
33248}
33249return `<a class="${lookupLinkPillClass(link.id)}" href="${escapeHtml(url)}" target="_blank" rel="noopener"${lookupPillStyleAttribute(style)} title="${escapeHtml(title)}" aria-label="${escapeHtml(`${title}: ${query}`)}">${escapeHtml(label)} ${externalLinkIcon()}</a>`;
33250}
33251function linkPillLiveRank(link, mergedLiveRanks) {
33252const provider = link.id === "jiten" ? "jiten" : link.id === "jpdb" ? "jpdb" : link.id === "bunpro" ? "bunpro" : null;
33253return provider ? mergedLiveRanks.get(provider) ?? null : null;
33254}
33255const BUNPRO_FREQUENCY_LIST_LABELS = {
33256general: ["General", "一般"],
33257anime: ["Anime", "アニメ"],
33258novels: ["Novels", "小説"],
33259netflix: ["Netflix", "Netflix"],
33260dictionary: ["Dictionary", "辞書"]
33261};
33262function bunproFrequencyDetail(language, lists) {
33263const japanese = language === "ja";
33264return lists.map((entry) => {
33265const label = BUNPRO_FREQUENCY_LIST_LABELS[entry.list];
33266const corpus = label ? label[japanese ? 1 : 0] : entry.list;
33267return `${corpus} #${entry.rank.toLocaleString("en-US")}`;
33268}).join(" · ");
33269}
33270function renderConfiguredLookupPill(options, context, language, query, link, frequencyPills, mergedLiveRanks) {
33271if (isFrequencyLookupPill(link)) return frequencyPills.get(link.id) ?? "";
33272return renderLookupLinkPill(options, context, language, query, link, mergedLiveRanks);
33273}
33274function isFrequencyLookupPill(link) {
33275return link.action === "frequency-live" || link.action === "frequency-local";
33276}
33277function lookupLinkPillUrl(options, context, link) {
33278if (link.id === "jiten" && options.overrideQuery && isSingleKanji(options.overrideQuery)) {
33279return `https://jiten.moe/kanji/${encodeURIComponent(options.overrideQuery)}`;
33280}
33281return link.id === "jpdb" && (Boolean(options.overrideQuery) || options.isJpdbBackedCard(options.card)) ? options.jpdbUrl : formatLookupUrl(link.urlTemplate, context);
33282}
33283function lookupLinkPillTitle(options, language, link) {
33284if (link.id !== "jpdb") return uiText$1(language, "openOnLookup").replace("{label}", link.label);
33285return options.overrideQuery ? uiText$1(language, "openKanjiOnJpdb") : uiText$1(language, "openOnJpdb");
33286}
33287function lookupLinkPillClass(id) {
33288return `jpdb-reader-pill jpdb-reader-action-pill${id === "jpdb" ? " jpdb-reader-jpdb-pill" : ""}`;
33289}
33290function renderAnkiPill(options, language, query) {
33291if (!ankiPillSurfaceIsTrusted(options)) return "";
33292const lookup = ankiPillLookup(options);
33293if (!lookup) return "";
33294if (lookup.primary) return renderEditAnkiPill(lookup, language, query, options.inert);
33295return renderNewAnkiPill(options, lookup, language, query);
33296}
33297function ankiPillSurfaceIsTrusted(options) {
33298return options.trustedAccountDataSurface ?? currentAccountDataSurfaceIsTrusted();
33299}
33300function ankiPillLookup(options) {
33301if (options.overrideQuery) return void 0;
33302if (!options.settings.ankiEnabled) return void 0;
33303return options.ankiLookup;
33304}
33305function renderNewAnkiPill(options, lookup, language, query) {
33306if (lookup.state !== "not-in-deck") return "";
33307const mobileHandoff = canUseMobileAnkiHandoff(options.settings);
33308if (!ankiAddIsAllowed(lookup, mobileHandoff)) return "";
33309return ankiPillButton({
33310action: "anki",
33311title: ankiAddTitle(language, mobileHandoff),
33312query,
33313language,
33314inert: options.inert
33315});
33316}
33317function ankiAddIsAllowed(lookup, mobileHandoff) {
33318return mobileHandoff || lookup.trusted !== false;
33319}
33320function ankiAddTitle(language, mobileHandoff) {
33321return mobileHandoff ? mobileAnkiHandoffButtonLabel(language) : uiText$1(language, "addToAnki");
33322}
33323function renderEditAnkiPill(lookup, language, query, inert = false) {
33324const noteId = Number(lookup.primary?.noteId);
33325if (!Number.isFinite(noteId) || noteId <= 0) return "";
33326return ankiPillButton({
33327action: "anki-edit",
33328title: uiText$1(language, "editInAnki"),
33329query,
33330language,
33331inert,
33332noteId
33333});
33334}
33335function mobileAnkiHandoffButtonLabel(language) {
33336const app = mobileAnkiHandoffAppName();
33337return language === "ja" ? formatUiText(language, "sendToMobileAnki", { app }) : ["Send", "to", app].join(" ");
33338}
33339function ankiPillButton(options) {
33340const styleAttribute = lookupPillStyleAttribute(lookupPillStyle("anki"));
33341const label = uiText$1(options.language, "anki");
33342const title = escapeHtml(options.title);
33343const ariaLabel = escapeHtml(`${options.title}: ${options.query}`);
33344const content = `${escapeHtml(label)} ${ankiIcon()}`;
33345if (options.inert) {
33346return `<span class="jpdb-reader-pill jpdb-reader-action-pill jpdb-reader-anki-pill" role="button" aria-disabled="true" tabindex="-1"${styleAttribute} title="${title}" aria-label="${ariaLabel}">${content}</span>`;
33347}
33348const noteAttribute = options.action === "anki-edit" && options.noteId ? ` data-note-id="${options.noteId}"` : "";
33349return `<button class="jpdb-reader-pill jpdb-reader-action-pill jpdb-reader-anki-pill" data-action="${options.action}"${noteAttribute}${privateCommandAttributes({ kind: "card-action", action: options.action, noteId: options.noteId })} type="button"${styleAttribute} title="${title}" aria-label="${ariaLabel}">${content}</button>`;
33350}
33351function lookupPillStyleAttribute(style) {
33352return style ? ` style="${style}"` : "";
33353}
33354function renderSelectionCopyPill(language, query, style = lookupPillStyle("copy")) {
33355const copyTitle = uiText$1(language, "copyWordTitle");
33356const styleAttribute = style ? ` style="${style}"` : "";
33357return `<button class="jpdb-reader-pill jpdb-reader-action-pill jpdb-reader-copy-pill" data-action="copy-selection" type="button"${styleAttribute} title="${escapeHtml(copyTitle)}" aria-label="${escapeHtml(`${copyTitle}: ${query}`)}">${escapeHtml(uiText$1(language, "copyWord"))} ${copyIcon()}</button>`;
33358}
33359function renderCopyPill(language, query, style = lookupPillStyle("copy"), inert = false) {
33360const copyTitle = uiText$1(language, "copyWordTitle");
33361const styleAttribute = style ? ` style="${style}"` : "";
33362if (inert) {
33363return `<span class="jpdb-reader-pill jpdb-reader-action-pill jpdb-reader-copy-pill" role="button" aria-disabled="true" tabindex="-1"${styleAttribute} title="${escapeHtml(copyTitle)}" aria-label="${escapeHtml(`${copyTitle}: ${query}`)}">${escapeHtml(uiText$1(language, "copyWord"))} ${copyIcon()}</span>`;
33364}
33365return `<button class="jpdb-reader-pill jpdb-reader-action-pill jpdb-reader-copy-pill" data-action="copy-word"${privateCommandAttributes({ kind: "card-action", action: "copy-word" })} type="button"${styleAttribute} title="${escapeHtml(copyTitle)}" aria-label="${escapeHtml(`${copyTitle}: ${query}`)}">${escapeHtml(uiText$1(language, "copyWord"))} ${copyIcon()}</button>`;
33366}
33367function frequencyPillsByLookupId(options) {
33368const mergeIntoLinkPill = options.settings.showLookupPillFrequency !== false;
33369const enabledLinkIds = new Set(options.settings.dictionaryLookupLinks.filter((link) => link.enabled).map((link) => link.id));
33370const kanjiQuery = options.overrideQuery && isSingleKanji(options.overrideQuery) ? options.overrideQuery.trim() : null;
33371const state = localFrequencyPills(options, mergeIntoLinkPill && !kanjiQuery, enabledLinkIds);
33372mergeLiveFrequencyRanks(options, state, mergeIntoLinkPill, enabledLinkIds, kanjiQuery);
33373return { pills: state.pills, mergedLiveRanks: state.mergedLiveRanks };
33374}
33375function localFrequencyPills(options, mergeIntoLinkPill, enabledLinkIds) {
33376const localLabel = (dictionary) => localFrequencyLookupLabel(options.settings, dictionary) || options.dictionaryLabel(dictionary);
33377const state = {
33378pills: new Map(),
33379mergedLiveRanks: new Map(),
33380localProviders: new Set()
33381};
33382for (const entry of bestFrequencyEntries(options.metaEntries ?? [])) {
33383mergeLocalFrequencyEntry(options, state, entry, localLabel, mergeIntoLinkPill, enabledLinkIds);
33384}
33385return state;
33386}
33387function mergeLocalFrequencyEntry(options, state, entry, localLabel, mergeIntoLinkPill, enabledLinkIds) {
33388if (entry.mode !== "freq" || !localFrequencyEnabled(options.settings, entry.dictionary)) return;
33389const provider = localFrequencyProvider(entry.dictionary);
33390const rank = extractFrequency(entry.data);
33391if (provider && rank && mergeIntoLinkPill && enabledLinkIds.has(provider)) {
33392state.mergedLiveRanks.set(provider, { rank });
33393state.localProviders.add(provider);
33394return;
33395}
33396const html = renderFrequencyPill(entry, localLabel);
33397if (!html) return;
33398state.pills.set(localFrequencyLookupPillId(entry.dictionary), html);
33399if (provider) state.localProviders.add(provider);
33400}
33401function mergeLiveFrequencyRanks(options, state, mergeIntoLinkPill, enabledLinkIds, kanjiQuery) {
33402for (const link of options.settings.dictionaryLookupLinks) {
33403if (link.action !== "frequency-live" || !link.enabled) continue;
33404const provider = liveFrequencyProvider(link);
33405if (!provider || !kanjiQuery && state.localProviders.has(provider)) continue;
33406const rank = liveFrequencyEvidence(options, provider);
33407if (!rank) continue;
33408if (kanjiQuery ? rank.source !== "kanji" || rank.spelling !== kanjiQuery : rank.source === "kanji") continue;
33409if (!mergeIntoLinkPill || !enabledLinkIds.has(provider)) continue;
33410state.mergedLiveRanks.set(provider, {
33411rank: rank.rank,
33412display: rank.display,
33413detail: provider === "bunpro" && rank.lists?.length ? bunproFrequencyDetail(options.settings.interfaceLanguage, rank.lists) : void 0
33414});
33415}
33416}
33417function localFrequencyEnabled(settings2, dictionary) {
33418const preference = settings2.dictionaryPreferences.find((item) => item.name === dictionary);
33419const lookupLink = settings2.dictionaryLookupLinks.find((link) => link.id === localFrequencyLookupPillId(dictionary));
33420return (preference?.enabled ?? true) && (lookupLink?.enabled ?? true);
33421}
33422function localFrequencyLookupLabel(settings2, dictionary) {
33423return settings2.dictionaryLookupLinks.find((link) => link.id === localFrequencyLookupPillId(dictionary))?.label.trim() ?? "";
33424}
33425function localFrequencyLookupPillId(dictionary) {
33426return `frequency-local:${dictionary}`;
33427}
33428function liveFrequencyProvider(link) {
33429return frequencyProviderForLookupId(link.id);
33430}
33431function liveFrequencyEvidence(options, provider) {
33432return options.frequencyRanks?.[provider] ?? null;
33433}
33434function localFrequencyProvider(dictionary) {
33435const normalized = dictionary.toLowerCase();
33436if (/\bjiten\b/.test(normalized)) return "jiten";
33437if (/\bjpdb\b|jpdbv?\d*/.test(normalized)) return "jpdb";
33438return null;
33439}
33440function wordPillContext(card, overrideQuery) {
33441return {
33442query: overrideQuery || card.spelling || card.reading,
33443word: overrideQuery || card.spelling,
33444reading: overrideQuery || card.reading || card.spelling,
33445vid: String(Math.max(0, card.vid)),
33446sid: String(Math.max(0, card.sid))
33447};
33448}
33449function isSingleKanji(value) {
33450const character = value.trim();
33451return /^[\u4e00-\u9faf\u3400-\u4dbf\u3005-\u3007]$/u.test(character) || character.length > 1 && isJapaneseKanjiCharacter(character);
33452}
33453function renderTokenListHtml(tokens, selected, previousNavigationEntry, settings2) {
33454const language = settings2.interfaceLanguage;
33455const title = uiText$1(language, "search");
33456return `
33457<div class="jpdb-reader-sheet-handle"></div>
33458<div class="jpdb-reader-popover-body" data-token-list-selected="${escapeHtml(selected)}">
33459${renderTokenListNavigation(previousNavigationEntry, language)}
33460<div class="jpdb-reader-pos">${escapeHtml(title)}</div>
33461${renderSelectionLookupPills(selected, settings2)}
33462${renderTokenSentence(tokens, selected, settings2)}
33463</div>
33464`;
33465}
33466function installTokenListHandlers(popover, tokens, anchor, context, callbacks) {
33467popover.addEventListener("click", (event) => {
33468const selectionCopyButton = event.target.closest('[data-action="copy-selection"]');
33469if (selectionCopyButton) {
33470event.preventDefault();
33471event.stopPropagation();
33472callbacks.copySelected(tokenListSelectedText(popover));
33473return;
33474}
33475const backButton = event.target.closest('[data-action="token-list-back"]');
33476if (backButton) {
33477event.preventDefault();
33478event.stopPropagation();
33479callbacks.showPrevious(anchor, context);
33480return;
33481}
33482const button = event.target.closest("button[data-token-choice]");
33483if (!button) return;
33484callbacks.showCard(button, tokens, anchor, context);
33485});
33486}
33487function tokenListSelectedText(popover) {
33488return popover.querySelector("[data-token-list-selected]")?.dataset.tokenListSelected ?? "";
33489}
33490function renderTokenListNavigation(previousNavigationEntry, language) {
33491if (!previousNavigationEntry) return "";
33492return renderModalNavigation({
33493backAction: "token-list-back",
33494backTitle: previousNavigationEntry.kind === "kanji" ? `${uiText$1(language, "backToKanji")}: ${previousNavigationEntry.kanji}` : `${uiText$1(language, "backToWord")}: ${previousNavigationEntry.card.spelling}`,
33495label: previousNavigationEntry.kind === "kanji" ? previousNavigationEntry.kanji : previousNavigationEntry.card.spelling
33496});
33497}
33498function renderTokenSentence(tokens, selected, settings2) {
33499const { segments, unmatched } = tokenSentenceSegments(tokens, selected);
33500const flow = segments.map((segment) => segment.kind === "token" ? renderTokenSentenceWord(segment.token, segment.surface, settings2) : `<span class="jpdb-reader-token-sentence-gap">${escapeHtml(segment.text)}</span>`).join("");
33501const extras = unmatched.map((token) => renderTokenSentenceWord(token, token.card.spelling, settings2)).join(" ");
33502return `<div class="jpdb-reader-meanings jpdb-reader-token-sentence" lang="ja">${flow}${extras ? `<span class="jpdb-reader-token-sentence-extras"> ${extras}</span>` : ""}</div>`;
33503}
33504function tokenSentenceSegments(tokens, selected) {
33505const segments = [];
33506const unmatched = [];
33507let cursor = 0;
33508for (const token of tokens) {
33509const match = matchTokenSurface(selected, cursor, token);
33510if (!match) {
33511unmatched.push(token);
33512continue;
33513}
33514if (match.start > cursor) segments.push({ kind: "text", text: selected.slice(cursor, match.start) });
33515segments.push({ kind: "token", token, surface: selected.slice(match.start, match.end) });
33516cursor = match.end;
33517}
33518if (cursor < selected.length) segments.push({ kind: "text", text: selected.slice(cursor) });
33519return { segments, unmatched };
33520}
33521function matchTokenSurface(selected, cursor, token) {
33522if (hasPlausibleTokenOffsets(selected, cursor, token)) {
33523const slice = selected.slice(token.start, token.end);
33524if (slice === token.card.spelling || slice === token.card.reading) return { start: token.start, end: token.end };
33525}
33526for (const needle of [token.card.spelling, token.card.reading]) {
33527const trimmed = needle?.trim();
33528if (!trimmed) continue;
33529const index = selected.indexOf(trimmed, cursor);
33530if (index >= 0) return { start: index, end: index + trimmed.length };
33531}
33532if (hasPlausibleTokenOffsets(selected, cursor, token) && sliceSharesTokenPrefix(selected, token)) {
33533return { start: token.start, end: token.end };
33534}
33535return null;
33536}
33537function hasPlausibleTokenOffsets(selected, cursor, token) {
33538return token.start >= cursor && token.end > token.start && token.end <= selected.length;
33539}
33540function sliceSharesTokenPrefix(selected, token) {
33541const first = selected.slice(token.start, token.end)[0];
33542return Boolean(first && (token.card.spelling.startsWith(first) || token.card.reading.startsWith(first)));
33543}
33544function renderTokenSentenceWord(token, surface, settings2) {
33545const reading = tokenSentenceReading(token);
33546const pitchClass = tokenSentencePitchClass(token, reading, surface);
33547const chipToken = { ...token, pitchClass, start: 0, end: surface.length, length: surface.length, rubies: [] };
33548const state = primaryCardState(normalizeCardStates(token.card.cardState));
33549const withRuby = shouldRenderRuby(surface, chipToken, settings2);
33550const classes = tokenSentenceWordClasses(state, chipToken, settings2, withRuby);
33551const content = tokenSentenceWordContent(surface, chipToken, withRuby);
33552return `<button type="button" class="${classes}" data-token-choice="true"${privateCommandAttributes({ kind: "token-choice", vid: token.card.vid, sid: token.card.sid })} data-surface="${escapeHtml(surface)}" data-expression="${escapeHtml(token.card.spelling)}"${tokenSentenceReadingAttribute(reading)} data-pitch-class="${escapeHtml(pitchClass || "unknown")}">${content}</button>`;
33553}
33554function tokenSentenceReading(token) {
33555return token.card.reading ? token.card.reading.trim() : "";
33556}
33557function tokenSentencePitchClass(token, reading, surface) {
33558if (token.pitchClass) return token.pitchClass;
33559return getPitchClass(token.card.pitchAccent ?? [], reading || surface);
33560}
33561function tokenSentenceWordClasses(state, token, settings2, withRuby) {
33562return [
33563readerWordClassName(state, token, settings2),
33564"jpdb-reader-token-sentence-word",
33565withRuby ? "jpdb-reader-has-furi" : ""
33566].filter(Boolean).join(" ");
33567}
33568function tokenSentenceWordContent(surface, token, withRuby) {
33569return withRuby ? renderRuby(surface, token) : escapeHtml(surface);
33570}
33571function tokenSentenceReadingAttribute(reading) {
33572return reading ? ` data-reading="${escapeHtml(reading)}"` : "";
33573}
33574const handleDrag = aggregateRuntimeModules().handleDrag;
33575const {
33576addViewportChangeListeners,
33577createHandleDragController,
33578firstChangedTouch,
33579getContainedClosest
33580} = handleDrag;
33581const SHEET_HEIGHT_STORAGE_KEY = "jpdb-reader-sheet-height-ratio";
33582const DEFAULT_SHEET_HEIGHT_RATIO = 0.7;
33583const MIN_SHEET_HEIGHT_PX = 180;
33584const SHEET_MIN_HEIGHT_RATIO = 0.32;
33585const SHEET_DISMISS_OVERSHOOT_PX = 72;
33586const SHEET_DISMISS_CLICK_SUPPRESSION_MS = 700;
33587const SHEET_FULL_HEIGHT_THRESHOLD_PX = 12;
33588const SHEET_TAP_MOVEMENT_PX = 8;
33589const SHEET_KEYBOARD_STEP_PX = 48;
33590const MINING_DRAWER_DRAG_THRESHOLD_PX = 22;
33591const MINING_DRAWER_TAP_MOVEMENT_PX = 8;
33592const AUTO_SHEET_COMPACT_WIDTH_PX = 768;
33593const AUTO_SHEET_PORTRAIT_MAX_WIDTH_PX = 1100;
33594const AUTO_POPOVER_VIEWPORT_MARGIN_PX = 48;
33595const AUTO_POPOVER_MIN_HEIGHT_PX = 520;
33596const FORCED_POPOVER_SURFACE_DATA_KEY = "jpdbReaderForcedPopoverSurface";
33597const MINING_DRAWER_HANDLE_SELECTOR = ".jpdb-reader-mining-drawer-handle";
33598const MINING_DRAWER_POINTER_TARGET_SELECTOR = ".jpdb-reader-mining-drawer-handle, .jpdb-reader-actions-gutter";
33599const POPOVER_BODY_ACTION_SELECTOR = [
33600"button",
33601'[role="button"]',
33602"input",
33603"select",
33604"textarea",
33605"[data-action]",
33606"[data-immersion-action]",
33607"[data-yomu-immersion-action]"
33608].join(",");
33609function popoverScrollBody$1(popover) {
33610return popover.querySelector(".jpdb-reader-popover-body") ?? popover;
33611}
33612function capturePopoverScrollFrame(target) {
33613const popover = target.closest(".jpdb-reader-popover") ?? target;
33614const scrollBody = target.closest(".jpdb-reader-popover-body") ?? popoverScrollBody$1(popover);
33615return { scrollBody, scrollTop: scrollBody.scrollTop };
33616}
33617function restorePopoverScrollFrame(frame) {
33618if (!frame.scrollBody.isConnected) return;
33619if (frame.scrollBody.scrollTop !== frame.scrollTop) frame.scrollBody.scrollTop = frame.scrollTop;
33620}
33621function restorePopoverScrollFrameSoon(frame) {
33622restorePopoverScrollFrame(frame);
33623requestAnimationFrame(() => restorePopoverScrollFrame(frame));
33624}
33625function capturePopoverScrollOffset(popover) {
33626return { popover, scrollTop: popoverScrollBody$1(popover).scrollTop };
33627}
33628function restorePopoverScrollOffset(offset) {
33629if (!offset.scrollTop || !offset.popover.isConnected) return;
33630const scrollBody = popoverScrollBody$1(offset.popover);
33631if (scrollBody.scrollTop !== offset.scrollTop) scrollBody.scrollTop = offset.scrollTop;
33632}
33633function restorePopoverScrollOffsetSoon(offset) {
33634restorePopoverScrollOffset(offset);
33635requestAnimationFrame(() => restorePopoverScrollOffset(offset));
33636}
33637function popoverBodyActionElement(target, scrollBody) {
33638const action = target.closest(POPOVER_BODY_ACTION_SELECTOR);
33639return action && scrollBody.contains(action) ? action : null;
33640}
33641function createReaderPopover(appName, settings2, trigger = "modal") {
33642const popover = document.createElement("div");
33643popover.className = "jpdb-reader-popover";
33644popover.dataset.jpdbReaderRoot = "true";
33645popover.setAttribute("aria-label", uiText$1(settings2.interfaceLanguage, "lookupDialog") || `${appName} lookup`);
33646popover.tabIndex = -1;
33647if (shouldUseSheet(settings2, trigger)) popover.classList.add("jpdb-reader-sheet");
33648else popover.style.width = `${settings2.popoverWidth}px`;
33649return popover;
33650}
33651function forceReaderPopoverSurface(popover, settings2) {
33652popover.dataset[FORCED_POPOVER_SURFACE_DATA_KEY] = "true";
33653refreshForcedReaderPopoverSurface(popover, settings2);
33654}
33655function refreshForcedReaderPopoverSurface(popover, settings2) {
33656if (popover.dataset[FORCED_POPOVER_SURFACE_DATA_KEY] !== "true") return;
33657popover.classList.remove("jpdb-reader-sheet", "jpdb-reader-sheet-sticky", "jpdb-reader-sheet-expanded", "jpdb-reader-sheet-resizing");
33658popover.style.width = `${settings2.popoverWidth}px`;
33659popover.style.height = "";
33660popover.style.minHeight = "";
33661popover.querySelectorAll(".jpdb-reader-sheet-handle, .jpdb-reader-sheet-close").forEach((element) => element.remove());
33662}
33663function createReaderBackdrop(onDismiss) {
33664const backdrop = document.createElement("div");
33665backdrop.className = "jpdb-reader-backdrop";
33666backdrop.dataset.jpdbReaderRoot = "true";
33667backdrop.addEventListener("mousedown", (event) => event.preventDefault());
33668backdrop.addEventListener("click", onDismiss);
33669return backdrop;
33670}
33671function popoverMaxHeightSetting(settings2) {
33672return settings2.popoverHeightMode === "fixed" ? settings2.popoverHeight : void 0;
33673}
33674let releaseTrailingSheetClick;
33675function suppressTrailingClickAfterSheetGestureDismiss() {
33676if (typeof window === "undefined") return;
33677releaseTrailingSheetClick?.();
33678const owner = window;
33679let timer = 0;
33680const cleanup = () => {
33681if (timer) owner.clearTimeout(timer);
33682owner.removeEventListener("click", consume, true);
33683if (releaseTrailingSheetClick === cleanup) releaseTrailingSheetClick = void 0;
33684};
33685const consume = (event) => {
33686cleanup();
33687event.preventDefault();
33688event.stopImmediatePropagation();
33689};
33690releaseTrailingSheetClick = cleanup;
33691owner.addEventListener("click", consume, { capture: true });
33692timer = owner.setTimeout(cleanup, SHEET_DISMISS_CLICK_SUPPRESSION_MS);
33693}
33694function installSheetHandle(popover, onDismiss, label = "Drag to resize lookup sheet, or tap to close") {
33695if (popover.dataset.jpdbReaderSheetHandleInstalled === "true") return;
33696popover.dataset.jpdbReaderSheetHandleInstalled = "true";
33697let viewportHeight = 0;
33698let sheetHeight = 0;
33699let startHeight = 0;
33700let rawDragHeight = 0;
33701let dragPageScale = 1;
33702const isFullHeight = () => viewportHeight > 0 && sheetHeight >= viewportHeight - SHEET_FULL_HEIGHT_THRESHOLD_PX;
33703const syncHandle = (handle) => {
33704handle.setAttribute("role", "button");
33705handle.setAttribute("tabindex", "0");
33706handle.setAttribute("aria-label", label);
33707handle.setAttribute("aria-expanded", String(isFullHeight()));
33708handle.setAttribute("aria-valuemin", String(sheetMinHeight(viewportHe
33708ight)));
33709handle.setAttribute("aria-valuemax", String(viewportHeight));
33710handle.setAttribute("aria-valuenow", String(Math.round(sheetHeight)));
33711};
33712const syncHandleState = () => {
33713popover.querySelectorAll(".jpdb-reader-sheet-handle").forEach(syncHandle);
33714};
33715const applySheetHeight = (height, persist = false) => {
33716const nextHeight = clampSheetHeight(height, viewportHeight);
33717sheetHeight = nextHeight;
33718popover.style.setProperty("--jpdb-reader-sheet-height", `${Math.round(nextHeight)}px`);
33719popover.classList.toggle("jpdb-reader-sheet-expanded", isFullHeight());
33720syncHandleState();
33721if (persist) storeSheetHeightRatio(nextHeight, viewportHeight);
33722};
33723const applyViewportSize = () => {
33724applyOverlayPageScale(popover);
33725const previousViewportHeight = viewportHeight;
33726const scaledViewport = overlayViewport();
33727viewportHeight = fixedChromeViewportHeight();
33728const bottomInset = scaledViewport.pageScale > 1 ? Math.round(overlayViewportBottomInset()) : 0;
33729popover.style.setProperty("--jpdb-reader-sheet-bottom", `${bottomInset}px`);
33730popover.style.setProperty("--jpdb-reader-sheet-viewport-height", `${viewportHeight}px`);
33731popover.style.setProperty("--jpdb-reader-sheet-collapsed-height", `${Math.round(viewportHeight * DEFAULT_SHEET_HEIGHT_RATIO)}px`);
33732popover.style.setProperty("--jpdb-reader-sheet-min-height", `${sheetMinHeight(viewportHeight)}px`);
33733const ratio = previousViewportHeight > 0 && sheetHeight > 0 ? sheetHeight / previousViewportHeight : readSheetHeightRatio();
33734applySheetHeight(viewportHeight * ratio);
33735};
33736const clearSheetPositionStyles = () => {
33737popover.style.removeProperty("left");
33738popover.style.removeProperty("right");
33739popover.style.removeProperty("top");
33740popover.style.removeProperty("bottom");
33741popover.style.removeProperty("width");
33742popover.style.removeProperty("height");
33743popover.style.removeProperty("max-width");
33744popover.style.removeProperty("max-height");
33745};
33746const clearDragStyles = () => {
33747popover.style.transform = "";
33748popover.style.removeProperty("--jpdb-reader-sheet-drag-up");
33749popover.classList.remove("jpdb-reader-sheet-resizing");
33750};
33751const resetSheetLayout = () => {
33752clearSheetPositionStyles();
33753applyViewportSize();
33754clearDragStyles();
33755};
33756resetSheetLayout();
33757syncHandleState();
33758let handleObserver;
33759if (typeof MutationObserver !== "undefined") {
33760handleObserver = new MutationObserver(syncHandleState);
33761handleObserver.observe(popover, { childList: true, subtree: true });
33762}
33763let suppressNextHandleClick = false;
33764const getHandleFromEvent = (event) => getContainedClosest(event, popover, ".jpdb-reader-sheet-handle", syncHandle);
33765const reset = () => {
33766popover.style.transition = "height .16s ease, max-height .16s ease, border-radius .16s ease, transform .16s ease";
33767clearDragStyles();
33768window.setTimeout(() => {
33769popover.style.transition = "";
33770}, 180);
33771};
33772const dismissFromGesture = () => {
33773suppressTrailingClickAfterSheetGestureDismiss();
33774onDismiss();
33775};
33776const sheetDrag = createHandleDragController({
33777tapMovementPx: SHEET_TAP_MOVEMENT_PX,
33778movementDistance: (state) => Math.abs(state.deltaY * dragPageScale),
33779onBegin: () => {
33780dragPageScale = overlayViewport().pageScale;
33781startHeight = sheetHeight || restoredSheetHeight(viewportHeight);
33782rawDragHeight = startHeight;
33783popover.style.transition = "";
33784popover.classList.add("jpdb-reader-sheet-resizing");
33785},
33786onUpdate: (state) => {
33787rawDragHeight = startHeight - state.deltaY * dragPageScale;
33788applySheetHeight(rawDragHeight);
33789},
33790onFinish: (_state, wasMoved) => {
33791const finishHeight = rawDragHeight;
33792popover.classList.remove("jpdb-reader-sheet-resizing");
33793if (!wasMoved) {
33794suppressNextHandleClick = true;
33795dismissFromGesture();
33796return;
33797}
33798suppressNextHandleClick = true;
33799if (finishHeight < sheetMinHeight(viewportHeight) - SHEET_DISMISS_OVERSHOOT_PX) {
33800dismissFromGesture();
33801return;
33802}
33803applySheetHeight(finishHeight, true);
33804reset();
33805},
33806onCancel: reset
33807});
33808const handleViewportChange = () => {
33809if (sheetDrag.isDragging()) sheetDrag.cancel();
33810popover.style.transition = "";
33811resetSheetLayout();
33812syncHandleState();
33813};
33814const viewportController = new AbortController();
33815let disposed = false;
33816let disposeObserver;
33817const dispose = () => {
33818if (disposed) return;
33819disposed = true;
33820sheetDrag.cleanupListeners();
33821viewportController.abort();
33822handleObserver?.disconnect();
33823disposeObserver?.disconnect();
33824};
33825disposeObserver = new MutationObserver(() => {
33826if (!popover.isConnected) dispose();
33827});
33828if (document.documentElement) {
33829disposeObserver.observe(document.documentElement, { childList: true, subtree: true });
33830}
33831popover.addEventListener("click", (event) => {
33832const handle = getHandleFromEvent(event.target);
33833if (!handle) return;
33834event.preventDefault();
33835event.stopPropagation();
33836if (suppressNextHandleClick) {
33837suppressNextHandleClick = false;
33838return;
33839}
33840onDismiss();
33841});
33842popover.addEventListener("pointerdown", (event) => {
33843const handle = getHandleFromEvent(event.target);
33844if (!handle) return;
33845sheetDrag.pointerDown(handle, event);
33846});
33847popover.addEventListener("touchstart", (event) => {
33848const handle = getHandleFromEvent(event.target);
33849if (!handle) return;
33850sheetDrag.touchStart(handle, event);
33851}, { capture: true, passive: false });
33852popover.addEventListener("keydown", (event) => {
33853const handle = getHandleFromEvent(event.target);
33854if (!handle) return;
33855if (event.key === "Enter" || event.key === " ") {
33856event.preventDefault();
33857event.stopPropagation();
33858onDismiss();
33859}
33860if (event.key === "ArrowUp" || event.key === "ArrowDown") {
33861event.preventDefault();
33862event.stopPropagation();
33863applySheetHeight(sheetHeight + (event.key === "ArrowUp" ? SHEET_KEYBOARD_STEP_PX : -SHEET_KEYBOARD_STEP_PX), true);
33864reset();
33865}
33866if (event.key === "Escape") onDismiss();
33867});
33868addViewportChangeListeners(handleViewportChange, viewportController.signal);
33869}
33870function installSheetCloseButton(popover, onDismiss, label = "Close drawer") {
33871if (popover.dataset.jpdbReaderSheetCloseInstalled === "true") return;
33872popover.dataset.jpdbReaderSheetCloseInstalled = "true";
33873const close = (event) => {
33874event.preventDefault();
33875event.stopPropagation();
33876onDismiss();
33877};
33878const createButton = () => {
33879const button = document.createElement("button");
33880button.type = "button";
33881button.className = "jpdb-reader-sheet-close";
33882button.dataset.jpdbReaderSheetClose = "true";
33883button.setAttribute("aria-label", label);
33884button.title = label;
33885setInnerHtml(button, '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18"></path></svg>');
33886button.addEventListener("click", close);
33887return button;
33888};
33889const ensureButton = () => {
33890if (!popover.isConnected) return;
33891if (popover.querySelector('[data-jpdb-reader-sheet-close="true"]')) return;
33892popover.append(createButton());
33893};
33894ensureButton();
33895let disposed = false;
33896let contentObserver;
33897let disposeObserver;
33898const dispose = () => {
33899if (disposed) return;
33900disposed = true;
33901contentObserver?.disconnect();
33902disposeObserver?.disconnect();
33903};
33904contentObserver = new MutationObserver(ensureButton);
33905contentObserver.observe(popover, { childList: true });
33906disposeObserver = new MutationObserver(() => {
33907if (!popover.isConnected) dispose();
33908});
33909if (document.documentElement) {
33910disposeObserver.observe(document.documentElement, { childList: true, subtree: true });
33911}
33912}
33913function installMiningDrawerHandle(root, setExpanded) {
33914if (root.dataset.jpdbReaderMiningDrawerHandleInstalled === "true") return;
33915root.dataset.jpdbReaderMiningDrawerHandleInstalled = "true";
33916let suppressNextHandleClick = false;
33917let cleanedUp = false;
33918const ownsTarget = (target) => root.contains(target);
33919const getHandleFromPointerEvent = (event) => miningDrawerHandleForPointerEvent(event, ownsTarget);
33920const getHandleFromTouchEvent = (event) => {
33921const direct = miningDrawerHandleFromTarget(event.target, ownsTarget);
33922if (direct) return direct;
33923const touch = firstChangedTouch(event);
33924return touch ? miningDrawerHandleAtPoint(touch.clientX, touch.clientY, ownsTarget) : null;
33925};
33926const isInteractiveGutterChild = (eventTarget) => {
33927if (!(eventTarget instanceof Element)) return false;
33928const action = eventTarget.closest(POPOVER_BODY_ACTION_SELECTOR);
33929return Boolean(
33930action && root.contains(action) && !action.matches(MINING_DRAWER_HANDLE_SELECTOR) && action.closest(MINING_DRAWER_POINTER_TARGET_SELECTOR)
33931);
33932};
33933const miningDrag = createHandleDragController({
33934tapMovementPx: MINING_DRAWER_TAP_MOVEMENT_PX,
33935updateOnEnd: true,
33936onBegin: (handle) => {
33937handle.closest(".jpdb-reader-actions")?.classList.add("jpdb-reader-mining-drawer-dragging");
33938},
33939onFinish: (state, wasMoved, handle) => {
33940handle?.closest(".jpdb-reader-actions")?.classList.remove("jpdb-reader-mining-drawer-dragging");
33941if (!wasMoved || !handle) return;
33942suppressNextHandleClick = true;
33943const expanded = miningDrawerDragExpandedState(handle, state.deltaX, state.deltaY);
33944if (expanded !== void 0) setExpanded(handle, expanded);
33945},
33946onCancel: (_state, handle) => {
33947handle?.closest(".jpdb-reader-actions")?.classList.remove("jpdb-reader-mining-drawer-dragging");
33948}
33949});
33950const cleanup = () => {
33951if (cleanedUp) return;
33952cleanedUp = true;
33953document.removeEventListener("click", handleClick, true);
33954document.removeEventListener("pointerdown", handlePointerDown, true);
33955document.removeEventListener("touchstart", handleTouchStart, true);
33956miningDrag.cleanupListeners();
33957};
33958const rootIsConnected = () => {
33959if (root.isConnected) return true;
33960cleanup();
33961return false;
33962};
33963const toggleHandle = (handle) => {
33964setExpanded(handle, handle.getAttribute("aria-expanded") !== "true");
33965};
33966function handleClick(event) {
33967if (!rootIsConnected()) return;
33968if (isInteractiveGutterChild(event.target)) return;
33969const handle = getHandleFromPointerEvent(event);
33970if (!handle) return;
33971event.preventDefault();
33972event.stopPropagation();
33973if (suppressNextHandleClick) {
33974suppressNextHandleClick = false;
33975return;
33976}
33977toggleHandle(handle);
33978}
33979function handlePointerDown(event) {
33980if (!rootIsConnected()) return;
33981if (isInteractiveGutterChild(event.target)) return;
33982const handle = getHandleFromPointerEvent(event);
33983if (!handle) return;
33984miningDrag.pointerDown(handle, event);
33985}
33986function handleTouchStart(event) {
33987if (!rootIsConnected()) return;
33988if (isInteractiveGutterChild(event.target)) return;
33989const handle = getHandleFromTouchEvent(event);
33990if (!handle) return;
33991miningDrag.touchStart(handle, event);
33992}
33993document.addEventListener("click", handleClick, true);
33994document.addEventListener("pointerdown", handlePointerDown, { capture: true, passive: false });
33995document.addEventListener("touchstart", handleTouchStart, { capture: true, passive: false });
33996}
33997function eventHasPointTarget(event) {
33998return event.type !== "click" || event.detail > 0 || event.clientX !== 0 || event.clientY !== 0;
33999}
34000function miningDrawerHandleForPointerEvent(event, owns = isReaderOwnedMiningDrawerTarget) {
34001return miningDrawerHandleFromTarget(event.target, owns) ?? (eventHasPointTarget(event) ? miningDrawerHandleAtPoint(event.clientX, event.clientY, owns) : null);
34002}
34003function miningDrawerHandleFromTarget(target, owns) {
34004if (!(target instanceof Element)) return null;
34005return ownedMiningDrawerHandle(target.closest(MINING_DRAWER_POINTER_TARGET_SELECTOR), owns);
34006}
34007function ownedMiningDrawerHandle(drawerTarget, owns) {
34008if (!drawerTarget || !owns(drawerTarget)) return null;
34009return drawerTarget.matches(MINING_DRAWER_HANDLE_SELECTOR) ? drawerTarget : drawerTarget.querySelector(MINING_DRAWER_HANDLE_SELECTOR);
34010}
34011function miningDrawerHandleAtPoint(x, y, owns) {
34012if (typeof document.elementsFromPoint !== "function") return null;
34013for (const element of document.elementsFromPoint(x, y)) {
34014const handle = miningDrawerHandleFromTarget(element, owns);
34015if (handle) return handle;
34016}
34017return null;
34018}
34019function isReaderOwnedMiningDrawerTarget(target) {
34020return target.isConnected && Boolean(target.closest("[data-jpdb-reader-root], .jpdb-reader-popover"));
34021}
34022function shouldUseSheet(settings2, trigger = "modal", viewport = lookupViewportSize()) {
34023const mode = trigger === "hover" ? settings2.hoverPopupMode : settings2.popupMode;
34024if (mode === "sheet") return true;
34025if (mode === "popover") return false;
34026const { width, height } = viewport;
34027const popoverWidth = Math.max(0, settings2.popoverWidth || 0);
34028const requiredPopoverWidth = popoverWidth + AUTO_POPOVER_VIEWPORT_MARGIN_PX;
34029if (width <= AUTO_SHEET_COMPACT_WIDTH_PX || width < requiredPopoverWidth) return true;
34030if (height > 0 && height < AUTO_POPOVER_MIN_HEIGHT_PX) return true;
34031return height > width && width <= AUTO_SHEET_PORTRAIT_MAX_WIDTH_PX;
34032}
34033function lookupViewportSize() {
34034const scaledViewport = overlayViewport();
34035if (scaledViewport.pageScale > 1) {
34036const bounds = overlayViewportBounds();
34037return {
34038width: Math.max(0, Math.round(bounds.width)),
34039height: Math.max(0, Math.round(bounds.height)),
34040pageScale: scaledViewport.pageScale
34041};
34042}
34043const visual = window.visualViewport;
34044const width = Math.round(visual?.width ?? window.innerWidth ?? document.documentElement.clientWidth ?? 0);
34045const height = Math.round(visual?.height ?? window.innerHeight ?? document.documentElement.clientHeight ?? 0);
34046return { width: Math.max(0, width), height: Math.max(0, height), pageScale: 1 };
34047}
34048function fixedChromeViewportHeight() {
34049const scaledViewport = overlayViewport();
34050return scaledViewport.pageScale > 1 ? Math.max(0, Math.round(overlayViewportBounds().height)) : visualViewportHeight();
34051}
34052function visualViewportHeight() {
34053return Math.max(0, Math.round(window.visualViewport?.height ?? layoutViewportHeight()));
34054}
34055function layoutViewportHeight() {
34056return Math.max(0, Math.round(window.innerHeight || document.documentElement.clientHeight || 0));
34057}
34058function sheetMinHeight(viewportHeight) {
34059if (viewportHeight <= 0) return MIN_SHEET_HEIGHT_PX;
34060return Math.min(viewportHeight, Math.max(140, Math.round(viewportHeight * SHEET_MIN_HEIGHT_RATIO)));
34061}
34062function restoredSheetHeight(viewportHeight) {
34063return clampSheetHeight(viewportHeight * readSheetHeightRatio(), viewportHeight);
34064}
34065function clampSheetHeight(height, viewportHeight) {
34066return clampDrawerHeight(height, viewportHeight, sheetMinHeight(viewportHeight));
34067}
34068function clampDrawerHeight(height, viewportHeight, minHeight) {
34069if (viewportHeight <= 0) return Math.max(minHeight, Math.round(height));
34070return Math.max(minHeight, Math.min(viewportHeight, Math.round(height)));
34071}
34072function miningDrawerDragExpandedState(handle, deltaX, deltaY) {
34073const axis = miningDrawerDragAxis(handle);
34074if (axis === "horizontal") {
34075if (Math.abs(deltaX) < MINING_DRAWER_DRAG_THRESHOLD_PX) return void 0;
34076return miningDrawerHorizontalOpenDirection(handle) === "right" ? deltaX > 0 : deltaX < 0;
34077}
34078if (Math.abs(deltaY) < MINING_DRAWER_DRAG_THRESHOLD_PX) return void 0;
34079return deltaY < 0;
34080}
34081function miningDrawerDragAxis(handle) {
34082const rect = handle.getBoundingClientRect();
34083if (rect.height > rect.width * 1.2) return "horizontal";
34084const actions = handle.closest(".jpdb-reader-actions");
34085if (!actions) return "vertical";
34086const actionsRect = actions.getBoundingClientRect();
34087return actionsRect.height > actionsRect.width * 1.2 ? "horizontal" : "vertical";
34088}
34089function miningDrawerHorizontalOpenDirection(handle) {
34090const actions = handle.closest(".jpdb-reader-actions");
34091if (!actions) return "left";
34092const handleRect = handle.getBoundingClientRect();
34093const actionsRect = actions.getBoundingClientRect();
34094const handleCenter = handleRect.left + handleRect.width / 2;
34095const actionsCenter = actionsRect.left + actionsRect.width / 2;
34096return handleCenter < actionsCenter ? "right" : "left";
34097}
34098function readSheetHeightRatio() {
34099const stored = readHeightRatio(SHEET_HEIGHT_STORAGE_KEY, DEFAULT_SHEET_HEIGHT_RATIO);
34100return stored < SHEET_MIN_HEIGHT_RATIO ? DEFAULT_SHEET_HEIGHT_RATIO : stored;
34101}
34102function storeSheetHeightRatio(height, viewportHeight) {
34103if (viewportHeight > 0 && height / viewportHeight < SHEET_MIN_HEIGHT_RATIO) return;
34104storeHeightRatio(SHEET_HEIGHT_STORAGE_KEY, height, viewportHeight);
34105}
34106function readHeightRatio(storageKey, fallback) {
34107const value = gmStorageGetSync(storageKey, fallback);
34108return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback;
34109}
34110function storeHeightRatio(storageKey, height, viewportHeight) {
34111if (viewportHeight <= 0) return;
34112const ratio = Math.max(0, Math.min(1, height / viewportHeight));
34113gmStorageSetSync(storageKey, Number(ratio.toFixed(4)));
34114}
34115function clearDocumentSelection() {
34116const selection = window.getSelection();
34117if (selection && selection.rangeCount > 0 && !selection.isCollapsed) selection.removeAllRanges();
34118}
34119function popoverScrollBody(popover) {
34120return popover.querySelector(".jpdb-reader-popover-body") ?? popover;
34121}
34122function stabilizePopoverBodyAround(popover, anchor) {
34123const scrollBody = popoverScrollBody(popover);
34124const scrollTop = scrollBody.scrollTop;
34125const anchorTop = sourceRectToOverlay(anchor.getBoundingClientRect(), anchor).top;
34126requestAnimationFrame(() => {
34127if (!popover.isConnected || !anchor.isConnected) return;
34128const delta = sourceRectToOverlay(anchor.getBoundingClientRect(), anchor).top - anchorTop;
34129if (Math.abs(delta) > 0.5) scrollBody.scrollTop = scrollTop + delta;
34130});
34131}
34132function installPopoverBodyStabilizers(popover) {
34133if (popover.dataset.jpdbReaderBodyStabilizers === "true") return;
34134popover.dataset.jpdbReaderBodyStabilizers = "true";
34135popover.addEventListener("touchmove", (event) => {
34136const target = event.target instanceof Node ? event.target : null;
34137const scrollBody = popoverScrollBody(popover);
34138if (target && scrollBody.contains(target)) event.stopPropagation();
34139}, { capture: true, passive: true });
34140popover.addEventListener("wheel", (event) => {
34141const target = event.target instanceof Node ? event.target : null;
34142const scrollBody = popoverScrollBody(popover);
34143if (target && scrollBody.contains(target)) event.stopPropagation();
34144}, { capture: true, passive: true });
34145popover.addEventListener("click", (event) => {
34146const target = event.target instanceof HTMLElement ? event.target : null;
34147if (!target) return;
34148const scrollBody = popoverScrollBody(popover);
34149if (!scrollBody.contains(target)) return;
34150const summary = target.closest("summary");
34151if (summary && scrollBody.contains(summary)) {
34152stabilizePopoverBodyAround(popover, summary);
34153return;
34154}
34155if (!popoverBodyActionElement(target, scrollBody)) return;
34156restorePopoverScrollFrameSoon(capturePopoverScrollFrame(scrollBody));
34157}, true);
34158}
34159function popoverMaxHeightAtTop(settings2, top, viewportBottom = overlayViewportBounds().bottom) {
34160const margin = 8;
34161const availableHeight = Math.max(0, viewportBottom - top - margin);
34162const configuredMaxHeight = configuredPopoverMaxHeight(settings2);
34163return configuredMaxHeight ? Math.min(availableHeight, configuredMaxHeight) : availableHeight;
34164}
34165function configuredPopoverMaxHeight(settings2) {
34166return popoverMaxHeightSetting(settings2);
34167}
34168function shouldUseFixedPopoverHeight(popover, settings2, enabled = true) {
34169return Boolean(
34170enabled && popover && settings2.popoverHeightMode === "fixed" && !popover.classList.contains("jpdb-reader-sheet") && popover.querySelector(".jpdb-reader-popover-body")
34171);
34172}
34173function syncFixedPopoverHeight(popover, shouldUseFixedHeight) {
34174if (!popover) return;
34175if (!shouldUseFixedHeight) {
34176popover.style.height = "";
34177return;
34178}
34179const maxHeight = Number.parseFloat(popover.style.maxHeight);
34180if (Number.isFinite(maxHeight) && maxHeight > 0) popover.style.height = `${maxHeight}px`;
34181}
34182const TERM_AUDIO_PRELOAD_LIMIT = 4;
34183const NEARBY_TERM_AUDIO_PRELOAD_LIMIT = 3;
34184const NEARBY_TERM_AUDIO_PRELOAD_DELAY_MS = 350;
34185const PRELOADED_TERM_AUDIO_KEY_LIMIT = 500;
34186const FALLBACK_LOOKUP_INITIAL_WAIT_MS = 180;
34187const TEXT_LOOKUP_JPDB_TIMEOUT_MS = 650;
34188const POINTER_TEXT_JPDB_TIMEOUT_MS = 450;
34189const HOVER_ANKI_HYDRATION_DELAY_MS = 180;
34190const PITCH_ENRICHMENT_LIMIT = 12;
34191const PITCH_ENRICHMENT_QUEUE_LIMIT = 240;
34192const PUBLIC_FALLBACK_SPELLING_SEARCH_LIMIT = 6;
34193const YOUTUBE_PUBLIC_PITCH_ENRICHMENT_LIMIT = 10;
34194const YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_LIMIT = 6;
34195const YOUTUBE_PUBLIC_PITCH_ENRICHMENT_TOTAL_LIMIT = 10;
34196const YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_TOTAL_LIMIT = 6;
34197const YOUTUBE_PUBLIC_PITCH_ENRICHMENT_PAGE_BUDGET = 64;
34198const YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_PAGE_BUDGET = 24;
34199const DEFERRED_PUBLIC_PITCH_ENRICHMENT_CHUNK_SIZE = 4;
34200const DEFERRED_PUBLIC_PITCH_ENRICHMENT_IDLE_TIMEOUT_MS = 350;
34201const DEFERRED_PUBLIC_PITCH_HOVER_PAUSE_MS = 180;
34202const DEFERRED_PUBLIC_PITCH_PER_URL_CAP = 256;
34203const NESTED_PUBLIC_PITCH_ENRICHMENT_LIMIT = 3;
34204const NESTED_PARSE_CONTENT_CACHE_TTL_MS = 3e4;
34205const NESTED_PARSE_CONTENT_CACHE_LIMIT = 160;
34206const PITCH_LOCAL_META_LIMIT = 12;
34207const LOCAL_PITCH_DICTIONARY_PRESENCE_TIMEOUT_MS = 500;
34208const PITCH_ENRICHMENT_LOCAL_CACHE_LIMIT = 800;
34209const RESOLVED_FALLBACK_VOCABULARY_CACHE_LIMIT = 800;
34210const UNRESOLVED_FALLBACK_VOCABULARY_CACHE_LIMIT = 1200;
34211const UNRESOLVED_FALLBACK_VOCABULARY_RETRY_TTL_MS = 12e4;
34212const PUBLIC_VOCABULARY_MISS_RETRY_LIMIT = 2;
34213const DEFERRED_PUBLIC_PITCH_BACKOFF_WAIT_MS = 3e3;
34214const MISALIGNED_PUBLIC_FURIGANA_RECOVERY_LIMIT = 24;
34215const ANKI_TARGETED_RENDERED_WORD_SELECTOR_THRESHOLD = 24;
34216const BACKGROUND_PITCH_ENRICHMENT_CONCURRENCY = 4;
34217const LOCAL_PITCH_ENRICHMENT_CONCURRENCY = 8;
34218const SUBTITLE_SURFACE_SELECTOR = [
34219".jpdb-subtitle-player",
34220".jpdb-subtitle-list",
34221".asbplayer-subtitles-container-bottom",
34222".asbplayer-offscreen",
34223".jpdb-reader-subtitle-surface"
34224].join(", ");
34225const ANKI_RECOLOR_SCAN_CHUNK_SIZE = 600;
34226const JPDB_REVIEW_BLOCKING_STATES = new Set(["blacklisted", "never-forget", "locked"]);
34227function hasBlockedJpdbReviewState(states) {
34228return states.some((state) => JPDB_REVIEW_BLOCKING_STATES.has(state));
34229}
34230function pickExactTokenForSelection(tokens = [], selected) {
34231return tokens.find((token) => token.card.spelling === selected || token.card.reading === selected);
34232}
34233function dictionaryLookupLink(target) {
34234return target?.closest?.("a.gloss-link[data-dictionary-lookup]") ?? null;
34235}
34236function actionPillLink(target) {
34237return target?.closest?.("a.jpdb-reader-action-pill[href]") ?? null;
34238}
34239function actionPillUrl(link) {
34240try {
34241const url = new URL(link.getAttribute("href") ?? "", location.href);
34242return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
34243} catch {
34244return null;
34245}
34246}
34247function handleReaderActionPillLink(event, open = openUrlInNewTab) {
34248const link = actionPillLink(event.target);
34249if (!link) return false;
34250event.preventDefault();
34251event.stopPropagation();
34252const url = actionPillUrl(link);
34253if (!url) return true;
34254if (!open(url)) location.href = url;
34255return true;
34256}
34257function dictionaryLookupQuery(link) {
34258return normalizedLookupText(link.dataset.dictionaryLookup ?? "");
34259}
34260function dictionaryLookupNestedWord(target, link) {
34261if (!(target instanceof Element)) return null;
34262const word = target.closest(".jpdb-reader-word");
34263return word !== null && link.contains(word) ? word : null;
34264}
34265function dictionaryLookupWordMatchesLink(word, query) {
34266return Boolean(query && normalizedLookupText(word.dataset.expression || readerWordSurfaceText(word)) === query);
34267}
34268function connectedElement(element) {
34269return element?.isConnected ? element : void 0;
34270}
34271function hasVisibleAutoScanTargets() {
34272return hasVisibleSiteScanTargets() || allowsGenericVisibleAutoScan() && collectVisibleTextTargets(1).length > 0;
34273}
34274function hasVisibleSiteScanTargets() {
34275return (collectSiteScanTargets(1)?.length ?? 0) > 0;
34276}
34277function allowsGenericVisibleAutoScan() {
34278return !isBookWalkerReaderPage();
34279}
34280function shouldAutoScanImageOcr(pageHasJapaneseText) {
34281return pageHasJapaneseText || documentLooksLikeImageReadingPage() || isBookWalkerReaderPage() && Boolean(document.querySelector("canvas"));
34282}
34283function allowsFrequentVisibleAutoScan() {
34284return true;
34285}
34286function isYouTubeHostname(hostname = location.hostname) {
34287return isYouTubeAppHostname(hostname);
34288}
34289function backgroundPitchEnrichmentOptionsForHost(_hostname, compactViewport = false) {
34290return {
34291publicLookupLimit: compactViewport ? YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_LIMIT : YOUTUBE_PUBLIC_PITCH_ENRICHMENT_LIMIT,
34292publicLookupTotalLimit: compactViewport ? YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_TOTAL_LIMIT : YOUTUBE_PUBLIC_PITCH_ENRICHMENT_TOTAL_LIMIT,
34293publicLookupPageBudget: compactViewport ? YOUTUBE_MOBILE_PUBLIC_PITCH_ENRICHMENT_PAGE_BUDGET : YOUTUBE_PUBLIC_PITCH_ENRICHMENT_PAGE_BUDGET,
34294publicLookupTermLimit: 3,
34295substantivePublicLookupOnly: true
34296};
34297}
34298function nestedPitchEnrichmentOptionsForHost(_hostname) {
34299return { publicLookupLimit: NESTED_PUBLIC_PITCH_ENRICHMENT_LIMIT };
34300}
34301function visibleAutoScanMutationDelay(defaultDelay = 450) {
34302return isYouTubeHostForAutoScan() ? 320 : defaultDelay;
34303}
34304function visibleAutoScanInitialDelay(defaultDelay = 600) {
34305return isYouTubeHostForAutoScan() ? 220 : defaultDelay;
34306}
34307const AUTO_SCAN_MIN_INTERVAL_MS = 900;
34308const AUTO_SCAN_DEBOUNCE_MAX_WAIT_MS = 1e3;
34309function debouncedAutoScanDeadline(requestedDeadline, debounceStartedAt) {
34310return Math.min(requestedDeadline, debounceStartedAt + AUTO_SCAN_DEBOUNCE_MAX_WAIT_MS);
34311}
34312function throttledAutoScanDelay(delay2, options, lastScanStartedAt, now, frequentHost = allowsFrequentVisibleAutoScan()) {
34313if (!options.debounce || !frequentHost) return delay2;
34314const floorDelay = AUTO_SCAN_MIN_INTERVAL_MS - (now - lastScanStartedAt);
34315return Math.max(delay2, Math.min(floorDelay, AUTO_SCAN_MIN_INTERVAL_MS));
34316}
34317function isYouTubeHostForAutoScan(hostname = location.hostname) {
34318return isYouTubeAppHostname(hostname);
34319}
34320function hasPressLookupEnabled(settings2) {
34321return settings2.popupActivationMode !== "off" && (settings2.lookupOnClick || settings2.lookupOnHover);
34322}
34323function isMousePointerEvent(event) {
34324return !("pointerType" in event) || event.pointerType === "mouse";
34325}
34326function eventElement(event) {
34327return event.target instanceof Element ? event.target : null;
34328}
34329function audioPreloadLimits(options) {
34330return {
34331sourceLimit: options.sourceLimit ?? 1,
34332candidateLimit: options.candidateLimit ?? 1,
34333prepareAudio: options.prepareAudio
34334};
34335}
34336function evictOldestStringKeysWhileOverLimit(cache, limit) {
34337while (cache.size > limit) {
34338const oldest = cache.keys().next().value;
34339if (typeof oldest !== "string") break;
34340cache.delete(oldest);
34341}
34342}
34343function ankiLookupHasDisplayableNotes(lookup) {
34344return Boolean(lookup.primary || lookup.notes.length);
34345}
34346function cardDisplayTrigger(options) {
34347return options.trigger === "hover" ? "hover" : "modal";
34348}
34349function cardSourceLabel(card) {
34350return card.source ?? "jpdb";
34351}
34352function renderedWordNavigationMode(insideReaderPopup, trigger) {
34353return insideReaderPopup && trigger === "modal" ? "push-current" : "reset";
34354}
34355function renderedWordAnchor(word, insideReaderPopup, activePopoverAnchor) {
34356return insideReaderPopup ? activePopoverAnchor ?? void 0 : word;
34357}
34358function selectionIntersectsElement(selection, element) {
34359for (let index = 0; index < selection.rangeCount; index += 1) {
34360try {
34361if (selection.getRangeAt(index).intersectsNode(element)) return true;
34362} catch {
34363}
34364}
34365return false;
34366}
34367function popoverAnchorRect(anchor, fallback) {
34368const rect = anchor?.getBoundingClientRect();
34369return rect && (rect.width > 0 || rect.height > 0) ? rememberOverlaySourceRect(rect, anchor) : fallback;
34370}
34371function shouldLockMountedPopoverPosition(popover, state) {
34372return state.mode !== "hover" && !popover.classList.contains("jpdb-reader-sheet") && Boolean(state.previousPopoverRect);
34373}
34374function mountedHoverPointerPosition(state, lastPointerPosition) {
34375const hoverPointerPosition = state.previousHoverPointerPosition ?? lastPointerPosition;
34376return state.mode === "hover" && hoverPointerPosition ? { ...hoverPointerPosition } : void 0;
34377}
34378const HOVER_POINTER_TEXT_LOOKUP_OPTIONS = { allowPassiveInteractionText: true };
34379function canSchedulePointerTextHoverLookup(hoverEnabled, candidate) {
34380return hoverEnabled && Boolean(candidate);
34381}
34382function samePointerTextLookupTarget(active, candidate) {
34383return active.anchor === candidate.anchor && active.text === candidate.text;
34384}
34385function pointerOffsetInsideLiveLookup(active, offset) {
34386return active.start <= offset && offset < active.end;
34387}
34388const MOKURO_OCR_DEFAULT_MARKER = "yomu_mokuro_ocr_default_applied";
34389const MOKURO_TOGGLE_LABEL = "OCR enabled";
34390const MOKURO_TOGGLE_NOTE = "off = Yomu OCR · on = mokuro OCR";
34391function isMokuroReaderHost(hostname = location.hostname, pathname = location.pathname, protocol = location.protocol) {
34392return hostname === "reader.mokuro.app" || hostname === "mokuro.moe" || hostname.endsWith(".mokuro.moe") || protocol === "file:" && /mokuro/i.test(safeDecode(pathname));
34393}
34394function safeDecode(value) {
34395try {
34396return decodeURIComponent(value);
34397} catch {
34398return value;
34399}
34400}
34401function defaultMokuroProfileOcrOffOnce(storage) {
34402try {
34403if (storage.getItem(MOKURO_OCR_DEFAULT_MARKER)) return false;
34404storage.setItem(MOKURO_OCR_DEFAULT_MARKER, "1");
34405const raw = storage.getItem("profiles");
34406if (!raw) return false;
34407const profiles = JSON.parse(raw);
34408const currentRaw = storage.getItem("currentProfile") ?? "Default";
34409let current = currentRaw;
34410try {
34411current = JSON.parse(currentRaw);
34412} catch {
34413}
34414const profile = profiles[current] ?? profiles[currentRaw];
34415if (!profile || typeof profile !== "object" || profile.displayOCR === false) return false;
34416profile.displayOCR = false;
34417storage.setItem("profiles", JSON.stringify(profiles));
34418return true;
34419} catch {
34420return false;
34421}
34422}
34423function applyMokuroReaderOcrDefault() {
34424if (typeof localStorage === "undefined" || !isMokuroReaderHost()) return;
34425defaultMokuroProfileOcrOffOnce(localStorage);
34426}
34427function injectMokuroToggleNote(root) {
34428for (const el of root.querySelectorAll("label, span, p, div")) {
34429if (elementOwnText(el) !== MOKURO_TOGGLE_LABEL) continue;
34430if (el.querySelector("[data-yomu-mokuro-note]")) continue;
34431const note = document.createElement("span");
34432note.dataset.yomuMokuroNote = "true";
34433note.className = "yomu-mokuro-ocr-note";
34434note.textContent = ` (${MOKURO_TOGGLE_NOTE})`;
34435el.append(note);
34436}
34437}
34438function elementOwnText(el) {
34439let text2 = "";
34440el.childNodes.forEach((node) => {
34441if (node.nodeType === 3) text2 += node.textContent ?? "";
34442});
34443return text2.trim();
34444}
34445function findMokuroOcrToggleInputs(root) {
34446const inputs = [];
34447for (const el of root.querySelectorAll("label, span, p, div")) {
34448if (elementOwnText(el) !== MOKURO_TOGGLE_LABEL) continue;
34449const input = el.querySelector('input[type="checkbox"]');
34450if (input) inputs.push(input);
34451}
34452return inputs;
34453}
34454function watchMokuroOcrToggle(onChange) {
34455if (typeof document === "undefined" || typeof window === "undefined" || !isMokuroReaderHost()) return () => void 0;
34456let last = mokuroDisplayOcrEnabled();
34457let scheduled = false;
34458let disposed = false;
34459let scheduledFrame = 0;
34460const fireIfChanged = () => {
34461if (disposed) return;
34462scheduled = false;
34463scheduledFrame = 0;
34464const next = mokuroDisplayOcrEnabled();
34465if (next === last) return;
34466last = next;
34467onChange(next);
34468};
34469const schedule = () => {
34470if (disposed || scheduled) return;
34471scheduled = true;
34472scheduledFrame = (typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => window.setTimeout(cb, 16))(fireIfChanged);
34473};
34474const bindToggleInputs = () => {
34475for (const input of findMokuroOcrToggleInputs(document)) {
34476if (input.dataset.yomuMokuroToggleWatched) continue;
34477input.dataset.yomuMokuroToggleWatched = "true";
34478input.addEventListener("change", schedule);
34479}
34480};
34481bindToggleInputs();
34482const observer = new MutationObserver((records) => {
34483for (const record2 of records) {
34484if (record2.addedNodes.length) {
34485bindToggleInputs();
34486return;
34487}
34488}
34489});
34490observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true });
34491const onStorage = (event) => {
34492if (event.key === "profiles" || event.key === "currentProfile" || event.key === null) schedule();
34493};
34494window.addEventListener("storage", onStorage);
34495return () => {
34496disposed = true;
34497if (scheduledFrame) {
34498(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : window.clearTimeout)(scheduledFrame);
34499scheduledFrame = 0;
34500}
34501observer.disconnect();
34502window.removeEventListener("storage", onStorage);
34503};
34504}
34505function installMokuroOcrToggleNote() {
34506if (typeof document === "undefined" || !isMokuroReaderHost()) return;
34507let scheduled = false;
34508const run = () => {
34509scheduled = false;
34510injectMokuroToggleNote(document);
34511};
34512const schedule = () => {
34513if (scheduled) return;
34514scheduled = true;
34515(typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => setTimeout(cb, 16))(run);
34516};
34517run();
34518new MutationObserver((records) => {
34519for (const record2 of records) {
34520if (record2.addedNodes.length) {
34521schedule();
34522return;
34523}
34524}
34525}).observe(document.body ?? document.documentElement, { childList: true, subtree: true });
34526}
34527function toggleMiningControls(button, label) {
34528yomuKanjiStudyCompanion()?.toggleMiningControls?.(button, label);
34529}
34530function setMiningControlsExpanded(button, expanded, label) {
34531yomuKanjiStudyCompanion()?.setMiningControlsExpanded?.(button, expanded, label);
34532}
34533function openDeckPickerForCardAdd(button, card, sentence, performAction) {
34534return yomuKanjiStudyCompanion()?.openDeckPickerForCardAdd?.(button, card, sentence, performAction) ?? false;
34535}
34536const AUTO_SCAN_OBSERVER_OPTIONS = {
34537childList: true,
34538subtree: true,
34539characterData: true,
34540attributes: true,
34541attributeFilter: ["hidden", "open", "aria-hidden", "aria-expanded", "contenteditable", "role", "aria-controls", "aria-disabled", ANNOTATION_SCOPE_SURFACE_ATTRIBUTE, "style", "class"],
34542attributeOldValue: true
34543};
34544const HIDDEN_INLINE_STYLE_RE = /display\s*:\s*none|visibility\s*:\s*hidden/i;
34545const MUTATION_TEXT_SCAN_LIMIT = 4e3;
34546const MUTATION_TEXT_NODE_SCAN_LIMIT = 80;
34547const MUTATION_SHADOW_MAX_DEPTH = 4;
34548const MUTATION_SHADOW_ELEMENT_INSPECT_LIMIT = 160;
34549function createMutationJapaneseScanBudget() {
34550return {
34551inspectedElements: 0,
34552inspectedTextNodes: 0,
34553inspectedTextLength: 0,
34554elementBudgetExhausted: false,
34555textBudgetExhausted: false
34556};
34557}
34558const TEXT_REVEAL_ATTRIBUTES = new Set(["hidden", "open", "aria-hidden", "aria-expanded", "contenteditable", "role", "aria-controls", "aria-disabled", ANNOTATION_SCOPE_SURFACE_ATTRIBUTE]);
34559const READER_ROOT_SELECTOR$2 = "[data-jpdb-reader-root]";
34560const DYNAMIC_UI_DISCLOSURE_SELECTOR = [
34561"summary",
34562"[aria-controls]",
34563"[aria-expanded]",
34564'[aria-haspopup]:not([aria-haspopup="false"])',
34565'[role="menuitem"]',
34566'[role="menuitemcheckbox"]',
34567'[role="menuitemradio"]',
34568'[role="tab"]'
34569].join(",");
34570const REVIEW_ANSWER_CONTROL_SELECTOR = [
34571"button",
34572'input[type="button"]',
34573'input[type="submit"]',
34574'[role="button"]'
34575].join(",");
34576const REVIEW_ANSWER_ENGLISH_LABEL_RE = /^(?:show|reveal)\s+(?:the\s+)?answers?\b/i;
34577const REVIEW_ANSWER_JAPANESE_LABEL_RE = /^(?:答え|解答|正解).*(?:見る|表示|開く)/;
34578const JPDB_PAGE_ENHANCEMENT_ROOT_SELECTOR = [
34579".result.vocabulary",
34580".result.kanji",
34581".entry",
34582".answer-box",
34583".review-card",
34584".answer"
34585].join(",");
34586const JPDB_PAGE_ENHANCEMENT_ANCHOR_SELECTOR = [
34587".subsection-meanings",
34588".subsection-used-in",
34589".cross-table"
34590].join(",");
34591const JPDB_PAGE_ENHANCEMENT_TARGET_SELECTOR = `${JPDB_PAGE_ENHANCEMENT_ROOT_SELECTOR},${JPDB_PAGE_ENHANCEMENT_ANCHOR_SELECTOR}`;
34592const JPDB_PAGE_ENHANCEMENT_DYNAMIC_IGNORE_SELECTOR = [
34593READER_ROOT_SELECTOR$2,
34594"[data-immersion-kit]",
34595'[class*="immersion" i]'
34596].join(",");
34597const JPDB_PAGE_ENHANCEMENT_VISIBILITY_ATTRIBUTES = new Set(["hidden", "aria-hidden"]);
34598function mutationTouchesAsbPlayer(mutation) {
34599const nodes = [
34600mutation.target,
34601...Array.from(mutation.addedNodes)
34602];
34603return nodes.some((node) => {
34604const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
34605return Boolean(element?.closest?.(".asbplayer-offscreen, .asbplayer-subtitles-container-bottom"));
34606});
34607}
34608function mutationInsideReaderRoot(mutation) {
34609return mutationInsideClosest(mutation, READER_ROOT_SELECTOR$2);
34610}
34611function clickMayRevealDynamicUiText(eventOrTarget) {
34612const elements = dynamicUiClickElements(eventOrTarget);
34613if (elements.some((element) => element.closest(READER_ROOT_SELECTOR$2))) return false;
34614return elements.some((element) => Boolean(element.closest(DYNAMIC_UI_DISCLOSURE_SELECTOR)));
34615}
34616function clickMayRevealReviewAnswer(eventOrTarget) {
34617const elements = dynamicUiClickElements(eventOrTarget);
34618if (elements.some((element) => element.closest(READER_ROOT_SELECTOR$2))) return false;
34619return elements.some((element) => {
34620const control = element.closest(REVIEW_ANSWER_CONTROL_SELECTOR);
34621if (!control) return false;
34622const label = control.getAttribute("aria-label") ?? (control instanceof HTMLInputElement ? control.value : control.textContent) ?? "";
34623const normalizedLabel = label.trim();
34624return REVIEW_ANSWER_ENGLISH_LABEL_RE.test(normalizedLabel) || REVIEW_ANSWER_JAPANESE_LABEL_RE.test(normalizedLabel);
34625});
34626}
34627function dynamicUiClickElements(eventOrTarget) {
34628const path = eventOrTarget instanceof Event ? eventOrTarget.composedPath() : [eventOrTarget];
34629return path.filter((target) => target instanceof Element);
34630}
34631function mutationScanProbeCanProduceWork(settings2, canParseTargetLanguage) {
34632return canParseTargetLanguage && !settings2.annotationsPaused && !settings2.manualScanEnabled;
34633}
34634function mutationMayContainJapaneseText(mutation, budget = createMutationJapaneseScanBudget(), scopeRoots = annotationScopeRoots()) {
34635const targetNodes = mutationNodesWithinAnnotationScope(mutation.target, scopeRoots);
34636if (mutation.type === "characterData") {
34637return nodesMayContainJapanese(targetNodes, budget);
34638}
34639if (mutation.type === "attributes") {
34640const attribute = mutation.attributeName ?? "";
34641if (attribute === "style" || attribute === "class") {
34642return styleOrClassMutationRevealsJapaneseText(mutation, attribute, budget, targetNodes);
34643}
34644if (!TEXT_REVEAL_ATTRIBUTES.has(attribute)) return false;
34645return nodesMayContainJapanese(targetNodes, budget);
34646}
34647let mayContainJapanese = false;
34648const candidates = new Set(Array.from(mutation.addedNodes).flatMap((node) => mutationNodesWithinAnnotationScope(node, scopeRoots)));
34649for (const node of candidates) {
34650if (!nodeIsReaderOwned(node) && nodeTextMayContainJapanese(node, budget)) mayContainJapanese = true;
34651}
34652return mayContainJapanese;
34653}
34654function nodesMayContainJapanese(nodes, budget) {
34655let found = false;
34656for (const node of nodes) {
34657if (nodeTextMayContainJapanese(node, budget)) found = true;
34658}
34659return found;
34660}
34661function mutationNodesWithinAnnotationScope(node, scopeRoots) {
34662if (!scopeRoots) return [node];
34663if (nodeWithinAnnotationScope(node, scopeRoots)) return [node];
34664if (!(node instanceof Element || node instanceof DocumentFragment)) return [];
34665return scopeRoots.filter((root) => node === root || node.contains(root));
34666}
34667function nodeIsReaderOwned(node) {
34668const element = mutationNodeElement(node);
34669return Boolean(element?.closest(`${READER_ROOT_SELECTOR$2},.jpdb-reader-text-mirror`));
34670}
34671function styleOrClassMutationRevealsJapaneseText(mutation, attribute, budget, candidates) {
34672const element = mutation.target instanceof HTMLElement ? mutation.target : null;
34673if (!element || !candidates.length) return false;
34674const current = element.getAttribute(attribute) ?? "";
34675if ((mutation.oldValue ?? "") === current) return false;
34676if (attribute === "style") {
34677const wasHidden = HIDDEN_INLINE_STYLE_RE.test(mutation.oldValue ?? "");
34678if (!wasHidden || HIDDEN_INLINE_STYLE_RE.test(current)) return false;
34679}
34680if (!elementRendersNow(element)) return false;
34681return nodesMayContainJapanese(candidates, budget);
34682}
34683function elementRendersNow(element) {
34684if (!element.isConnected) return false;
34685if (element.closest("[hidden]")) return false;
34686if (typeof element.checkVisibility === "function") return element.checkVisibility();
34687const view = element.ownerDocument.defaultView;
34688if (!view) return false;
34689const style = view.getComputedStyle(element);
34690return style.display !== "none" && style.visibility !== "hidden";
34691}
34692function nodeTextMayContainJapanese(node, budget) {
34693if (node.nodeType === Node.TEXT_NODE) return textNodeMayContainJapanese(node, budget);
34694const root = node.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? node : mutationNodeElement(node);
34695return root ? nodeTreeTextMayContainJapanese(root, budget) : false;
34696}
34697function nodeTreeTextMayContainJapanese(root, budget) {
34698let found = root.nodeType === Node.ELEMENT_NODE && probeComposedElement(root, MUTATION_SHADOW_MAX_DEPTH, budget);
34699if (budget.elementBudgetExhausted || budget.textBudgetExhausted) return true;
34700const walker = (root.ownerDocument ?? document).createTreeWalker(
34701root,
34702NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
34703);
34704let node;
34705while (node = walker.nextNode()) {
34706if (node.nodeType === Node.TEXT_NODE) {
34707if (textNodeMayContainJapanese(node, budget)) found = true;
34708} else if (probeComposedElement(node, MUTATION_SHADOW_MAX_DEPTH, budget)) {
34709found = true;
34710}
34711if (budget.elementBudgetExhausted || budget.textBudgetExhausted) return true;
34712}
34713return found;
34714}
34715function probeComposedShadowRoot(shadowRoot, remainingDepth, budget) {
34716if (!shadowRoot) return false;
34717noteScannedShadowRoot(shadowRoot);
34718if (remainingDepth <= 0) {
34719budget.elementBudgetExhausted = true;
34720return true;
34721}
34722if (budget.elementBudgetExhausted || budget.textBudgetExhausted) return true;
34723let found = false;
34724const walker = shadowRoot.ownerDocument.createTreeWalker(
34725shadowRoot,
34726NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
34727);
34728let node;
34729while (node = walker.nextNode()) {
34730if (node.nodeType === Node.TEXT_NODE) {
34731if (textNodeMayContainJapanese(node, budget)) found = true;
34732} else if (probeComposedElement(node, remainingDepth - 1, budget)) {
34733found = true;
34734}
34735if (budget.elementBudgetExhausted || budget.textBudgetExhausted) return true;
34736}
34737return found;
34738}
34739function probeComposedElement(element, remainingDepth, budget) {
34740if (!consumeProbeElement(budget)) return true;
34741const shadowRoot = element instanceof HTMLElement ? watchPotentialOpenShadowRootHost(element) : element.shadowRoot;
34742return probeComposedShadowRoot(shadowRoot, remainingDepth, budget);
34743}
34744function consumeProbeElement(budget) {
34745if (budget.inspectedElements >= MUTATION_SHADOW_ELEMENT_INSPECT_LIMIT) {
34746budget.elementBudgetExhausted = true;
34747return false;
34748}
34749budget.inspectedElements += 1;
34750return true;
34751}
34752function textNodeMayContainJapanese(node, budget) {
34753if (budget.inspectedTextNodes >= MUTATION_TEXT_NODE_SCAN_LIMIT || budget.inspectedTextLength >= MUTATION_TEXT_SCAN_LIMIT) {
34754budget.textBudgetExhausted = true;
34755return true;
34756}
34757budget.inspectedTextNodes += 1;
34758const text2 = node.textContent ?? "";
34759const remainingLength = MUTATION_TEXT_SCAN_LIMIT - budget.inspectedTextLength;
34760const sampledText = text2.slice(0, remainingLength);
34761budget.inspectedTextLength += sampledText.length;
34762if (isTargetLanguageText(sampledText)) return true;
34763if (sampledText.length < text2.length) {
34764budget.textBudgetExhausted = true;
34765return true;
34766}
34767return false;
34768}
34769function mutationMayAffectJpdbPageEnhancements(mutation) {
34770if (mutation.type === "attributes") return jpdbPageEnhancementAttributeMayAffect(mutation);
34771if (mutation.type === "characterData") return nodeMayAffectJpdbPageEnhancements(mutation.target);
34772if (mutation.type === "childList") return childListMutationMayAffectJpdbPageEnhancements(mutation);
34773return false;
34774}
34775function childListMutationMayAffectJpdbPageEnhancements(mutation) {
34776const nodes = [
34777...Array.from(mutation.addedNodes),
34778...Array.from(mutation.removedNodes)
34779];
34780return nodes.some(nodeMayAffectJpdbPageEnhancements) || childListTargetMayAffectJpdbPageEnhancements(mutation.target);
34781}
34782function childListTargetMayAffectJpdbPageEnhancements(node) {
34783const element = mutationNodeElement(node);
34784if (!element || element.closest(JPDB_PAGE_ENHANCEMENT_DYNAMIC_IGNORE_SELECTOR)) return false;
34785return elementMatchesJpdbPageEnhancementTarget(element) || Boolean(element.closest(JPDB_PAGE_ENHANCEMENT_TARGET_SELECTOR));
34786}
34787function jpdbPageEnhancementAttributeMayAffect(mutation) {
34788return JPDB_PAGE_ENHANCEMENT_VISIBILITY_ATTRIBUTES.has(mutation.attributeName ?? "") && nodeMayAffectJpdbPageEnhancements(mutation.target);
34789}
34790function nodeMayAffectJpdbPageEnhancements(node) {
34791const element = mutationNodeElement(node);
34792if (!element || element.closest(JPDB_PAGE_ENHANCEMENT_DYNAMIC_IGNORE_SELECTOR)) return false;
34793if (node.nodeType === Node.TEXT_NODE) return textNodeMayAffectJpdbPageEnhancements(node, element);
34794return elementMayAffectJpdbPageEnhancements(element);
34795}
34796function textNodeMayAffectJpdbPageEnhancements(node, parent) {
34797return isTargetLanguageText(node.textContent ?? "") && Boolean(parent.closest(JPDB_PAGE_ENHANCEMENT_ROOT_SELECTOR));
34798}
34799function elementMayAffectJpdbPageEnhancements(element) {
34800return elementMatchesJpdbPageEnhancementTarget(element) || elementContainsJpdbPageEnhancementTarget(element) || elementTextMayAffectJpdbPageEnhancements(element);
34801}
34802function elementMatchesJpdbPageEnhancementTarget(element) {
34803return element.matches(JPDB_PAGE_ENHANCEMENT_ROOT_SELECTOR) || element.matches(JPDB_PAGE_ENHANCEMENT_ANCHOR_SELECTOR);
34804}
34805function elementContainsJpdbPageEnhancementTarget(element) {
34806return Boolean(element.querySelector(JPDB_PAGE_ENHANCEMENT_TARGET_SELECTOR));
34807}
34808function elementTextMayAffectJpdbPageEnhancements(element) {
34809return isTargetLanguageText(element.textContent ?? "") && Boolean(element.closest(JPDB_PAGE_ENHANCEMENT_ROOT_SELECTOR));
34810}
34811function mutationNodeElement(node) {
34812if (node.nodeType === Node.ELEMENT_NODE) return node;
34813return node.parentElement;
34814}
34815const STORED_TITLE_ATTRIBUTE = "data-jpdb-reader-native-title";
34816class NativeTitleGuard {
34817suppressed = new Map();
34818observer;
34819suppressForPopover(popover, anchor) {
34820this.restore();
34821this.suppressRelatedTitles(popover, anchor);
34822this.observePopover(popover, anchor);
34823}
34824restore() {
34825this.observer?.disconnect();
34826this.observer = void 0;
34827for (const [element, title] of this.suppressed) {
34828if (element.isConnected || element.hasAttribute(STORED_TITLE_ATTRIBUTE)) {
34829element.setAttribute("title", title);
34830element.removeAttribute(STORED_TITLE_ATTRIBUTE);
34831}
34832}
34833this.suppressed.clear();
34834}
34835suppressRelatedTitles(popover, anchor) {
34836this.suppressElementAndDescendants(popover);
34837this.suppressAnchorTitlePath(anchor);
34838}
34839observePopover(popover, anchor) {
34840if (typeof MutationObserver === "undefined") return;
34841this.observer = new MutationObserver((mutations) => {
34842for (const mutation of mutations) {
34843if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) {
34844this.suppressElement(mutation.target);
34845}
34846mutation.addedNodes.forEach((node) => this.suppressNodeTitles(node));
34847}
34848this.suppressAnchorTitlePath(anchor);
34849});
34850this.observer.observe(popover, {
34851attributes: true,
34852attributeFilter: ["title"],
34853childList: true,
34854subtree: true
34855});
34856this.observeAnchorTitlePath(anchor);
34857}
34858suppressNodeTitles(node) {
34859if (node instanceof HTMLElement) this.suppressElementAndDescendants(node);
34860}
34861suppressElementAndDescendants(element) {
34862this.suppressElement(element);
34863element.querySelectorAll("[title]").forEach((item) => this.suppressElement(item));
34864}
34865suppressAnchorTitlePath(anchor) {
34866if (!anchor) return;
34867for (const element of this.anchorTitlePath(anchor)) this.suppressElement(element);
34868}
34869observeAnchorTitlePath(anchor) {
34870if (!this.observer || !anchor) return;
34871for (const element of this.anchorTitlePath(anchor)) {
34872this.observer.observe(element, {
34873attributes: true,
34874attributeFilter: ["title"]
34875});
34876}
34877}
34878anchorTitlePath(anchor) {
34879const path = [];
34880let current = anchor;
34881while (current) {
34882path.push(current);
34883if (current === document.body || current === document.documentElement) break;
34884current = this.nextTitleAncestor(current);
34885}
34886return path;
34887}
34888nextTitleAncestor(element) {
34889if (element.parentElement) return element.parentElement;
34890const root = element.getRootNode();
34891return typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement ? root.host : null;
34892}
34893suppressElement(element) {
34894if (element.closest(".jpdb-reader-modal-nav")) return;
34895if (!element.hasAttribute("title")) return;
34896const title = element.getAttribute("title") ?? "";
34897this.suppressed.set(element, title);
34898element.setAttribute(STORED_TITLE_ATTRIBUTE, title);
34899element.removeAttribute("title");
34900}
34901}
34902const PARSEABLE_SELECTOR = ".jpdb-reader-parseable";
34903const POPOVER_SUMMARY_PARSE_SELECTOR = ".jpdb-reader-popover summary.jpdb-reader-example-summary";
34904const POPOVER_SOURCE_TITLE_PARSE_SELECTOR = ".jpdb-reader-popover summary.jpdb-reader-local-title";
34905const NESTED_PARSE_ROOT_SELECTOR = [
34906PARSEABLE_SELECTOR,
34907POPOVER_SUMMARY_PARSE_SELECTOR,
34908POPOVER_SOURCE_TITLE_PARSE_SELECTOR
34909].join(",");
34910const READER_WORD_SELECTOR = ".jpdb-reader-word";
34911const EXAMPLE_TARGET_SELECTOR = ".jpdb-reader-example-target";
34912const NESTED_PARSE_EXCLUDE_SELECTOR = ".gloss-image-link";
34913function nestedTextParsePlan(root, limit, options = {}) {
34914const discoveredRoots = root.matches(NESTED_PARSE_ROOT_SELECTOR) ? [root] : Array.from(root.querySelectorAll(NESTED_PARSE_ROOT_SELECTOR));
34915const parseRoots = options.excludeProviderExamples ? discoveredRoots.filter((parseRoot) => !parseRoot.matches("[data-provider-example-sentence]")) : discoveredRoots;
34916const renderedParseKey = renderedNestedParseKey(parseRoots);
34917if (renderedParseKey && nestedParseAlreadyScheduled(root, renderedParseKey)) return null;
34918normalizePartiallyParsedRoots(root, parseRoots);
34919const targets = [...parseRoots].sort((left, right) => providerExamplePriority(left) - providerExamplePriority(right)).flatMap((parseRoot) => nestedParseTargetsIn(parseRoot, limit, false, NESTED_PARSE_EXCLUDE_SELECTOR, {
34920includeReaderRoot: true,
34921allowUiText: true,
34922includePassiveInteractions: true,
34923heading: true,
34924minLength: 1,
34925readerRootPassiveInteractions: true,
34926parseSurfaceIgnoredRoot: true
34927})).slice(0, limit);
34928return targets.length ? { targets, parseKey: nestedParseKey(targets) } : null;
34929}
34930function providerExamplePriority(parseRoot) {
34931return parseRoot.matches("[data-provider-example-sentence]") ? 0 : 1;
34932}
34933function providerExampleTextParsePlan(root, limit) {
34934const parseRoots = root.matches("[data-provider-example-sentence]") ? [root] : Array.from(root.querySelectorAll("[data-provider-example-sentence]"));
34935if (!parseRoots.length) return null;
34936normalizePartiallyParsedRoots(root, parseRoots);
34937const targets = parseRoots.flatMap((parseRoot) => nestedParseTargetsIn(parseRoot, limit, false, NESTED_PARSE_EXCLUDE_SELECTOR, {
34938includeReaderRoot: true,
34939allowUiText: true,
34940includePassiveInteractions: true,
34941heading: true,
34942minLength: 1,
34943readerRootPassiveInteractions: true,
34944parseSurfaceIgnoredRoot: true
34945})).slice(0, limit);
34946return targets.length ? { targets, parseKey: nestedParseKey(targets) } : null;
34947}
34948function nestedParseTargetsIn(parseRoot, limit, visibleOnly, excludeSelector, options) {
34949const fragmentTargets = collectFragmentTextTargetsIn(parseRoot, limit, visibleOnly, excludeSelector, options);
34950const remaining = Math.max(0, limit - fragmentTargets.length);
34951if (options?.includeFormControls === false) return fragmentTargets;
34952const controlTargets = collectFormControlTextTargetsIn(parseRoot, remaining, visibleOnly, {
34953includeReaderRoot: options?.includeReaderRoot,
34954excludeSelector: options?.formControlExcludeSelector ?? excludeSelector,
34955selectTextMode: options?.formControlSelectTextMode
34956});
34957return [...fragmentTargets, ...controlTargets];
34958}
34959function nestedParseAlreadyScheduled(root, parseKey) {
34960return root.dataset.jpdbReaderParseLoadingKey === parseKey || root.dataset.jpdbReaderParseKey === parseKey;
34961}
34962function applyNestedParsePlan(plan, parsed, settings2) {
34963plan.targets.forEach((target, index) => {
34964if (isCurrentScanTarget(target)) applyTokensToScanTarget(target, parsed[index] ?? [], settings2);
34965});
34966}
34967function clearNestedParseLoadingKey(root, parseKey, parseLoadingId) {
34968const matchesKey = root.dataset.jpdbReaderParseLoadingKey === parseKey;
34969const matchesId = parseLoadingId === void 0 || root.dataset.jpdbReaderParseLoadingId === parseLoadingId;
34970if (!matchesKey || !matchesId) return;
34971delete root.dataset.jpdbReaderParseLoadingKey;
34972delete root.dataset.jpdbReaderParseLoadingId;
34973}
34974function clearNestedParseState(root) {
34975delete root.dataset.jpdbReaderParseKey;
34976delete root.dataset.jpdbReaderParseLoadingKey;
34977delete root.dataset.jpdbReaderParseLoadingId;
34978}
34979function normalizePartiallyParsedRoots(root, parseRoots) {
34980let changed = false;
34981for (const parseRoot of parseRoots) {
34982if (!shouldNormalizePartiallyParsedRoot(parseRoot)) continue;
34983preserveExampleTargetMarks(parseRoot);
34984changed = unwrapReaderWords(parseRoot, { includeReaderRoot: true }) > 0 || changed;
34985}
34986if (changed) clearNestedParseState(root);
34987}
34988function shouldNormalizePartiallyParsedRoot(parseRoot) {
34989return Boolean(parseRoot.querySelector(READER_WORD_SELECTOR)) && hasUnparsedJapaneseText(parseRoot);
34990}
34991function preserveExampleTargetMarks(parseRoot) {
34992parseRoot.querySelectorAll(`${READER_WORD_SELECTOR}${EXAMPLE_TARGET_SELECTOR}`).forEach((word) => {
34993if (word.closest(`mark${EXAMPLE_TARGET_SELECTOR}`)) return;
34994const mark = document.createElement("mark");
34995mark.className = EXAMPLE_TARGET_SELECTOR.slice(1);
34996word.replaceWith(mark);
34997mark.append(word);
34998});
34999}
35000function hasUnparsedJapaneseText(parseRoot, excludeSelector = "") {
35001const walker = document.createTreeWalker(parseRoot, NodeFilter.SHOW_TEXT, {
35002acceptNode: (node) => {
35003const parent = node.parentElement;
35004if (!parent || parent.closest(READER_WORD_SELECTOR) || parent.closest("[data-jpdb-reader-surface-ignore]") || excludeSelector && parent.closest(excludeSelector)) return NodeFilter.FILTER_REJECT;
35005return isTargetLanguageText(node.textContent || "") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
35006}
35007});
35008return Boolean(walker.nextNode());
35009}
35010function renderedNestedParseKey(parseRoots) {
35011const renderedRoots = parseRoots.filter((parseRoot) => parseRoot.querySelector(READER_WORD_SELECTOR)).map((parseRoot) => readerWordSurfaceText(parseRoot).trim()).filter(Boolean);
35012return renderedRoots.length === parseRoots.length ? renderedRoots.join("\n\n") : "";
35013}
35014function nestedParseKey(targets) {
35015return targets.map((target) => target.text).join("\n\n");
35016}
35017async function translateJapaneseSentence(sentence, language = "en") {
35018return (await translateTargetSentence(sentence, language))?.text ?? "";
35019}
35020async function translateTargetSentence(sentence, outputLanguage = "en") {
35021return await (yomuKanjiStudyCompanion()?.translateTargetSentence?.(sentence, outputLanguage) ?? Promise.resolve(null));
35022}
35023const LANGUAGE_FAMILY_CLASSES = [
35024"jp-only",
35025"jpzhyue-only",
35026"jpzhyueko-only",
35027"not-jpzhyueko"
35028];
35029const familyNodesByRoot = new WeakMap();
35030function syncLanguageFamilyDom(root, language) {
35031const base = languageSubtag(language) ?? language.toLowerCase();
35032root.dataset.language = base;
35033for (const state of languageFamilyNodes(root)) {
35034if (languageFamilyIncludes(state.family, base)) {
35035if (!state.node.parentNode) state.placeholder.after(state.node);
35036} else {
35037state.node.remove();
35038}
35039}
35040}
35041function languageFamilyIncludes(family, language) {
35042const base = languageSubtag(language) ?? language.toLowerCase();
35043if (family === "jp-only") return base === "ja";
35044const jpZhYue = base === "ja" || base === "zh" || base === "yue";
35045if (family === "jpzhyue-only") return jpZhYue;
35046const jpZhYueKo = jpZhYue || base === "ko";
35047return family === "jpzhyueko-only" ? jpZhYueKo : !jpZhYueKo;
35048}
35049function jpOnlyOn(settings2, storedValue, chosen) {
35050return storedValue && (chosen || languageFamilyIncludes("jp-only", targetLanguageOf(settings2)));
35051}
35052function languageFamilyNodes(root) {
35053const states = familyNodesByRoot.get(root) ?? [];
35054const selector = LANGUAGE_FAMILY_CLASSES.map((family) => `.${family}`).join(",");
35055const knownNodes = new Set(states.map((state) => state.node));
35056const discovered = Array.from(root.querySelectorAll(selector)).filter((node) => !knownNodes.has(node)).filter((node) => !node.parentElement?.closest(selector)).map((node) => {
35057const family = LANGUAGE_FAMILY_CLASSES.find((value) => node.classList.contains(value));
35058if (!family) throw new TypeError("Language-family node has no supported family class.");
35059const placeholder = root.ownerDocument.createComment(`yomu-language-family:${family}`);
35060node.before(placeholder);
35061return { family, node, placeholder };
35062});
35063states.push(...discovered);
35064familyNodesByRoot.set(root, states);
35065return states;
35066}
35067const { resolveInterfaceLocale } = aggregateRuntimeModules().interfaceLocaleResolution;
35068async function installPreferredJapaneseSiteLanguageFromStoredSettings() {
35069if (isHostedReaderRuntime()) return;
35070await yomuVideoCompanionSlot()?.installPreferredJapaneseSiteLanguageFromStoredSettings?.();
35071}
35072function applyPreferredJapaneseSiteLanguage(enabled, revertOnDisable = false, deferCookieResponseReloadUntilPersisted = false, targetLanguage = "ja") {
35073if (isHostedReaderRuntime()) return;
35074yomuVideoCompanionSlot()?.applyPreferredJapaneseSiteLanguage?.(
35075enabled,
35076revertOnDisable,
35077deferCookieResponseReloadUntilPersisted,
35078targetLanguage
35079);
35080}
35081function ocrInteractionModeFromSettings(settings2) {
35082if (!settings2.ocrEnabled) return "off";
35083return settings2.ocrAutoScanImages ? "auto" : "manual";
35084}
35085function nextOcrInteractionMode(mode) {
35086if (mode === "auto") return "manual";
35087if (mode === "manual") return "off";
35088return "auto";
35089}
35090function applyOcrInteractionMode(settings2, mode) {
35091settings2.ocrEnabled = mode !== "off";
35092settings2.ocrAutoScanImages = mode === "auto";
35093}
35094const READER_ROOT_SELECTOR$1 = "[data-jpdb-reader-root]";
35095const POINTER_TEXT_STRUCTURAL_SKIP_SELECTOR = [
35096"script",
35097"style",
35098"noscript",
35099"textarea",
35100"input",
35101"select",
35102"option",
35103"svg",
35104"use",
35105"rt",
35106"rp",
35107"[contenteditable]",
35108'[role="textbox"]',
35109'[role="searchbox"]',
35110'[role="combobox"]',
35111"[aria-placeholder]",
35112"[data-placeholder]",
35113'[class*="placeholder" i]',
35114READER_ROOT_SELECTOR$1
35115].join(",");
35116const POINTER_TEXT_INTERACTIVE_SKIP_SELECTOR = [
35117"button",
35118"summary",
35119'[role="button"]',
35120'[role="checkbox"]',
35121'[role="radio"]',
35122'[role="tab"]',
35123"[onclick]"
35124].join(",");
35125const POINTER_TEXT_SKIP_SELECTOR = `${POINTER_TEXT_STRUCTURAL_SKIP_SELECTOR},${POINTER_TEXT_INTERACTIVE_SKIP_SELECTOR}`;
35126const READER_ROOT_POINTER_TEXT_LINK_SELECTOR = `${READER_ROOT_SELECTOR$1} .jpdb-reader-local-glossary a[href]`;
35127const SCREEN_READER_ONLY_CLASS_RE = /(^|[-_\s])(sr-only|screen-reader-text|visually-hidden|visuallyhidden)([-_\s]|$)/i;
35128const YOUTUBE_METADATA_SELECTOR = [
35129"#metadata",
35130"#metadata-line",
35131"#metadata-text",
35132"#video-info",
35133"#stats",
35134"ytd-video-meta-block",
35135"yt-content-metadata-view-model",
35136".inline-metadata-item",
35137".badge-style-type-simple"
35138].join(",");
35139const METADATA_TOKEN_RE = /^(?:[\d0-9][\d0-9,.,]*\s*)?(?:万|億)?(?:回視聴|視聴|再生|回再生|件|コメント|高評価|日前|時間前|分前|秒前|か月前|ヶ月前|週間前|年前|ライブ配信中|新着)$/u;
35140const POINTER_TEXT_CONTEXT_ROOT_SELECTOR = [
35141"p",
35142"li",
35143"figcaption",
35144"blockquote",
35145"h1",
35146"h2",
35147"h3",
35148"h4",
35149"h5",
35150"h6",
35151"a",
35152"yt-formatted-string",
35153"yt-attributed-string",
35154'[id="content-text"]',
35155'[id="video-title"]',
35156"[data-jpdb-reader-context]"
35157].join(",");
35158const POINTER_TEXT_CONTEXT_SKIP_SELECTOR = [
35159"script",
35160"style",
35161"noscript",
35162"textarea",
35163"input",
35164"select",
35165"button",
35166"option",
35167"summary",
35168"svg",
35169"use",
35170"rt",
35171"rp",
35172'[aria-hidden="true"]',
35173"[hidden]"
35174].join(",");
35175const POINTER_TEXT_CONTEXT_MAX_LENGTH = 220;
35176const POINTER_TEXT_INLINE_CONTEXT_MAX_LENGTH = 80;
35177const POINTER_TEXT_INLINE_CONTEXT_MAX_DEPTH = 4;
35178function caretTextPositionFromPoint(x, y) {
35179const doc = document;
35180const position = doc.caretPositionFromPoint?.(x, y);
35181if (position?.offsetNode.nodeType === Node.TEXT_NODE) {
35182return { node: position.offsetNode, offset: position.offset };
35183}
35184const range = doc.caretRangeFromPoint?.(x, y);
35185if (range?.startContainer.nodeType === Node.TEXT_NODE) {
35186return { node: range.startContainer, offset: range.startOffset };
35187}
35188return null;
35189}
35190const pointerTextRunAt = targetPointerWordAt;
35191function pointerTextCharacterOffset(node, caretOffset, x, y) {
35192const parent = node.parentElement;
35193if (!parent || !isPointerTextParentEligible(parent)) return null;
35194const clamped = Math.min(Math.max(caretOffset, 0), node.data.length - 1);
35195const candidates = [clamped, clamped - 1, clamped + 1].filter((offset, index, offsets) => offset >= 0 && offset < node.data.length && offsets.indexOf(offset) === index);
35196return candidates.find((offset) => textCharacterContainsPoint(node, offset, x, y)) ?? null;
35197}
35198function pointerTextLookupFromRenderedWord(word, x, y) {
35199const context = renderedWordLookupContext(word);
35200if (!context) return null;
35201const surfaceOffset = renderedWordSurfaceOffsetAtPoint(word, context.surface, x, y);
35202if (surfaceOffset === null) return null;
35203const offset = context.tokenStart + surfaceOffset;
35204const run = pointerTextRunAt(context.sentence, offset);
35205if (!run) return null;
35206return {
35207text: context.sentence,
35208offset: run.offset,
35209start: run.start,
35210end: run.end,
35211anchor: word
35212};
35213}
35214function pointerTextLookupFromRenderedWordStart(word) {
35215const context = renderedWordLookupContext(word);
35216if (!context) return null;
35217const run = pointerTextRunAt(context.sentence, context.tokenStart);
35218if (!run) return null;
35219return {
35220text: context.sentence,
35221offset: run.offset,
35222start: run.start,
35223end: run.end,
35224anchor: word
35225};
35226}
35227function renderedWordLookupContext(word) {
35228const sentence = word.dataset.sentence ?? "";
35229const surface = readerWordSurfaceText(word);
35230if (!sentence || !surface) return null;
35231const range = renderedWordTokenRange(word);
35232if (!range || sentence.slice(range.start, range.end) !== surface) return null;
35233return { sentence, surface, tokenStart: range.start };
35234}
35235function renderedWordTokenRange(word) {
35236const start = Number.parseInt(word.dataset.tokenStart ?? "", 10);
35237const end = Number.parseInt(word.dataset.tokenEnd ?? "", 10);
35238if (![start, end].every(Number.isFinite)) return null;
35239if (start < 0) return null;
35240if (end <= start) return null;
35241return { start, end };
35242}
35243function pointerTextLookupFromTextNode(node, characterOffset, options = {}) {
35244const parent = node.parentElement;
35245if (!parent || !isPointerTextParentEligible(parent, options)) return null;
35246const local = pointerTextLookupForText(parent, node.data, characterOffset);
35247const contextual = pointerTextLookupContext(node, characterOffset, parent);
35248return contextual ?? local;
35249}
35250function renderedWordSurfaceOffsetAtPoint(word, surface, x, y) {
35251const nodes = renderedWordSurfaceTextNodes(word);
35252const caretOffset = renderedWordCaretOffset(nodes, surface.length, x, y);
35253if (caretOffset !== null) return caretOffset;
35254const rangeOffset = renderedWordRangeOffsetAtPoint(nodes, x, y);
35255if (rangeOffset !== null && rangeOffset < surface.length) return rangeOffset;
35256return proportionalRenderedWordOffset(word, surface.length, x, y);
35257}
35258function renderedWordCaretOffset(nodes, surfaceLength, x, y) {
35259const caret = caretTextPositionFromPoint(x, y);
35260if (!caret) return null;
35261let preceding = 0;
35262for (const node of nodes) {
35263if (node === caret.node && node.data.length) {
35264const nodeOffset = Math.min(Math.max(caret.offset, 0), node.data.length - 1);
35265return Math.min(surfaceLength - 1, preceding + nodeOffset);
35266}
35267preceding += node.data.length;
35268}
35269return null;
35270}
35271function renderedWordSurfaceTextNodes(word) {
35272const nodes = [];
35273const visit = (node) => {
35274if (node.nodeType === Node.TEXT_NODE) {
35275nodes.push(node);
35276return;
35277}
35278if (node.nodeType !== Node.ELEMENT_NODE) return;
35279const element = node;
35280if (element !== word && element.matches("rt,rp,script,style,[data-jpdb-reader-surface-ignore],.jpdb-reader-furi,.jpdb-ocr-furi")) return;
35281element.childNodes.forEach(visit);
35282};
35283visit(word);
35284return nodes;
35285}
35286function renderedWordRangeOffsetAtPoint(nodes, x, y) {
35287const hits = [];
35288let surfaceOffset = 0;
35289for (const node of nodes) {
35290for (let offset = 0; offset < node.data.length; offset++) {
35291const hit = renderedWordCharacterHit(node, offset, surfaceOffset + offset, x, y);
35292if (hit) hits.push(hit);
35293}
35294surfaceOffset += node.data.length;
35295}
35296return hits.sort((left, right) => left.score - right.score)[0]?.offset ?? null;
35297}
35298function renderedWordCharacterHit(node, nodeOffset, surfaceOffset, x, y) {
35299const range = document.createRange();
35300try {
35301range.setStart(node, nodeOffset);
35302range.setEnd(node, nodeOffset + 1);
35303const scores = Array.from(range.getClientRects()).map((rect) => pointerRectScore(rect, x, y)).filter((score) => score !== null);
35304return scores.length ? { offset: surfaceOffset, score: Math.min(...scores) } : null;
35305} catch {
35306return null;
35307} finally {
35308range.detach?.();
35309}
35310}
35311function pointerRectScore(rect, x, y) {
35312const bounds = pointerRectBoundsAtPoint(rect, x, y);
35313if (!bounds) return null;
35314return Math.hypot(x - (rect.left + bounds.right) / 2, y - (rect.top + bounds.bottom) / 2);
35315}
35316function proportionalRenderedWordOffset(word, surfaceLength, x, y) {
35317if (surfaceLength <= 0) return null;
35318const rect = word.getBoundingClientRect();
35319const bounds = pointerRectBoundsAtPoint(rect, x, y);
35320if (!bounds) return null;
35321const vertical = getComputedStyle(word).writingMode.startsWith("vertical");
35322const span = vertical ? bounds.bottom - rect.top : bounds.right - rect.left;
35323const position = vertical ? y - rect.top : x - rect.left;
35324return proportionalSurfaceOffset(surfaceLength, position, span);
35325}
35326function pointerRectBoundsAtPoint(rect, x, y) {
35327const right = rect.right || rect.left + rect.width;
35328const bottom = rect.bottom || rect.top + rect.height;
35329if (!hasPositiveRectArea(rect, right, bottom)) return null;
35330if (!coordinateInRange(x, rect.left, right, 1)) return null;
35331if (!coordinateInRange(y, rect.top, bottom, 1)) return null;
35332return { right, bottom };
35333}
35334function proportionalSurfaceOffset(surfaceLength, position, span) {
35335const progress = span > 0 ? Math.min(0.999999, Math.max(0, position / span)) : 0;
35336return Math.min(surfaceLength - 1, Math.floor(progress * surfaceLength));
35337}
35338function isLowValuePointerText(text2, parent) {
35339const compact2 = text2.replace(/\s+/g, "");
35340if (!compact2) return true;
35341if (parent?.closest(YOUTUBE_METADATA_SELECTOR)) return true;
35342const parts = compact2.split(/[・•||//()[\]【】「」『』<>〈〉《》]+/u).map((part) => part.trim()).filter(Boolean);
35343if (!parts.length) return false;
35344return parts.every((part) => METADATA_TOKEN_RE.test(part));
35345}
35346function pointerTextLookupContext(node, characterOffset, anchor) {
35347const root = pointerTextContextRoot(anchor);
35348if (!root || root === anchor) return null;
35349const context = readablePointerTextContext(root, node);
35350if (!context || context.text.length > POINTER_TEXT_CONTEXT_MAX_LENGTH) return null;
35351const offset = context.start + Math.min(Math.max(characterOffset, 0), node.data.length - 1);
35352const lookup = pointerTextLookupForText(anchor, context.text, offset);
35353if (!lookup || lookup.end - lookup.start <= localPointerWordLength(node.data, characterOffset)) return null;
35354return isLowValuePointerText(lookup.text, root) ? null : lookup;
35355}
35356function pointerTextLookupForText(anchor, text2, offset) {
35357const run = pointerTextRunAt(text2, offset);
35358if (!run || isLowValuePointerText(text2, anchor)) return null;
35359return {
35360text: text2,
35361offset: run.offset,
35362start: run.start,
35363end: run.end,
35364anchor
35365};
35366}
35367function localPointerWordLength(text2, offset) {
35368const run = pointerTextRunAt(text2, offset);
35369return run ? run.end - run.start : 0;
35370}
35371function pointerTextContextRoot(anchor) {
35372const root = anchor.closest(POINTER_TEXT_CONTEXT_ROOT_SELECTOR);
35373if (!root) return pointerTextInlineContextRoot(anchor);
35374if (!isPointerTextParentEligible(root)) return null;
35375return root === anchor ? pointerTextInlineContextRoot(anchor) : root;
35376}
35377function pointerTextInlineContextRoot(anchor) {
35378const localText = anchor.textContent ?? "";
35379let current = anchor.parentElement;
35380for (let depth = 0; current && depth < POINTER_TEXT_INLINE_CONTEXT_MAX_DEPTH; depth++, current = current.parentElement) {
35381if (current === document.body || current === document.documentElement) return null;
35382if (isPointerTextInlineContextCandidate(current, localText.length)) {
35383return current;
35384}
35385}
35386return null;
35387}
35388function isPointerTextInlineContextCandidate(element, localTextLength) {
35389const text2 = element.textContent ?? "";
35390return text2.length > localTextLength && text2.length <= POINTER_TEXT_INLINE_CONTEXT_MAX_LENGTH && hasTargetPointerWord(text2) && isPointerTextParentEligible(element);
35391}
35392function readablePointerTextContext(root, target) {
35393let text2 = "";
35394let rangeStart = -1;
35395let rangeEnd = -1;
35396const visit = (node) => {
35397if (node.nodeType === Node.TEXT_NODE) {
35398const value = node.textContent ?? "";
35399if (node === target) {
35400rangeStart = text2.length;
35401rangeEnd = text2.length + value.length;
35402}
35403text2 += value;
35404return;
35405}
35406if (node.nodeType !== Node.ELEMENT_NODE) return;
35407const element = node;
35408if (element.matches(POINTER_TEXT_CONTEXT_SKIP_SELECTOR)) return;
35409element.childNodes.forEach(visit);
35410};
35411visit(root);
35412if (rangeStart < 0 || rangeEnd <= rangeStart || !hasTargetPointerWord(text2)) return null;
35413const leadingWhitespace = text2.match(/^\s*/u)?.[0].length ?? 0;
35414const trailingWhitespace = text2.match(/\s*$/u)?.[0].length ?? 0;
35415const trimmedText = text2.slice(leadingWhitespace, text2.length - trailingWhitespace);
35416if (!trimmedText || !hasTargetPointerWord(trimmedText)) return null;
35417return {
35418text: trimmedText,
35419start: rangeStart - leadingWhitespace,
35420end: rangeEnd - leadingWhitespace
35421};
35422}
35423function isPointerTextParentEligible(parent, options = {}) {
35424const insideSubtitle = Boolean(parent.closest(".jpdb-subtitle-player, .jpdb-subtitle-list"));
35425const allowReaderRoot = Boolean(parent.closest(READER_ROOT_POINTER_TEXT_LINK_SELECTOR));
35426let current = parent;
35427while (current) {
35428if (!isPointerTextElementEligible(current, allowReaderRoot, insideSubtitle, options)) return false;
35429current = current.parentElement;
35430}
35431return true;
35432}
35433function isPointerTextElementEligible(element, allowReaderRoot = false, insideSubtitle = false, options = {}) {
35434const style = getComputedStyle(element);
35435return elementPassesPointerTextAttributes(element, allowReaderRoot, insideSubtitle, options) && stylePassesPointerTextLookup(style) && !isScreenReaderOnlyElement(element, style);
35436}
35437function elementPassesPointerTextAttributes(element, allowReaderRoot, insideSubtitle = false, options = {}) {
35438if (element.hasAttribute("hidden") || element.hasAttribute("inert") || element.getAttribute("aria-hidden")?.toLowerCase() === "true") {
35439return false;
35440}
35441if (insideSubtitle) {
35442if (element.matches('script,style,noscript,textarea,input,select,button,option,summary,svg,use,rt,rp,[contenteditable],[role="textbox"],[role="searchbox"],[role="combobox"],[aria-placeholder],[data-placeholder],[class*="placeholder" i],[role="checkbox"],[role="radio"],[role="tab"],[onclick]')) {
35443return false;
35444}
35445return true;
35446}
35447const skipSelector = options.allowInteractiveText ? POINTER_TEXT_STRUCTURAL_SKIP_SELECTOR : POINTER_TEXT_SKIP_SELECTOR;
35448return !element.matches(skipSelector) || allowReaderRoot && element.matches(READER_ROOT_SELECTOR$1);
35449}
35450function stylePassesPointerTextLookup(style) {
35451return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && Number(style.opacity || "1") > 0;
35452}
35453function isScreenReaderOnlyElement(element, style) {
35454if (hasScreenReaderOnlyClass(element)) return true;
35455const rect = element.getBoundingClientRect();
35456return isTinyClippedElement(rect, style) || isTinyHiddenAbsoluteElement(rect, style);
35457}
35458function hasScreenReaderOnlyClass(element) {
35459return SCREEN_READER_ONLY_CLASS_RE.test(element.className || "");
35460}
35461function isTinyClippedElement(rect, style) {
35462const clipped = Boolean(style.clip && style.clip !== "auto" || style.clipPath && style.clipPath !== "none");
35463return clipped && isTinyRect(rect);
35464}
35465function isTinyHiddenAbsoluteElement(rect, style) {
35466return style.position === "absolute" && style.overflow === "hidden" && isTinyRect(rect);
35467}
35468function isTinyRect(rect) {
35469return rect.width <= 2 && rect.height <= 2;
35470}
35471function textCharacterContainsPoint(node, offset, x, y) {
35472if (!node.data.length) return false;
35473const start = Math.min(Math.max(offset, 0), node.data.length - 1);
35474const range = document.createRange();
35475try {
35476range.setStart(node, start);
35477range.setEnd(node, start + 1);
35478return Array.from(range.getClientRects()).some((rect) => rectContainsPoint(rect, x, y));
35479} finally {
35480range.detach?.();
35481}
35482}
35483function rectContainsPoint(rect, x, y) {
35484const right = rect.right || rect.left + rect.width;
35485const bottom = rect.bottom || rect.top + rect.height;
35486const slack = 1;
35487return hasPositiveRectArea(rect, right, bottom) && coordinateInRange(x, rect.left, right, slack) && coordinateInRange(y, rect.top, bottom, slack);
35488}
35489const HOVER_POPOVER_TRANSIT_RADIUS_PX = 12;
35490const HOVER_POPOVER_TRANSIT_SETTLE_DELAY_MS = 160;
35491function isActiveHoverPopoverPointerContext(state) {
35492const { mode, popover, point } = state;
35493if (mode !== "hover" || !popover || !point) return false;
35494const target = document.elementFromPoint(point.x, point.y);
35495return Boolean(target && (target === popover || popover.contains(target))) || isHoverPopoverTransitActive(state);
35496}
35497function isHoverPopoverTransitActive(state) {
35498const { mode, popover, point, origin } = state;
35499if (mode !== "hover" || !popover || !point || !origin) return false;
35500return pointInsideHoverPopoverTransit(point, origin, popover.getBoundingClientRect());
35501}
35502function pointInsideHoverPopoverTransit(point, origin, popoverRect) {
35503if (popoverRect.width <= 0 || popoverRect.height <= 0) return false;
35504const destination = {
35505x: Math.max(popoverRect.left, Math.min(origin.x, popoverRect.right)),
35506y: Math.max(popoverRect.top, Math.min(origin.y, popoverRect.bottom))
35507};
35508const segmentX = destination.x - origin.x;
35509const segmentY = destination.y - origin.y;
35510const segmentLengthSquared = segmentX * segmentX + segmentY * segmentY;
35511if (segmentLengthSquared <= 0) return false;
35512const projection = ((point.x - origin.x) * segmentX + (point.y - origin.y) * segmentY) / segmentLengthSquared;
35513if (projection <= 0 || projection >= 1) return false;
35514const nearestX = origin.x + segmentX * projection;
35515const nearestY = origin.y + segmentY * projection;
35516return Math.hypot(point.x - nearestX, point.y - nearestY) <= HOVER_POPOVER_TRANSIT_RADIUS_PX;
35517}
35518const HOVER_WATCH_PERIOD_MS = 90;
35519const domHoverCloseTimers = {
35520now: () => Date.now(),
35521setTimeout: (handler, delayMs) => window.setTimeout(handler, delayMs),
35522clearTimeout: (handle) => window.clearTimeout(handle)
35523};
35524class HoverCloseController {
35525constructor(timers, host) {
35526this.timers = timers;
35527this.host = host;
35528}
35529closeTimer;
35530deadline = 0;
35531watchTimer;
35532get pending() {
35533return this.closeTimer !== void 0;
35534}
35535/** Milliseconds until the armed close, or undefined when none is armed. */
35536get remainingMs() {
35537return this.pending ? Math.max(0, this.deadline - this.timers.now()) : void 0;
35538}
35539/**
35540* Ask for the popover to close in `delayMs`. Earliest deadline wins, so the repeat
35541* arming a moving pointer produces is a no-op rather than an extension.
35542*/
35543arm(delayMs, query = {}) {
35544const delay2 = Math.max(0, delayMs);
35545const deadline = this.timers.now() + delay2;
35546if (this.pending && deadline >= this.deadline) return;
35547this.timers.clearTimeout(this.closeTimer);
35548this.deadline = deadline;
35549this.closeTimer = this.timers.setTimeout(() => {
35550this.closeTimer = void 0;
35551if (!this.host.isHoverPopoverActive() || this.host.isHoverContextActive(query)) return;
35552this.host.close();
35553}, delay2);
35554}
35555cancel() {
35556this.timers.clearTimeout(this.closeTimer);
35557this.closeTimer = void 0;
35558this.deadline = 0;
35559}
35560/**
35561* Start watching for a departure no exit event reported — a re-render can detach
35562* the node the pointer was over before the browser dispatches its pointerleave, and
35563* a hover popover would then have nothing left to close it.
35564*
35565* The watchdog only ARMS the close, and keeps polling either way. It used to
35566* dismiss on the spot, which is how it ended up deciding close latency instead of
35567* merely detecting the departure. Arming is strictly less eager to close than
35568* dismissing was, because the deadline re-checks the hover context before acting.
35569*/
35570startWatch() {
35571this.stopWatch();
35572const tick = () => {
35573this.watchTimer = void 0;
35574if (!this.host.isHoverPopoverActive()) return;
35575if (!this.host.isHoverContextActive({ ignorePointerPosition: true })) {
35576this.arm(this.host.closeDelayMs(), { ignoreCssHover: true });
35577}
35578this.watchTimer = this.timers.setTimeout(tick, HOVER_WATCH_PERIOD_MS);
35579};
35580this.watchTimer = this.timers.setTimeout(tick, HOVER_WATCH_PERIOD_MS);
35581}
35582stopWatch() {
35583this.timers.clearTimeout(this.watchTimer);
35584this.watchTimer = void 0;
35585}
35586/** Both timers, for teardown paths that drop the popover entirely. */
35587stop() {
35588this.cancel();
35589this.stopWatch();
35590}
35591}
35592class DisabledReaderAudioActions {
35593playTermAudio() {
35594return Promise.resolve();
35595}
35596playSentenceAudio() {
35597return Promise.resolve();
35598}
35599playJpdbExampleAudio() {
35600return Promise.resolve();
35601}
35602playMediaUrl() {
35603return Promise.resolve(false);
35604}
35605}
35606const CompanionBackedReaderAudioActions = class {
35607constructor(dependencies) {
35608const Actions = yomuAudioCompanion()?.ReaderAudioActions;
35609return Actions ? new Actions(dependencies) : new DisabledReaderAudioActions();
35610}
35611};
35612let activationTrackingWindow;
35613let pageHasUserActivation = false;
35614function canAttemptAudiblePlayback(userGesture = false) {
35615installPageActivationTracking();
35616if (userGesture) {
35617pageHasUserActivation = true;
35618return true;
35619}
35620const browserActivation = browserUserActivationState();
35621if (browserActivation) pageHasUserActivation = true;
35622if (browserActivation !== void 0) return true;
35623if (pageHasUserActivation) return true;
35624if (isFirefoxLikeBrowser()) return true;
35625return true;
35626}
35627function installPageActivationTracking() {
35628if (typeof window === "undefined" || activationTrackingWindow === window) return;
35629activationTrackingWindow = window;
35630const markActive = () => {
35631pageHasUserActivation = true;
35632};
35633for (const eventName of ["click", "keydown", "pointerdown", "touchstart"]) {
35634window.addEventListener(eventName, markActive, { capture: true, passive: true });
35635}
35636}
35637function browserUserActivationState() {
35638const activation = typeof navigator === "undefined" ? void 0 : navigator.userActivation;
35639if (!activation) return void 0;
35640return activation.hasBeenActive || activation.isActive;
35641}
35642function isFirefoxLikeBrowser() {
35643return typeof navigator !== "undefined" && /firefox|iceweasel|fxios/i.test(navigator.userAgent ?? "");
35644}
35645installPageActivationTracking();
35646function canAttemptReaderAutoAudio(options) {
35647if (!options.settings.audioEnabled || !options.settings.autoPlayAudio) return false;
35648if (shouldSuppressAutoAudioForVideo(options)) return false;
35649if (!shouldAutoPlayForTrigger(options.settings, options.trigger)) return false;
35650return canAttemptAudiblePlayback(options.userGesture);
35651}
35652function shouldSuppressAutoAudioForVideo(options) {
35653if (options.userGesture) return false;
35654return options.settings.suppressAutoAudioOnVideo && hasVisiblePageVideo();
35655}
35656function shouldAutoPlayForTrigger(settings2, trigger) {
35657const mode = settings2.audioAutoPlayMode;
35658if (mode === "off") return false;
35659if (mode === "all") return true;
35660return mode === "hover" ? trigger === "hover" : trigger === "modal";
35661}
35662function registerReaderMenuCommands(handlers) {
35663const register = userscriptMenuCommandRegister$1();
35664if (!register) return;
35665register(`${APP_NAME} settings`, () => handlers.showSettings());
35666register(`${APP_NAME} open Study`, () => openReaderStudyPage(handlers.logInfo));
35667register(`${APP_NAME} open video player`, () => openReaderVideoPlayer(handlers.logInfo));
35668register(`${APP_NAME} annotations`, handlers.toggleAnnotations);
35669register(`${APP_NAME} audio`, handlers.toggleAudio);
35670register(`${APP_NAME} OCR`, handlers.cycleOcr);
35671register(`${APP_NAME} language`, handlers.toggleSiteLanguage);
35672register(`${APP_NAME} YouTube`, handlers.toggleYoutube);
35673register(`${APP_NAME} toggle puck`, () => {
35674const settings2 = handlers.getSettings();
35675settings2.showFloatingButton = !settings2.showFloatingButton;
35676void handlers.saveSettings(settings2, ["showFloatingButton"]).then(() => handlers.installFloatingButton());
35677});
35678register(`${APP_NAME} Factory Reset`, () => handlers.factoryReset());
35679}
35680function openReaderStudyPage(logInfo) {
35681openReaderRuntimePage(NEW_TAB_PAGE_URL, "Study page opened", logInfo);
35682}
35683function openReaderVideoPlayer(logInfo) {
35684openReaderRuntimePage(VIDEO_PLAYER_PAGE_URL, "Video player page opened", logInfo);
35685}
35686function openReaderRuntimePage(url, message, logInfo) {
35687const opened = window.open(url, "_blank");
35688if (opened) opened.opener = null;
35689if (!opened) location.href = url;
35690logInfo(message, { url });
35691}
35692function userscriptMenuCommandRegister$1() {
35693if (typeof GM_registerMenuCommand === "function") return GM_registerMenuCommand;
35694if (typeof GM !== "undefined" && typeof GM?.registerMenuCommand === "function") {
35695return (name, fn) => GM.registerMenuCommand?.(name, fn);
35696}
35697return null;
35698}
35699function bindReaderRuntimeEvents(handlers, signal) {
35700addWindowEventListener(OPEN_SETTINGS_EVENT, (event) => {
35701if (handlers.isDestroyed()) return;
35702if (!isHostedYomuOrigin()) return;
35703handlers.showSettings(openSettingsPanelDetail(event.detail));
35704}, { signal });
35705addWindowEventListener(INTERFACE_LANGUAGE_CHANGE_EVENT, (event) => {
35706if (handlers.isDestroyed()) return;
35707if (!isHostedYomuOrigin()) return;
35708const language = interfaceLanguageChangeDetail(event.detail);
35709if (language) void handlers.setInterfaceLanguage(language);
35710}, { signal });
35711subscribeToSettingsChanges((detail) => handleSettingsChange(handlers, detail), signal);
35712addWindowEventListener(SETTINGS_CHANGE_EVENT, (event) => {
35713if (handlers.isDestroyed() || !isHostedYomuOrigin()) return;
35714const detail = hostedPublicSettingsChangeDetail(event.detail);
35715if (detail) handleSettingsChange(handlers, detail);
35716}, { signal });
35717addWindowEventListener(USERSCRIPT_HTTP_BRIDGE_READY_EVENT, () => {
35718handlers.clearBridgeCaches();
35719}, { signal });
35720}
35721function hostedPublicSettingsChangeDetail(value) {
35722const records = hostedPublicSettingsChangeRecords(value);
35723if (!records) return null;
35724const theme = settingsThemeValue(records.settings.theme);
35725if (!theme) return null;
35726return {
35727settings: { theme },
35728preview: records.detail.preview === true,
35729remote: records.detail.remote === true
35730};
35731}
35732function hostedPublicSettingsChangeRecords(value) {
35733if (!isRecord$2(value)) return null;
35734if (!isRecord$2(value.settings)) return null;
35735return { detail: value, settings: value.settings };
35736}
35737function handleSettingsChange(handlers, detail) {
35738if (handlers.isDestroyed()) return;
35739const settings2 = handlers.getSettings();
35740adoptChosenLearningTarget(settings2);
35741applyChangedTheme(handlers, settings2, detail);
35742}
35743function adoptChosenLearningTarget(settings2) {
35744if (settings2.learningTargetChosen) adoptLearningTargetFromSettings(settings2);
35745}
35746function applyChangedTheme(handlers, settings2, detail) {
35747const theme = settingsThemeChangeDetail(detail);
35748if (!theme) return;
35749if (settings2.theme === theme) return;
35750settings2.theme = theme;
35751handlers.setSettings(settings2);
35752handlers.applyTheme();
35753persistChangedTheme(handlers, settings2, detail);
35754}
35755function persistChangedTheme(handlers, settings2, detail) {
35756if (detail.preview === true) return;
35757void handlers.saveSettings(settings2, ["theme"]);
35758}
35759function interfaceLanguageChangeDetail(detail) {
35760const value = detail?.language ?? detail?.interfaceLanguage;
35761return value === "auto" || value === "en" || value === "ja" ? value : null;
35762}
35763function openSettingsPanelDetail(detail) {
35764const value = detail?.panel ?? detail?.tab;
35765return typeof value === "string" && value.trim() ? value.trim() : void 0;
35766}
35767function settingsThemeChangeDetail(detail) {
35768return settingsThemeValue(detail.settings.theme);
35769}
35770function settingsThemeValue(value) {
35771return value === "auto" || value === "dark" || value === "light" ? value : null;
35772}
35773function runningAsBrowserExtension() {
35774const global = globalThis;
35775try {
35776return Boolean(global.chrome?.runtime?.id || global.browser?.runtime?.id);
35777} catch {
35778return false;
35779}
35780}
35781const initJpdbReviewPageBridge = () => yomuJpdbCompanion()?.initJpdbReviewPageBridge?.();
35782async function loadReaderStartupSettings(options = {}) {
35783const loadedSettings = adoptHostedInterfaceLanguage(options.startupSettings ?? await loadSettings());
35784const settings2 = applyUrlBootstrapSettings(loadedSettings);
35785const pageOwnedLearningTarget2 = activateReaderStartupTarget(settings2);
35786return {
35787settings: settings2,
35788settingsSummary: loggingSettingsSummary(settings2),
35789shouldShowWelcome: options.showWelcome ?? true,
35790pageOwnedLearningTarget: pageOwnedLearningTarget2
35791};
35792}
35793function activateReaderStartupTarget(settings2, href = location.href) {
35794if (settings2.learningTargetChosen) {
35795adoptLearningTargetFromSettings(settings2);
35796return null;
35797}
35798const pageOwnedLearningTarget2 = hostedPageOwnedLearningTarget(href);
35799if (pageOwnedLearningTarget2) adoptLearningTargetLanguage(pageOwnedLearningTarget2);
35800return pageOwnedLearningTarget2;
35801}
35802function hostedPageOwnedLearningTarget(href) {
35803return isYomuHostedPassivePage(href) || isYomuHostedAcademyPage(href) ? "ja" : null;
35804}
35805function adoptHostedInterfaceLanguage(settings2, href = location.href) {
35806if (!isYomuHostedAppUrl(href)) return settings2;
35807const language = hostedPageInterfaceLanguage();
35808if (!language || settings2.interfaceLanguage === language) return settings2;
35809return { ...settings2, interfaceLanguage: language };
35810}
35811function hostedPageInterfaceLanguage() {
35812try {
35813const raw = window.localStorage?.getItem(SETTINGS_STORAGE_KEY);
35814if (!raw) return null;
35815const record2 = JSON.parse(raw);
35816const value = record2?.interfaceLanguage;
35817return value === "auto" || value === "en" || value === "ja" ? value : null;
35818} catch {
35819return null;
35820}
35821}
35822function shouldShowReaderOnboarding(shouldShowWelcome, href = location.href) {
35823if (hostedFileReaderRequiresOnboarding(href)) return true;
35824if (!shouldShowWelcome) return false;
35825if (extensionNewTabCanHostOnboarding(href)) return true;
35826return !isYomuHostedAppUrl(href);
35827}
35828function hostedFileReaderRequiresOnboarding(href) {
35829return isYomuHostedPdfReaderPage(href) || isYomuHostedVideoPlayerPage(href);
35830}
35831function extensionNewTabCanHostOnboarding(href) {
35832return runningAsBrowserExtension() && isYomuNewTabUrl(href);
35833}
35834function installReaderStartupBridge() {
35835return initJpdbReviewPageBridge();
35836}
35837function detectReaderStartupJapaneseText() {
35838return documentHasJapaneseText(2e5, scanScopeRoots());
35839}
35840class ReaderSettingsUnavailableError extends Error {
35841name = "ReaderSettingsUnavailableError";
35842constructor(cause) {
35843super("Reader settings could not be read.", { cause });
35844}
35845}
35846function rejectAsReaderSettingsUnavailable(cause) {
35847throw new ReaderSettingsUnavailableError(cause);
35848}
35849function isReaderSettingsUnavailable(error) {
35850return error instanceof ReaderSettingsUnavailableError;
35851}
35852const ANKI_STATUS_WARMUP_DELAY_MS = 1e3;
35853const ANKI_STATUS_WARMUP_IDLE_TIMEOUT_MS = 5e3;
35854function scheduleReaderAnkiStatusWarmup(options) {
35855if (!shouldLookupAnkiStatus(options.getSettings())) return;
35856const run = () => {
35857if (options.isDestroyed() || !shouldLookupAnkiStatus(options.getSettings())) return;
35858void warmStatusIndex(options).then((index) => {
35859if (!index || options.isDestroyed() || !shouldLookupAnkiStatus(options.getSettings())) return;
35860recolorRenderedAnkiWordsFromCache(options);
35861});
35862};
35863window.setTimeout(() => {
35864if (options.isDestroyed() || !shouldLookupAnkiStatus(options.getSettings())) return;
35865const requestIdle = window.requestIdleCallback;
35866if (typeof requestIdle === "function") requestIdle(run, { timeout: ANKI_STATUS_WARMUP_IDLE_TIMEOUT_MS });
35867else run();
35868}, ANKI_STATUS_WARMUP_DELAY_MS);
35869}
35870function scheduleReaderAnkiStatusRefresh(settings2, refresh) {
35871if (!shouldLookupAnkiStatus(settings2)) return;
35872window.setTimeout(() => {
35873void refresh();
35874}, 0);
35875}
35876function warmStatusIndex(options) {
35877const promise = options.warmStatusIndex();
35878if (!options.onWarmupError) return promise;
35879return promise.catch((error) => {
35880options.onWarmupError?.(error);
35881return null;
35882});
35883}
35884function recolorRenderedAnkiWordsFromCache(options) {
35885if (!options.recolorRenderedAnkiWordsFromCache) return;
35886const promise = options.recolorRenderedAnkiWordsFromCache();
35887if (options.onRecolorError) void promise.catch(options.onRecolorError);
35888}
35889const HOVER_ANCHOR_SWITCH_COALESCE_MS = 50;
35890function retargetsPendingHoverOpen(input) {
35891return input.timerPending && !input.popoverOpen && input.minimumDelayMs === void 0;
35892}
35893function hoverLookupScheduleDelay(input) {
35894const normal = input.switchesAnchor ? HOVER_ANCHOR_SWITCH_COALESCE_MS : Math.max(0, input.hoverOpenDelayMs);
35895return Math.max(normal, input.minimumDelayMs ?? 0);
35896}
35897function cachedRenderedWordHydration(word, cache) {
35898const card = cache.get(renderedFallbackVocabularyCacheKey(word));
35899if (![Boolean(word.dataset.expression), Boolean(card)].every(Boolean)) return null;
35900return { card, pitchClass: publicVocabularyPitchClass(card) };
35901}
35902function publicVocabularyPitchClass(card) {
35903const reading = [card.reading, card.spelling].find(Boolean) ?? "";
35904return getPitchClass(card.pitchAccent, reading) || "unknown";
35905}
35906function firefoxAuthenticationInfoSettingsPageUrl() {
35907if (!isFirefoxExtensionRuntime()) return "";
35908const runtime2 = firefoxExtensionApi()?.runtime;
35909if (!runtime2?.id || typeof runtime2.getURL !== "function") return "";
35910try {
35911return `${runtime2.getURL("newtab/index.html")}#settings=api`;
35912} catch {
35913return "";
35914}
35915}
35916function firefoxExtensionApi() {
35917try {
35918return globalThis.browser;
35919} catch {
35920return void 0;
35921}
35922}
35923function isFirefoxExtensionRuntime() {
35924const firefox = firefoxExtensionApi();
35925if (!firefox?.runtime?.id) return false;
35926const getURL = firefox.runtime.getURL;
35927if (typeof getURL === "function") {
35928try {
35929return getURL.call(firefox.runtime, "").startsWith("moz-extension://");
35930} catch {
35931}
35932}
35933try {
35934return /\bFirefox\/\d/u.test(navigator.userAgent);
35935} catch {
35936return false;
35937}
35938}
35939const CANONICAL_SETTINGS_URL = `${NEW_TAB_PAGE_URL}#settings=api`;
35940const PACKAGED_STUDY_SETTINGS_LAUNCHER_PROTOCOL = "yomu-packaged-study-settings-launcher:v1";
35941function sensitiveSettingsSurfaceAccess(pageUrl, extensionSettingsUrl = "") {
35942return {
35943trusted: isTrustedSensitiveSettingsSurface(pageUrl),
35944launcherUrl: trustedSettingsLauncherUrl(extensionSettingsUrl)
35945};
35946}
35947function mountSettingsSurfaceLauncher(host, modal, language, panel, trigger) {
35948return mountSettingsLauncher(host, modal, language, panel, trigger);
35949}
35950function mountSettingsLauncher(host, modal, language, panel, trigger, allowTrustedCurrentSurface) {
35951const access = currentSensitiveSettingsSurfaceAccess(host);
35952if (access.trusted && false) ;
35953const launcherUrl = sensitiveSettingsLauncherForPanel(access.launcherUrl, panel);
35954const surface = createSensitiveSettingsLauncher(language);
35955const close = () => {
35956modal.release();
35957host.dismiss();
35958};
35959surface.querySelector("[data-trusted-settings-launcher]")?.addEventListener("click", async (event) => {
35960event.preventDefault();
35961event.stopPropagation();
35962if (!isDirectTrustedReaderInteraction(event)) return;
35963await launchTrustedSettingsSurface(host, language, launcherUrl, event.currentTarget);
35964});
35965surface.querySelector('[data-action="cancel"]')?.addEventListener("click", close);
35966surface.addEventListener("keydown", (event) => {
35967if (event.key !== "Escape" || event.isComposing) return;
35968event.preventDefault();
35969event.stopPropagation();
35970close();
35971});
35972host.mountDialog(host.createBackdrop(), surface);
35973modal.activate(surface, trigger);
35974return surface;
35975}
35976async function launchTrustedSettingsSurface(host, language, url, launcher) {
35977launcher.disabled = true;
35978launcher.setAttribute("aria-busy", "true");
35979try {
35980if (!await openTrustedSettingsSurface(url)) {
35981host.toast(uiText$1(language, "settingsCompanionUnavailable"));
35982}
35983} catch {
35984host.toast(uiText$1(language, "settingsCompanionUnavailable"));
35985} finally {
35986launcher.disabled = false;
35987launcher.removeAttribute("aria-busy");
35988}
35989}
35990function currentSensitiveSettingsSurfaceAccess(host) {
35991return host.sensitiveSettingsSurface?.() ?? sensitiveSettingsSurfaceAccess(location.href, firefoxAuthenticationInfoSettingsPageUrl());
35992}
35993function sensitiveSettingsLauncherForPanel(url, panel) {
35994const target = new URL(url);
35995target.hash = settingsPanelHash(panel);
35996return target.href;
35997}
35998function isTrustedSensitiveSettingsSurface(value) {
35999const appUrl = readTrustedYomuUrl(value);
36000if (!appUrl) return false;
36001if (!isYomuNewTabUrl(value)) return false;
36002return trustedSettingsOrigin(appUrl.originKind, appUrl.url.origin);
36003}
36004function trustedSettingsOrigin(originKind, origin) {
36005if (originKind === "docs-preview") return false;
36006return originKind === "loopback" ? isPrivilegedYomuLocalDevelopmentOrigin(origin) : true;
36007}
36008function trustedSettingsLauncherUrl(extensionSettingsUrl) {
36009return isTrustedExtensionSettingsUrl(extensionSettingsUrl) ? extensionSettingsUrl : CANONICAL_SETTINGS_URL;
36010}
36011function isTrustedExtensionSettingsUrl(value) {
36012const appUrl = readTrustedYomuUrl(value);
36013return appUrl?.originKind === "extension" && isYomuNewTabUrl(value);
36014}
36015async function openTrustedSettingsSurface(url) {
36016const protocol = new URL(url).protocol;
36017if (WEB_SETTINGS_PROTOCOLS.has(protocol)) {
36018return openUrlInNewTab(url);
36019}
36020return openOwnedFirefoxStudySettings(url);
36021}
36022const WEB_SETTINGS_PROTOCOLS = new Set(["http:", "https:"]);
36023async function openOwnedFirefoxStudySettings(url) {
36024const runtime2 = firefoxLauncherRuntime();
36025if (!runtime2) return false;
36026const panel = runtimeOwnedFirefoxStudySettingsPanel(runtime2, url);
36027if (!panel) return false;
36028return requestPackagedStudySettingsTab(runtime2, panel);
36029}
36030async function requestPackagedStudySettingsTab(runtime2, panel) {
36031try {
36032const pending = runtime2.sendMessage({
36033type: "yomu.openPackagedStudySettings",
36034protocol: PACKAGED_STUDY_SETTINGS_LAUNCHER_PROTOCOL,
36035panel
36036});
36037if (!isPromiseLike$1(pending)) return false;
36038const response = await pending;
36039return validCreatedTabResponse(response);
36040} catch {
36041return false;
36042}
36043}
36044function firefoxLauncherRuntime() {
36045const runtime2 = firefoxLauncherRuntimeCandidate();
36046return hasFirefoxLauncherInterface(runtime2) ? runtime2 : null;
36047}
36048function firefoxLauncherRuntimeCandidate() {
36049try {
36050return globalThis.browser?.runtime;
36051} catch {
36052return void 0;
36053}
36054}
36055function hasFirefoxLauncherInterface(value) {
36056const runtime2 = recordValue(value);
36057if (!runtime2) return false;
36058return typeof runtime2.id === "string" && [runtime2.getURL, runtime2.sendMessage].every(isFunction);
36059}
36060function runtimeOwnedFirefoxStudySettingsPanel(runtime2, value) {
36061const target = readUrl(value);
36062if (!target) return null;
36063const panel = settingsPanelFromHash(target.hash);
36064if (!panel) return null;
36065const expected = packagedFirefoxStudySettingsUrl(runtime2, panel);
36066if (!expected) return null;
36067return matchingSettingsPanel(target, expected, panel);
36068}
36069function packagedFirefoxStudySettingsUrl(runtime2, panel) {
36070const expected = readRuntimeStudyUrl(runtime2);
36071if (!expected || expected.protocol !== "moz-extension:") return null;
36072expected.hash = settingsPanelHash(panel);
36073return expected;
36074}
36075function readRuntimeStudyUrl(runtime2) {
36076try {
36077return new URL(runtime2.getURL("newtab/index.html"));
36078} catch {
36079return null;
36080}
36081}
36082function readUrl(value) {
36083try {
36084return new URL(value);
36085} catch {
36086return null;
36087}
36088}
36089function matchingSettingsPanel(target, expected, panel) {
36090return target.href === expected.href ? panel : null;
36091}
36092function isFunction(value) {
36093return typeof value === "function";
36094}
36095function isPromiseLike$1(value) {
36096return Boolean(value && typeof value.then === "function");
36097}
36098function validCreatedTabResponse(value) {
36099const response = recordValue(value);
36100if (!response || response.ok !== true) return false;
36101return isNonNegativeInteger(response.tabId);
36102}
36103function recordValue(value) {
36104return value && typeof value === "object" ? value : null;
36105}
36106function isNonNegativeInteger(value) {
36107return typeof value === "number" && Number.isInteger(value) && value >= 0;
36108}
36109function createSensitiveSettingsLauncher(language) {
36110const root = document.createElement("section");
36111root.className = "jpdb-reader-settings jpdb-reader-settings-launcher";
36112root.dataset.jpdbReaderRoot = "true";
36113root.dataset.sensitiveSettingsLauncher = "true";
36114root.setAttribute("role", "dialog");
36115root.setAttribute("aria-modal", "true");
36116root.setAttribute("aria-label", uiText$1(language, "settingsTitle"));
36117root.tabIndex = -1;
36118const head = document.createElement("div");
36119head.className = "jpdb-reader-settings-head";
36120const title = document.createElement("h2");
36121title.textContent = uiText$1(language, "accountSettingsTrustedSurfaceTitle");
36122head.append(title);
36123const content = document.createElement("div");
36124content.className = "jpdb-reader-settings-scroll";
36125const help = document.createElement("p");
36126help.className = "jpdb-reader-help";
36127help.textContent = uiText$1(language, "accountSettingsTrustedSurfaceHelp");
36128const launcher = document.createElement("button");
36129launcher.className = "jpdb-reader-btn";
36130launcher.dataset.trustedSettingsLauncher = "true";
36131launcher.type = "button";
36132launcher.textContent = uiText$1(language, "openAccountSettingsTrustedSurface");
36133content.append(help, launcher);
36134const footer = document.createElement("div");
36135footer.className = "footer";
36136const close = document.createElement("button");
36137close.className = "jpdb-reader-btn";
36138close.dataset.action = "cancel";
36139close.type = "button";
36140close.textContent = uiText$1(language, "cancel");
36141footer.append(close);
36142root.append(head, content, footer);
36143return root;
36144}
36145const installAcademyReaderSrsSync = () => {
36146yomuSettingsSurfaceCompanion()?.installAcademyReaderSrsSync?.();
36147};
36148const UNAVAILABLE = "The Yomu WaniKani companion did not load.";
36149function disabledWanikaniSrsAdapter() {
36150return {
36151id: "wanikani",
36152label: "WaniKani",
36153capabilities: { stats: false, queue: false, review: false, mine: false, import: false },
36154hasCredential: () => false,
36155verify: () => Promise.resolve(false),
36156stats: () => Promise.reject(new Error(UNAVAILABLE)),
36157queue: () => Promise.reject(new Error(UNAVAILABLE)),
36158review: () => Promise.reject(new Error(UNAVAILABLE)),
36159mine: () => Promise.reject(new Error(UNAVAILABLE))
36160};
36161}
36162const createWanikaniSrsAdapter = (client) => yomuWanikaniCompanion()?.createWanikaniSrsAdapter(client) ?? disabledWanikaniSrsAdapter();
36163class DisabledWanikaniClient {
36164hasCredential() {
36165return false;
36166}
36167}
36168const CompanionBackedWanikaniClient = class {
36169constructor(options) {
36170const Client = yomuWanikaniCompanion()?.WanikaniClient;
36171return Client ? new Client(options) : new DisabledWanikaniClient();
36172}
36173};
36174class DisabledWanikaniLookupClient {
36175lookupCard() {
36176return Promise.resolve(null);
36177}
36178lookupKanji() {
36179return Promise.resolve(null);
36180}
36181}
36182const CompanionBackedWanikaniLookupClient = class {
36183constructor(client) {
36184const LookupClient = yomuWanikaniCompanion()?.WanikaniLookupClient;
36185return LookupClient ? new LookupClient(client) : new DisabledWanikaniLookupClient();
36186}
36187};
36188const COLOR_SOURCE_CLASSES = ["status", "jpdb", "anki", "pitch", "off"];
36189const COLOR_CHANNELS = ["highlight", "underline", "text"];
36190const READER_THEME_COLORS = READER_THEME_COLOR_TOKENS;
36191function applyReaderTheme(settings2, root = document.documentElement) {
36192const theme = appliedReaderTheme(settings2);
36193if (!root) return theme;
36194toggleClassIfChanged(root, "jpdb-reader-theme-dark", settings2.theme === "dark");
36195toggleClassIfChanged(root, "jpdb-reader-theme-light", settings2.theme === "light");
36196syncHostedPageTheme(settings2, root);
36197applyReaderAccentColor(settings2.accentColor, root);
36198applyReaderWordColors(settings2, root);
36199applyReaderImageTextOverlaySettings(settings2, root);
36200applyReaderSubtitleSettings(settings2, root);
36201applyReaderFontSettings(settings2, root);
36202applyPopupFontSettings(settings2, root);
36203const hideGroups = theme.furiganaMode === "known-status" ? new Set(settings2.furiganaHiddenStateGroups) : new Set();
36204for (const group of FURIGANA_HIDE_STATE_GROUPS) {
36205toggleClassIfChanged(root, `yomu-furi-hide-${group}`, hideGroups.has(group));
36206}
36207toggleClassIfChanged(root, "jpdb-reader-hide-known", theme.furiganaMode === "known-status" && hideGroups.has("known"));
36208toggleClassIfChanged(root, "yomu-furi-hover", theme.furiganaMode === "hover");
36209toggleClassIfChanged(root, "yomu-word-color-new-only", settings2.wordColorStates === "
36209new-only");
36210const colorHideGroups = new Set(settings2.wordColorHiddenStateGroups);
36211for (const group of WORD_COLOR_HIDE_STATE_GROUPS) {
36212toggleClassIfChanged(root, `yomu-word-color-hide-${group}`, colorHideGroups.has(group));
36213}
36214toggleClassIfChanged(root, "jpdb-reader-suppress-redundant", Boolean(settings2.suppressRedundantWordUi));
36215toggleClassIfChanged(root, "jpdb-reader-sheet-close-left", Boolean(settings2.sheetCloseButtonOnLeft));
36216const legacyClasses = ["jpdb-reader-highlight-status", "jpdb-reader-highlight-pitch", "jpdb-reader-highlight-off"].filter((className) => root.classList.contains(className));
36217if (legacyClasses.length) root.classList.remove(...legacyClasses);
36218applyReaderColorSourceClasses(root, "word", theme.wordColorSources);
36219applyReaderColorSourceClasses(root, "subtitle", theme.subtitleColorSources);
36220if (root === document.documentElement) setReviewLanePainted(paintsReviewLane(theme));
36221guardReaderRootClasses(root);
36222return theme;
36223}
36224const HOSTED_PAGE_THEME_CLASSES = ["yomu-page-theme-dark", "yomu-page-theme-light"];
36225function syncHostedPageTheme(settings2, root) {
36226if (!HOSTED_PAGE_THEME_CLASSES.some((className) => root.classList.contains(className))) return;
36227const dark = settings2.theme === "dark" || settings2.theme === "auto" && prefersDarkMode();
36228toggleClassIfChanged(root, "yomu-page-theme-dark", dark);
36229toggleClassIfChanged(root, "yomu-page-theme-light", !dark);
36230}
36231function paintsReviewLane(theme) {
36232return [theme.wordColorSources, theme.subtitleColorSources].some((sources) => Object.values(sources).includes("anki"));
36233}
36234function toggleClassIfChanged(root, className, enabled) {
36235if (root.classList.contains(className) !== enabled) root.classList.toggle(className, enabled);
36236}
36237let guardedRootClasses = [];
36238let readerRootClassObserver = null;
36239function guardReaderRootClasses(root) {
36240if (typeof MutationObserver !== "function" || root !== document.documentElement) return;
36241guardedRootClasses = root.className.match(/\b(?:jpdb-reader-|yomu-)\S+/g) ?? [];
36242if (readerRootClassObserver) return;
36243readerRootClassObserver = new MutationObserver(() => {
36244const missing = guardedRootClasses.filter((className) => !root.classList.contains(className));
36245if (missing.length) root.classList.add(...missing);
36246});
36247readerRootClassObserver.observe(root, { attributes: true, attributeFilter: ["class"] });
36248}
36249function applyReaderFontSettings(settings2, root) {
36250root.style.setProperty("--jpdb-reader-font", settings2.readerFontFamily);
36251}
36252function applyPopupFontSettings(settings2, root) {
36253root.style.setProperty("--jpdb-reader-popup-font", settings2.popupFontFamily);
36254root.style.setProperty("--jpdb-reader-popup-font-weight", String(settings2.popupFontWeight));
36255}
36256function applyReaderImageTextOverlaySettings(settings2, root) {
36257const opacity = accessibleOcrBackgroundOpacity(settings2.ocrBackgroundOpacity);
36258const background = accessibleOcrBackgroundColor(settings2.accentColor, opacity);
36259root.style.setProperty("--jpdb-ocr-text-color", sanitizeAccentColor(settings2.ocrTextColor));
36260root.style.setProperty("--jpdb-ocr-outline-color", sanitizeAccentColor(settings2.ocrOutlineColor));
36261root.style.setProperty("--jpdb-ocr-background-rgba", accentToRgba(background, opacity));
36262root.style.setProperty("--jpdb-ocr-background-active-rgba", accentToRgba(background, Math.min(1, opacity + 0.12)));
36263}
36264function applyReaderSubtitleSettings(settings2, root) {
36265const background = sanitizeAccentColor(settings2.subtitleBackgroundColor);
36266root.style.setProperty("--subtitle-font-size-target", `${settings2.subtitleFontSize}px`);
36267root.style.setProperty("--subtitle-color", sanitizeAccentColor(settings2.subtitleTextColor));
36268root.style.setProperty("--subtitle-outline", sanitizeAccentColor(settings2.subtitleOutlineColor));
36269root.style.setProperty("--subtitle-background-rgba", accentToRgba(background, settings2.subtitleBackgroundOpacity));
36270root.style.setProperty("--subtitle-family", settings2.subtitleFontFamily);
36271root.style.setProperty("--subtitle-weight", String(settings2.subtitleFontWeight));
36272}
36273function applyResolvedReaderTheme(settings2, theme, root) {
36274const other = theme === "dark" ? "jpdb-reader-theme-light" : "jpdb-reader-theme-dark";
36275if (root.classList.contains(`jpdb-reader-theme-${theme}`) && !root.classList.contains(other)) return;
36276root.classList.add(`jpdb-reader-theme-${theme}`);
36277root.classList.remove(other);
36278applyReaderAccentColor(settings2.accentColor, root);
36279applyReaderWordColors(settings2, root);
36280}
36281function applyReaderAccentColor(color, root = document.documentElement) {
36282const accentColor = sanitizeAccentColor(color);
36283root.style.setProperty("--jpdb-reader-accent", accentColor, "important");
36284root.style.setProperty("--jpdb-reader-accent-soft", accentToRgba(accentColor, 0.18), "important");
36285root.style.setProperty("--jpdb-reader-accent-readable", readableAccentOnSurface(accentColor, root), "important");
36286root.style.setProperty("--jpdb-reader-accent-text", readableTextOnAccent(accentColor), "important");
36287}
36288function applyReaderWordColors(settings2, root = document.documentElement) {
36289Object.entries(readerStateColors(settings2)).forEach(([state, color]) => {
36290root.style.setProperty(`--jpdb-reader-state-${state}`, color);
36291root.style.setProperty(`--jpdb-reader-state-${state}-readable`, readableThemeColorOnSurface(color, root));
36292root.style.setProperty(`--jpdb-reader-state-${state}-soft`, accentToRgba(color, 0.16));
36293root.style.setProperty(`--jpdb-reader-state-${state}-strong`, accentToRgba(color, 0.28));
36294});
36295Object.entries(readerPitchColors(settings2)).forEach(([pattern, { color, alpha }]) => {
36296root.style.setProperty(`--jpdb-reader-pitch-${pattern}`, color);
36297root.style.setProperty(`--jpdb-reader-pitch-${pattern}-readable`, readableThemeColorOnSurface(color, root));
36298root.style.setProperty(`--jpdb-reader-pitch-${pattern}-soft`, alpha > 0 ? accentToRgba(color, alpha) : "transparent");
36299});
36300}
36301function appliedReaderTheme(settings2) {
36302const wordColorSources = normalizedAppliedColorSources(settings2, {
36303highlight: effectiveReaderColorSource(settings2, settings2.wordHighlightColorSource, "jpdb"),
36304underline: effectiveReaderColorSource(settings2, settings2.wordUnderlineColorSource, "pitch"),
36305text: effectiveReaderTextColorSource(settings2, settings2.wordTextColorSource, "anki")
36306}, "word");
36307const subtitleColorSources = normalizedAppliedColorSources(settings2, {
36308highlight: appliedSubtitleColorSource(settings2, effectiveSubtitleColorSource(settings2, settings2.subtitleHighlightColorSource, "jpdb")),
36309underline: appliedSubtitleColorSource(settings2, effectiveSubtitleColorSource(settings2, settings2.subtitleUnderlineColorSource, "pitch")),
36310text: appliedSubtitleColorSource(settings2, effectiveSubtitleTextColorSource(settings2, settings2.subtitleTextColorSource, "anki"))
36311}, "subtitle");
36312return {
36313furiganaMode: effectiveFuriganaMode(settings2),
36314wordColorSources,
36315subtitleColorSources
36316};
36317}
36318function appliedSubtitleColorSource(settings2, source) {
36319return source === "status" ? effectiveReaderColorSource(settings2, "status", "status") : source;
36320}
36321function normalizedAppliedColorSources(settings2, sources, scope) {
36322if (sources.highlight !== "pitch" || sources.underline !== "pitch") return sources;
36323return {
36324...sources,
36325highlight: defaultHighlightSource(settings2, scope)
36326};
36327}
36328function defaultHighlightSource(settings2, scope) {
36329if (scope === "word") return effectiveReaderColorSource(settings2, "jpdb", "jpdb");
36330return appliedSubtitleColorSource(settings2, effectiveSubtitleColorSource(settings2, "jpdb", "jpdb"));
36331}
36332function readerStateColors(settings2) {
36333return {
36334new: sanitizeAccentColor(settings2.wordColorNew),
36335learning: sanitizeAccentColor(settings2.wordColorLearning),
36336known: sanitizeAccentColor(settings2.wordColorKnown),
36337due: sanitizeAccentColor(settings2.wordColorDue),
36338failed: sanitizeAccentColor(settings2.wordColorFailed),
36339ignored: sanitizeAccentColor(settings2.wordColorIgnored)
36340};
36341}
36342function readerPitchColors(settings2) {
36343return {
36344heiban: { color: sanitizeAccentColor(settings2.pitchColorHeiban), alpha: 0.14 },
36345atamadaka: { color: sanitizeAccentColor(settings2.pitchColorAtamadaka), alpha: 0.14 },
36346nakadaka: { color: sanitizeAccentColor(settings2.pitchColorNakadaka), alpha: 0.16 },
36347odaka: { color: sanitizeAccentColor(settings2.pitchColorOdaka), alpha: 0.14 },
36348unknown: { color: sanitizeAccentColor(settings2.pitchColorUnknown), alpha: 0 }
36349};
36350}
36351function applyReaderColorSourceClasses(root, scope, sources) {
36352COLOR_CHANNELS.forEach((channel) => {
36353COLOR_SOURCE_CLASSES.forEach((source) => {
36354toggleClassIfChanged(root, colorSourceClassName(scope, channel, source), sources[channel] === source);
36355});
36356});
36357}
36358function readableAccentOnSurface(accentColor, root) {
36359return readableThemeColorOnSurface(accentColor, root);
36360}
36361function readableThemeColorOnSurface(color, root) {
36362const surface = readerSurfaceColor(root);
36363const safeColor = sanitizeAccentColor(color);
36364return readableOnAll(safeColor, [
36365surface,
36366readerBackgroundColor(root),
36367readerElevatedSurfaceColor(root),
36368mixHex(surface, safeColor, 0.18),
36369mixHex(surface, safeColor, 0.26)
36370], 4.5);
36371}
36372function readableTextOnAccent(accentColor) {
36373return contrastRatio(accentColor, READER_THEME_COLORS.dark.accentText) >= contrastRatio(accentColor, READER_THEME_COLORS.light.accentText) ? READER_THEME_COLORS.dark.accentText : READER_THEME_COLORS.light.accentText;
36374}
36375function readerSurfaceColor(root) {
36376const computed = typeof getComputedStyle === "function" ? getComputedStyle(root).getPropertyValue("--jpdb-reader-surface").trim() : "";
36377if (isHexColor(computed)) return sanitizeAccentColor(computed);
36378if (root.classList.contains("jpdb-reader-theme-dark")) return READER_THEME_COLORS.dark.surface;
36379if (root.classList.contains("jpdb-reader-theme-light")) return READER_THEME_COLORS.light.surface;
36380return prefersLightMode() ? READER_THEME_COLORS.light.surface : READER_THEME_COLORS.dark.surface;
36381}
36382function readerBackgroundColor(root) {
36383const computed = typeof getComputedStyle === "function" ? getComputedStyle(root).getPropertyValue("--jpdb-reader-bg").trim() : "";
36384if (isHexColor(computed)) return sanitizeAccentColor(computed);
36385if (root.classList.contains("jpdb-reader-theme-dark")) return READER_THEME_COLORS.dark.bg;
36386if (root.classList.contains("jpdb-reader-theme-light")) return READER_THEME_COLORS.light.bg;
36387return prefersLightMode() ? READER_THEME_COLORS.light.bg : READER_THEME_COLORS.dark.bg;
36388}
36389function readerElevatedSurfaceColor(root) {
36390const computed = typeof getComputedStyle === "function" ? getComputedStyle(root).getPropertyValue("--jpdb-reader-surface-2").trim() : "";
36391if (isHexColor(computed)) return sanitizeAccentColor(computed);
36392if (root.classList.contains("jpdb-reader-theme-dark")) return READER_THEME_COLORS.dark.surface2;
36393if (root.classList.contains("jpdb-reader-theme-light")) return READER_THEME_COLORS.light.surface2;
36394return prefersLightMode() ? READER_THEME_COLORS.light.surface2 : READER_THEME_COLORS.dark.surface2;
36395}
36396function prefersDarkMode() {
36397return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches;
36398}
36399function prefersLightMode() {
36400return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: light)").matches;
36401}
36402function parseContentCacheKey(texts, options, settings2) {
36403return JSON.stringify({
36404texts,
36405options,
36406settings: {
36407apiKey: hasJpdbApiCredential(settings2),
36408jitenApiKey: hasJitenApiCredential(settings2),
36409localDictionariesEnabled: settings2.localDictionariesEnabled,
36410dictionaries: settings2.dictionaryPreferences.map((preference) => ({
36411name: preference.name,
36412enabled: preference.enabled,
36413priority: preference.priority
36414}))
36415}
36416});
36417}
36418const KANJI_STATIC_SOURCE_MOUNTS = {
36419[KANJI_JPDB_SOURCE_ID]: "<div data-kanji-jpdb-mount></div>",
36420[KANJI_RTK_SOURCE_ID]: "<div data-kanji-rtk-mount></div>",
36421[KANJI_ORIGINS_SOURCE_ID]: "<div data-kanji-origin-mount></div>",
36422[KANJI_WANIKANI_SOURCE_ID]: "<div data-kanji-wanikani-mount></div>",
36423[KANJI_DICTIONARIES_SOURCE_ID]: "<div data-kanji-definitions-mount></div>"
36424};
36425function renderKanjiSourceMounts(options) {
36426const mounts = [];
36427for (const sourceId of orderedKanjiSourceIds(options.settings)) {
36428const mount = renderKanjiSourceMount(sourceId, options);
36429if (mount) mounts.push(mount);
36430}
36431return mounts.join("");
36432}
36433function renderKanjiImmersionKitMount(settings2, sourceAttributes) {
36434if (!settings2.immersionKitEnabled || !settings2.kanjiImmersionKitEnabled) return "";
36435const sourceStateKey = kanjiSourceStateKey(IMMERSION_KIT_SOURCE_ID);
36436return `
36437        <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-immersion" data-immersion-kit ${sourceAttributes(sourceStateKey, false)}>
36438            <summary class="jpdb-reader-local-title" data-jpdb-reader-surface-ignore>${uiText$1(settings2.interfaceLanguage, "immersionKit")}</summary>
36439            <div class="jpdb-reader-help">${uiText$1(settings2.interfaceLanguage, "loadingExamples")}</div>
36440        </details>
36441    `;
36442}
36443function renderKanjiSourceMount(sourceId, options) {
36444const staticMount = (options.staticMounts ?? KANJI_STATIC_SOURCE_MOUNTS)[sourceId];
36445if (staticMount) return staticMount;
36446const sourceStateKey = kanjiSourceStateKey(sourceId);
36447if (sourceId === KANJI_STROKE_SOURCE_ID) {
36448return renderKanjiPracticeShell(options, sourceStateKey);
36449}
36450if (sourceId === IMMERSION_KIT_SOURCE_ID) return options.renderImmersionMount?.() ?? "";
36451const dictionaryName = kanjiDictionaryNameFromSourceId(sourceId);
36452return dictionaryName ? `<div data-kanji-definitions-mount data-kanji-dictionary="${escapeHtml(dictionaryName)}" data-kanji-source-id="${escapeHtml(sourceId)}"></div>` : "";
36453}
36454function renderKanjiPracticeShell(options, sourceStateKey) {
36455const title = options.sourceTitle(KANJI_STROKE_SOURCE_ID);
36456const sourceAttributes = options.sourceAttributes(sourceStateKey, options.isSourceOpen(sourceStateKey));
36457return `
36458        <details class="jpdb-reader-local jpdb-reader-source-card jpdb-reader-kanjivg" ${sourceAttributes}>
36459            <summary class="jpdb-reader-local-title" data-jpdb-reader-surface-ignore>${escapeHtml(title)}</summary>
36460            <div class="jpdb-reader-doodle-stage trace-hidden" data-kanji="${escapeHtml(options.kanji)}">
36461                <div class="jpdb-reader-doodle-ghost" aria-hidden="true" hidden><div class="jpdb-reader-doodle-text-ghost">${escapeHtml(options.kanji)}</div></div>
36462                <canvas class="jpdb-reader-doodle-canvas" aria-label="${escapeHtml(`${uiText$1(options.language, "practiceDrawing")} ${options.kanji}`)}"></canvas>
36463            </div>
36464            <div class="jpdb-reader-doodle-tools">
36465                <span class="jpdb-reader-help">${escapeHtml(uiText$1(options.language, "textTrace"))}</span>
36466                <button class="jpdb-reader-btn jpdb-reader-doodle-control" type="button" data-doodle-trace>${escapeHtml(uiText$1(options.language, "showTrace"))}</button>
36467                <button class="jpdb-reader-btn jpdb-reader-doodle-control" type="button" data-doodle-clear>${escapeHtml(uiText$1(options.language, "clear"))}</button>
36468            </div>
36469            <div class="jpdb-reader-newtab-doodle-result" data-newtab-doodle-result></div>
36470        </details>
36471    `;
36472}
36473const ARMOURED_PROPERTIES = new Set([
36474"border-radius",
36475"border-top-left-radius",
36476"border-top-right-radius",
36477"border-bottom-left-radius",
36478"border-bottom-right-radius",
36479"background",
36480"background-color",
36481"background-image",
36482"background-clip",
36483"background-origin",
36484"background-size",
36485"background-repeat",
36486"background-position",
36487"background-attachment",
36488"border",
36489"border-top",
36490"border-right",
36491"border-bottom",
36492"border-left",
36493"border-width",
36494"border-top-width",
36495"border-right-width",
36496"border-bottom-width",
36497"border-left-width",
36498"border-style",
36499"border-top-style",
36500"border-right-style",
36501"border-bottom-style",
36502"border-left-style",
36503"border-color",
36504"border-top-color",
36505"border-right-color",
36506"border-bottom-color",
36507"border-left-color",
36508"border-image",
36509"border-image-source",
36510"border-image-slice",
36511"border-image-width",
36512"border-image-outset",
36513"border-image-repeat",
36514"box-shadow",
36515"color"
36516]);
36517const HOST_DERIVED_COLOUR_SELECTOR = /jpdb-reader-(?:canvas-text-layer|control-text-mirror|text-mirror|word|furi|ruby|detach|projected)|yomu-projected-reading|jpdb-ocr/i;
36518const ARMOUR_STRONG_LAYER = "jpdb-reader-armour-strong";
36519const ARMOUR_LAYER = "jpdb-reader-armour";
36520const CONDITIONAL_AT_RULE = /^@(?:media|supports|container|scope|document)\b/i;
36521const YOMU_SELECTOR = /jpdb|yomu/i;
36522const OWNED_SUBJECT_ATTRIBUTE = /^(?:data-jpdb|data-yomu|aria-|role$|type$|disabled$|open$|hidden$|checked$|selected$|href$|lang$|dir$|value$|placeholder$|contenteditable$|multiple$|readonly$|required$)/i;
36523const FOREIGN_CONTENT_CONTAINER = /jpdb-reader-local-glossary/i;
36524const ARMOURABLE_BLOCK = /border|background|box-shadow|color/i;
36525const IMPORTANT_SUFFIX = /!\s*important\s*$/i;
36526const MAX_INPUT_LENGTH = 4e6;
36527let lastArmourInput = "";
36528let lastArmourOutput = "";
36529function hostCssArmour(css) {
36530if (typeof css !== "string" || css.length === 0 || css.length > MAX_INPUT_LENGTH) return "";
36531if (css === lastArmourInput) return lastArmourOutput;
36532const strong = [];
36533const normal = [];
36534try {
36535collectArmourRules(css, strong, normal);
36536} catch {
36537return "";
36538}
36539const armour = strong.length === 0 && normal.length === 0 ? "" : [
36540`@layer ${ARMOUR_STRONG_LAYER},${ARMOUR_LAYER};`,
36541strong.length > 0 ? `@layer ${ARMOUR_STRONG_LAYER}{${strong.join("")}}` : "",
36542normal.length > 0 ? `@layer ${ARMOUR_LAYER}{${normal.join("")}}` : ""
36543].join("");
36544lastArmourInput = css;
36545lastArmourOutput = armour;
36546return armour;
36547}
36548function withHostCssArmour(css) {
36549const armour = hostCssArmour(css);
36550return armour ? `${css}
36551${armour}` : css;
36552}
36553const PRELUDE_STOP = /[{};"'/]/gu;
36554function collectArmourRules(css, strong, normal) {
36555const conditions = [];
36556let preludeStart = 0;
36557let index = 0;
36558while (index < css.length) {
36559PRELUDE_STOP.lastIndex = index;
36560const stop = PRELUDE_STOP.exec(css);
36561if (!stop) break;
36562index = stop.index;
36563const char = css[index];
36564if (char === "/") {
36565if (css[index + 1] === "*") index = skipComment(css, index);
36566else index++;
36567continue;
36568}
36569if (char === '"' || char === "'") {
36570index = skipString(css, index);
36571continue;
36572}
36573if (char === ";") {
36574index++;
36575preludeStart = index;
36576continue;
36577}
36578if (char === "}") {
36579conditions.pop();
36580index++;
36581preludeStart = index;
36582continue;
36583}
36584const selector = collapseWhitespace(css.slice(preludeStart, index));
36585index++;
36586preludeStart = index;
36587if (selector.startsWith("@")) {
36588if (CONDITIONAL_AT_RULE.test(selector)) {
36589conditions.push(selector);
36590continue;
36591}
36592index = findBlockEnd(css, index) + 1;
36593preludeStart = index;
36594continue;
36595}
36596const blockEnd = findBlockEnd(css, index);
36597const block = css.slice(index, blockEnd);
36598index = blockEnd + 1;
36599preludeStart = index;
36600if (!ARMOURABLE_BLOCK.test(block)) continue;
36601const armourable = armourableSelector(selector);
36602if (!armourable) continue;
36603appendArmourRule(armourable, block, conditions, strong, normal);
36604}
36605}
36606function armourableSelector(selectorList) {
36607if (!YOMU_SELECTOR.test(selectorList) || FOREIGN_CONTENT_CONTAINER.test(selectorList)) return "";
36608const kept = splitTopLevel(selectorList, ",").map((selector) => selector.trim()).filter((selector) => selector.length > 0 && YOMU_SELECTOR.test(selector) && ownsSubject(selector));
36609return kept.join(",");
36610}
36611function ownsSubject(selector) {
36612const compounds = splitTopLevel(selector, " >+~").filter((part) => part.trim().length > 0);
36613const subject = compounds[compounds.length - 1]?.trim() ?? "";
36614if (!subject || subject.startsWith("*")) return false;
36615if (YOMU_SELECTOR.test(subject)) return true;
36616const bare = subject.replace(/::?[a-zA-Z-]+(\([^()]*(?:\([^()]*\)[^()]*)*\))?/gu, "").trim();
36617if (!bare || bare.startsWith("*") || /[.#]/u.test(bare)) return false;
36618for (const attribute of bare.matchAll(/\[\s*([-\w]+)/gu)) {
36619if (!OWNED_SUBJECT_ATTRIBUTE.test(attribute[1])) return false;
36620}
36621return /^[a-zA-Z][\w-]*(?:\[[^\]]*\])*$/u.test(bare);
36622}
36623function splitTopLevel(value, separators) {
36624const parts = [];
36625let current = "";
36626let parens = 0;
36627let brackets = 0;
36628let index = 0;
36629while (index < value.length) {
36630const char = value[index];
36631if (char === '"' || char === "'") {
36632const end = skipString(value, index);
36633current += value.slice(index, end);
36634index = end;
36635continue;
36636}
36637if (char === "(") parens++;
36638else if (char === ")") parens = Math.max(0, parens - 1);
36639else if (char === "[") brackets++;
36640else if (char === "]") brackets = Math.max(0, brackets - 1);
36641if (parens === 0 && brackets === 0 && separators.includes(char)) {
36642parts.push(current);
36643current = "";
36644index++;
36645continue;
36646}
36647current += char;
36648index++;
36649}
36650parts.push(current);
36651return parts;
36652}
36653function appendArmourRule(selector, block, conditions, strong, normal) {
36654const strongDeclarations = [];
36655const normalDeclarations = [];
36656for (const declaration of splitDeclarations(block)) {
36657const colon = declaration.indexOf(":");
36658if (colon <= 0) continue;
36659const property = declaration.slice(0, colon).trim().toLowerCase();
36660if (property.startsWith("--") || !ARMOURED_PROPERTIES.has(property)) continue;
36661if (property === "color" && HOST_DERIVED_COLOUR_SELECTOR.test(selector)) continue;
36662const rawValue = declaration.slice(colon + 1).trim();
36663const important = IMPORTANT_SUFFIX.test(rawValue);
36664const value = important ? rawValue.replace(IMPORTANT_SUFFIX, "").trim() : rawValue;
36665if (!value) continue;
36666(important ? strongDeclarations : normalDeclarations).push(`${property}:${value}!important`);
36667}
36668if (strongDeclarations.length > 0) strong.push(wrapRule(selector, strongDeclarations, conditions));
36669if (normalDeclarations.length > 0) normal.push(wrapRule(selector, normalDeclarations, conditions));
36670}
36671function wrapRule(selector, declarations, conditions) {
36672const rule = `${selector}{${declarations.join(";")}}`;
36673if (conditions.length === 0) return rule;
36674return `${conditions.map((condition) => `${condition}{`).join("")}${rule}${"}".repeat(conditions.length)}`;
36675}
36676function splitDeclarations(block) {
36677const declarations = [];
36678let current = "";
36679let depth = 0;
36680let index = 0;
36681while (index < block.length) {
36682const char = block[index];
36683if (char === "/" && block[index + 1] === "*") {
36684index = skipComment(block, index);
36685continue;
36686}
36687if (char === '"' || char === "'") {
36688const end = skipString(block, index);
36689current += block.slice(index, end);
36690index = end;
36691continue;
36692}
36693if (char === "(") depth++;
36694else if (char === ")") depth = Math.max(0, depth - 1);
36695if (char === ";" && depth === 0) {
36696declarations.push(current);
36697current = "";
36698index++;
36699continue;
36700}
36701current += char;
36702index++;
36703}
36704declarations.push(current);
36705return declarations;
36706}
36707function findBlockEnd(css, start) {
36708let depth = 1;
36709let index = start;
36710while (index < css.length) {
36711const char = css[index];
36712if (char === "/" && css[index + 1] === "*") {
36713index = skipComment(css, index);
36714continue;
36715}
36716if (char === '"' || char === "'") {
36717index = skipString(css, index);
36718continue;
36719}
36720if (char === "{") depth++;
36721else if (char === "}") {
36722depth--;
36723if (depth === 0) return index;
36724}
36725index++;
36726}
36727return css.length;
36728}
36729function skipComment(css, start) {
36730const end = css.indexOf("*/", start + 2);
36731return end === -1 ? css.length : end + 2;
36732}
36733function skipString(css, start) {
36734const quote = css[start];
36735let index = start + 1;
36736while (index < css.length) {
36737const char = css[index];
36738if (char === "\\") {
36739index += 2;
36740continue;
36741}
36742if (char === quote) return index + 1;
36743if (char === "\n") return index;
36744index++;
36745}
36746return css.length;
36747}
36748function collapseWhitespace(value) {
36749return value.replace(/\/\*[\s\S]*?\*\//gu, " ").replace(/\s+/gu, " ").trim();
36750}
36751const READER_CSS_RESOURCE = "yomuCss";
36752const READER_CSS_HOSTED_FALLBACK_URL = `https://yomureader.com/yomu.css?v=${"2.0.7"}`;
36753const READER_CSS_RAW_FALLBACK_URL = `https://raw.githubusercontent.com/HRussellZFAC023/yomu-reader/main/dist/yomu.css?v=${"2.0.7"}`;
36754const READER_CSS_CACHE_KEY = "yomu:reader-css-cache:v3";
36755const READER_CSS = resourceReaderCss();
36756function criticalWordCss() {
36757const pitchClasses = ["heiban", "atamadaka", "nakadaka", "odaka"];
36758const pitchSelector = (pattern) => `.jpdb-pitch-${pattern},[data-pitch-class=${pattern}]`;
36759const pitches = pitchClasses.map((pattern) => `.jpdb-reader-word:is(${pitchSelector(pattern)}){--pc:var(--jpdb-reader-pitch-${pattern});--pr:var(--jpdb-reader-pitch-${pattern}-readable)}`).join("");
36760const unknownPitch = ".jpdb-reader-word:is(.jpdb-pitch-unknown,[data-pitch-class=unknown],.jpdb-pitch-particle,[data-pitch-class=particle]){--pc:var(--jpdb-reader-pitch-unknown);--pr:var(--jpdb-reader-pitch-unknown-readable);--c2:var(--pr,var(--pc,currentColor));--d2:#0000}";
36761const allPitches = pitchClasses.map(pitchSelector).join(",");
36762return [
36763pitches,
36764unknownPitch,
36765`.jpdb-reader-word:is(${allPitches}){--c2:var(--pr,var(--pc,currentColor));--d2:var(--pc,#0000)}`,
36766".jpdb-reader-word-underline-pitch .jpdb-reader-word{--yu:var(--d2,#0000)}.jpdb-reader-word-text-pitch .jpdb-reader-word{--yt:var(--c2,currentColor);color:var(--yt,currentColor)!important;-webkit-text-fill-color:var(--yt,currentColor)}"
36767].join("");
36768}
36769const CRITICAL_READER_CSS = `
36770[data-jpdb-reader-root],[data-jpdb-reader-root] *,[data-jpdb-reader-root]::before,[data-jpdb-reader-root]::after,[data-jpdb-reader-root] *::before,[data-jpdb-reader-root] *::after{box-sizing:border-box}
36771.jpdb-reader-popover,.jpdb-reader-settings,.jpdb-reader-backdrop,.jpdb-reader-popover-body,.jpdb-reader-word-pills,.jpdb-reader-popover :is(a[href],button,input,select,textarea,summary,[role=button],[data-action],.jpdb-reader-word,.jpdb-reader-action-pill),.jpdb-reader-settings :is(a[href],button,input,select,textarea,summary,[role=button],[data-action]){pointer-events:auto!important}
36772[data-jpdb-reader-root]:where(button),[data-jpdb-reader-root] :where(button){all:unset;box-sizing:border-box;color:inherit;cursor:pointer;font:inherit}
36773:is(.jpdb-reader-popover,.jpdb-reader-settings) .jpdb-reader-icon-btn{position:relative;display:inline-grid;place-items:center;box-sizing:border-box;width:36px!important;min-width:36px!important;max-width:36px!important;height:36px!important;min-height:36px!important;max-height:36px!important;flex:0 0 auto;border:1px solid var(--jpdb-reader-border,rgba(37,52,73,.18));border-radius:50%;background:var(--jpdb-reader-surface,#f4f7fa);color:var(--jpdb-reader-text,#17202a);overflow:hidden;-webkit-tap-highlight-color:#0000}
36774:is(.jpdb-reader-popover,.jpdb-reader-settings) .jpdb-reader-icon-btn svg{display:block;width:20px!important;height:20px!important;max-width:20px!important;max-height:20px!important;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
36775.jpdb-reader-actions .jpdb-reader-mining-collapse{position:relative;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:72px;height:30px;min-width:72px;min-height:30px;flex:none;border:0;border-radius:999px;background:#0000;color:var(--jpdb-reader-muted,#4f5968);cursor:pointer;pointer-events:auto;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:#0000}
36776.jpdb-reader-actions .jpdb-reader-mining-collapse::before{content:"";position:relative;z-index:1;display:block;width:42px;height:5px;border-radius:999px;background:var(--jpdb-reader-faint,#687384)}
36777[data-jpdb-reader-root] :where(section,article,aside,main,header,footer,nav,pre,p,ul,ol,li,figure,blockquote,form,table){margin:revert;padding:revert}
36778a[href] .jpdb-reader-word{-webkit-touch-callout:none}.jpdb-reader-word{--yi:.08em;--yz:calc(100% - var(--yi) - var(--yi));--yo:.12em;--ys:solid;--yw:1px;--yb:var(--jpdb-reader-highlight-backdrop);position:relative;text-decoration:underline var(--ys) #0000 var(--yw)!important;text-underline-offset:var(--yo)!important}.jpdb-reader-word.jpdb-reader-passive-word{--yt:currentColor}:is(button,[role=button],[role=tab],summary,label,.jpdb-reader-control-text-mirror,[data-jpdb-reader-passive-chrome=true]) .jpdb-reader-word.jpdb-reader-passive-word{--yh:#0000}
36779.jpdb-reader-word::after{content:"";position:absolute;z-index:1;inset-inline:var(--yi);inset-block-end:0;border-block-end:var(--yw) var(--ys) var(--yu,#0000);pointer-events:none}
36780${criticalWordCss()}
36781.jpdb-reader-word-underline-pitch .jpdb-reader-text-mirror .jpdb-reader-word{text-decoration-color:var(--yu,#0000)!important}
36782.jpdb-reader-word-underline-pitch .jpdb-reader-text-mirror .jpdb-reader-word::after{content:none!important}
36783${criticalRubyCss()}
36784`.trim();
36785function criticalRubyCss() {
36786return [
36787".jpdb-reader-word.jpdb-reader-has-furi{line-height:2.15}",
36788".jpdb-reader-word ruby{position:static!important;display:ruby!important;ruby-align:center!important;ruby-position:over!important;line-height:1;vertical-align:baseline!important}",
36789".jpdb-reader-ruby-base{display:inline}",
36790".jpdb-reader-word rp{display:none}",
36791".jpdb-reader-word rt{position:static;display:ruby-text;ruby-align:center;line-height:1;text-align:center;white-space:nowrap;pointer-events:inherit;text-decoration:none!important}",
36792".jpdb-reader-word rt.jpdb-reader-furi{display:ruby-text!important;white-space:nowrap!important;overflow-wrap:normal!important;word-break:keep-all!important}",
36793".jpdb-reader-furi{font-size:.58em;font-weight:700;line-height:1.08;color:inherit!important;-webkit-text-fill-color:currentColor!important;user-select:none;-webkit-user-select:none}"
36794].join("\n");
36795}
36796function initialReaderCss(css = READER_CSS) {
36797return withHostCssArmour(readerCssNeedsFallback(css) ? CRITICAL_READER_CSS : css);
36798}
36799async function loadReaderCssFallback(fetcher = globalThis.fetch, href = safeLocationHref()) {
36800const css = await resolveReaderCssFallback(fetcher, href);
36801if (!css) {
36802reportReaderCssUnavailable(href);
36803return css;
36804}
36805return withHostCssArmour(css);
36806}
36807function reportReaderCssUnavailable(href) {
36808try {
36809console.error(
36810"[Yomu] Reader CSS unavailable: the userscript @resource, the last-good cache and every fallback URL failed. The reader is running on the critical CSS subset (no furigana, no pitch underlines, unstyled settings).",
36811{ fallbackUrls: readerCssFallbackUrls(href) }
36812);
36813} catch {
36814}
36815}
36816async function resolveReaderCssFallback(fetcher, href) {
36817const cached = await cachedReaderCss();
36818if (cached) {
36819if (typeof fetcher === "function") void refreshReaderCssFallback(fetcher, href).catch(() => {
36820});
36821return cached;
36822}
36823if (typeof fetcher !== "function") return "";
36824return await refreshReaderCssFallback(fetcher, href);
36825}
36826async function refreshReaderCssFallback(fetcher, href) {
36827for (const url of readerCssFallbackUrls(href)) {
36828try {
36829const css = await fetchReaderCssFallbackUrl(url, fetcher);
36830if (isFullReaderCss(css)) {
36831await gmStorageSet(READER_CSS_CACHE_KEY, css);
36832return css;
36833}
36834} catch {
36835}
36836}
36837return "";
36838}
36839async function fetchReaderCssFallbackUrl(url, fetcher) {
36840const userscriptRequest = getUserscriptHttpRequest();
36841if (userscriptRequest) {
36842try {
36843return await requestReaderCssViaUserscript(url, userscriptRequest);
36844} catch {
36845}
36846}
36847const response = await fetcher(url, { credentials: "omit", cache: "force-cache" });
36848if (!response.ok) return "";
36849return await response.text();
36850}
36851function requestReaderCssViaUserscript(url, request) {
36852return requestViaUserscriptManager(request, {
36853details: {
36854method: "GET",
36855url,
36856responseType: "text",
36857timeout: 6e3,
36858anonymous: true
36859},
36860readResponse: (response) => {
36861if (response.status < 200 || response.status >= 300) throw new Error(`Reader CSS request failed (${response.status}).`);
36862return String(response.responseText ?? response.response ?? "");
36863},
36864onError: (error) => error instanceof Error ? error : new Error("Reader CSS request failed."),
36865onTimeout: () => new Error("Reader CSS request timed out.")
36866});
36867}
36868function readerCssNeedsFallback(css = READER_CSS) {
36869return !isFullReaderCss(css);
36870}
36871function shouldLoadReaderCssFallback(hasLinkedReaderCss, css = READER_CSS) {
36872return !hasLinkedReaderCss && readerCssNeedsFallback(css);
36873}
36874function readerCssFallbackUrls(href = safeLocationHref()) {
36875const urls = [hostedReaderCssUrl(href), READER_CSS_HOSTED_FALLBACK_URL, READER_CSS_RAW_FALLBACK_URL];
36876return [...new Set(urls.filter((url) => Boolean(url)))];
36877}
36878async function cachedReaderCss() {
36879const css = await gmStorageGet(READER_CSS_CACHE_KEY, "");
36880return typeof css === "string" && isFullReaderCss(css) ? css : "";
36881}
36882function resourceReaderCss() {
36883try {
36884if (typeof GM_getResourceText !== "function") return "";
36885const resource = GM_getResourceText(READER_CSS_RESOURCE);
36886return typeof resource === "string" ? resource : "";
36887} catch {
36888return "";
36889}
36890}
36891function hostedReaderCssUrl(href) {
36892try {
36893const url = new URL(href);
36894if (!isHostedYomuPage(url)) return null;
36895const path = url.hostname === "hrussellzfac023.github.io" ? "/yomu-reader/yomu.css" : "/yomu.css";
36896return `${new URL(path, url.origin).href}?v=${"2.0.7"}`;
36897} catch {
36898return null;
36899}
36900}
36901function isHostedYomuPage(url) {
36902return url.hostname === "yomureader.com" || url.pathname.startsWith("/yomu-reader/") && (url.hostname === "hrussellzfac023.github.io" || url.hostname === "127.0.0.1" || url.hostname === "localhost");
36903}
36904function isFullReaderCss(css) {
36905if (typeof css !== "string") return false;
36906return css.includes(".jpdb-reader-popover") && css.includes(".jpdb-reader-settings") && css.includes(".jpdb-reader-source-card") && css.includes(".jpdb-subtitle-player") && css.includes(".jpdb-ocr-layer");
36907}
36908function safeLocationHref() {
36909try {
36910return globalThis.location?.href ?? "";
36911} catch {
36912return "";
36913}
36914}
36915class DisabledStudySourceController {
36916renderTranslationSource() {
36917return "";
36918}
36919renderGrammarSource() {
36920return "";
36921}
36922installLoaders() {
36923}
36924detectGrammarHints() {
36925return Promise.resolve([]);
36926}
36927}
36928const CompanionBackedStudySourceController = class {
36929constructor(dependencies) {
36930const Controller = yomuKanjiStudyCompanion()?.StudySourceController;
36931return Controller ? new Controller(dependencies) : new DisabledStudySourceController();
36932}
36933};
36934const log$4 = Logger.scope("LateCardReconciliation");
36935const RESOLVED_WORD_EFFECTS_BATCH_DELAY_MS = 0;
36936function currentAnkiLookupBatch(tokens, lookupKeys, lookups) {
36937const currentTokens = [];
36938const currentLookups = [];
36939tokens.forEach((token, index) => {
36940if (cardKey(token.card) !== lookupKeys[index]) return;
36941currentTokens.push(token);
36942currentLookups.push(lookups[index] ?? untrustedAnkiLookupResult());
36943});
36944return { tokens: currentTokens, lookups: currentLookups };
36945}
36946class LateCardReconciliation {
36947constructor(dependencies) {
36948this.dependencies = dependencies;
36949}
36950tokens = new Map();
36951roots = new Set();
36952timer;
36953queue(tokens, roots) {
36954if (this.dependencies.isDestroyed() || !tokens.length) return;
36955tokens.forEach((token) => this.tokens.set(cardKey(token.card), token));
36956roots.filter((root) => root instanceof Node && root.isConnected).forEach((root) => this.roots.add(root));
36957if (this.timer !== void 0) return;
36958this.timer = window.setTimeout(() => {
36959this.timer = void 0;
36960this.flush();
36961}, RESOLVED_WORD_EFFECTS_BATCH_DELAY_MS);
36962}
36963token(card, pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown") {
36964return {
36965card,
36966start: 0,
36967end: card.spelling.length,
36968length: card.spelling.length,
36969rubies: [],
36970pitchClass: isParticleCard(card) ? "particle" : pitchClass
36971};
36972}
36973repaint(fallback, card, pitchClass, span) {
36974const changedRoots = new Set();
36975const geometryRoots = new Set();
36976const targets = this.dependencies.renderedWordsForCardStateRepaint(fallback).filter((word) => !span || word.dataset.tokenStart === void 0 || Number(word.dataset.tokenStart) === span.start && Number(word.dataset.tokenEnd) === span.end).map((word) => ({ word, root: this.dependencies.annotationRoot(word) }));
36977this.dependencies.pauseMutationObserver(() => {
36978targets.forEach(({ word, root }) => {
36979if (this.dependencies.applyVocabulary(word, card, pitchClass)) geometryRoots.add(root);
36980changedRoots.add(root);
36981});
36982});
36983const roots = [...changedRoots];
36984this.dependencies.scheduleAnnotationRefresh(roots, geometryRoots);
36985return roots;
36986}
36987resetPending() {
36988window.clearTimeout(this.timer);
36989this.timer = void 0;
36990this.tokens.clear();
36991this.roots.clear();
36992this.dependencies.resetRenderedWordRepaintCycle();
36993}
36994flush() {
36995const tokens = [...this.tokens.values()];
36996const roots = [...this.roots].filter((root) => root instanceof Node && root.isConnected);
36997this.tokens.clear();
36998this.roots.clear();
36999if (this.dependencies.isDestroyed() || !tokens.length) return;
37000roots.forEach((root) => this.dependencies.registerRenderedRoot(root));
37001this.dependencies.preloadAudio(tokens);
37002if (roots.length) this.dependencies.queueAnki(tokens, roots);
37003void this.hydrateLocalSrs(tokens, roots);
37004}
37005async hydrateLocalSrs(tokens, roots) {
37006const adapter = this.dependencies.getLocalSrs();
37007if (!this.dependencies.getSettings().yomuLocalSrsEnabled || typeof adapter.lookupCards !== "function") return;
37008try {
37009await hydrateYomuLocalSrsCardStates([tokens], adapter);
37010const settings2 = this.dependencies.getSettings();
37011if (this.dependencies.isDestroyed() || !settings2.yomuLocalSrsEnabled || !roots.length) return;
37012const connectedRoots = roots.filter((root) => root instanceof Node && root.isConnected);
37013if (!connectedRoots.length) return;
37014const cards = tokens.map((token) => token.card);
37015const cardsByIdentity = new Map(cards.flatMap((card) => {
37016const key = yomuLocalSrsCardIdentityKey(card.spelling, card.reading, card.language);
37017return key ? [[key, card]] : [];
37018}));
37019let changedWords = [];
37020const geometryRoots = new Set();
37021this.dependencies.pauseMutationObserver(() => {
37022changedWords = repaintYomuLocalSrsRenderedCards(cards, connectedRoots);
37023for (const word of changedWords) {
37024const key = yomuLocalSrsCardIdentityKey(
37025word.dataset.expression ?? "",
37026word.dataset.reading ?? "",
37027word.dataset.language
37028);
37029const card = key ? cardsByIdentity.get(key) : void 0;
37030if (!card) continue;
37031const root = this.dependencies.annotationRoot(word);
37032if (applyPublicVocabularyFurigana(word, card, settings2)) geometryRoots.add(root);
37033this.dependencies.reconcileInteractiveVocabulary(word, card, word.dataset.pitchClass ?? "");
37034}
37035});
37036if (!changedWords.length) return;
37037const changedRoots = uniqueParentNodes(changedWords.map((word) => this.dependencies.annotationRoot(word)));
37038this.dependencies.scheduleAnnotationRefresh(changedRoots, geometryRoots);
37039} catch (error) {
37040log$4.warnOnce("local-srs-hydration-failed", "Academy SRS late-card hydration failed", error);
37041}
37042}
37043}
37044const RENDERED_WORD_INDEX_PRUNE_DELAY_MS = 3e4;
37045class RenderedWordIndex {
37046constructor(dependencies) {
37047this.dependencies = dependencies;
37048}
37049entries = new Map();
37050fullyScanned = false;
37051pruneTimer;
37052repaintSeeded = false;
37053repaintSeedTimer;
37054markFullyScanned() {
37055this.fullyScanned = true;
37056}
37057registerRoot(root) {
37058renderedWordsInRoot(root).forEach((word) => this.register(word));
37059}
37060register(word) {
37061const key = renderedWordElementKey(word);
37062if (!isValidRenderedWordKey(key)) return;
37063const words = this.entries.get(key) ?? new Set();
37064words.add(word);
37065this.entries.set(key, words);
37066this.schedulePrune();
37067}
37068prepareForLookups(lookupByWordKey, roots) {
37069const targetRoots = roots.length ? roots : [document];
37070targetRoots.filter((root) => root instanceof ShadowRoot).forEach((root) => this.registerRoot(root));
37071const includesDocument = targetRoots.includes(document);
37072if (this.shouldSkipPreparation(lookupByWordKey, includesDocument)) return;
37073targetRoots.forEach((root) => this.registerRoot(root));
37074if (includesDocument) this.fullyScanned = true;
37075}
37076wordsForLookupKey(key, roots) {
37077const targetRoots = roots.length ? roots : [document];
37078const indexed = this.indexedWordsForKey(key, targetRoots);
37079if (indexed.length || this.entries.has(key)) return indexed;
37080const queried = this.queryWordsForKey(key, targetRoots);
37081queried.forEach((word) => this.register(word));
37082return queried;
37083}
37084wordsForCardStateRepaint(card) {
37085this.primeRepaintIndex();
37086const key = renderedWordCardKey(card.vid, card.sid);
37087const indexed = this.indexedWordsForKey(key, [document]);
37088if (indexed.length || this.entries.has(key)) return indexed;
37089const queried = this.queryWordsForKey(key, this.dependencies.annotationRoots());
37090queried.forEach((word) => this.register(word));
37091return queried;
37092}
37093tokensForRecolor(root, tokenForWord) {
37094const seen = new Set();
37095const tokens = [];
37096for (const [wordKey, words] of this.entries) {
37097for (const word of words) {
37098if (!word.isConnected || renderedWordElementKey(word) !== wordKey) {
37099words.delete(word);
37100continue;
37101}
37102if (!rootContainsRenderedWord(root, word)) continue;
37103const token = tokenForWord(word);
37104if (!token) continue;
37105const key = cardKey(token.card);
37106if (seen.has(key)) continue;
37107seen.add(key);
37108tokens.push(token);
37109}
37110if (!words.size) this.entries.delete(wordKey);
37111}
37112return tokens;
37113}
37114resetRepaintCycle() {
37115window.clearTimeout(this.repaintSeedTimer);
37116this.repaintSeedTimer = void 0;
37117this.repaintSeeded = false;
37118}
37119clear() {
37120window.clearTimeout(this.pruneTimer);
37121this.pruneTimer = void 0;
37122this.resetRepaintCycle();
37123this.entries.clear();
37124this.fullyScanned = false;
37125}
37126shouldSkipPreparation(lookupByWordKey, includesDocument) {
37127if (!includesDocument) return false;
37128if (this.fullyScanned) return true;
37129for (const key of lookupByWordKey.keys()) {
37130if (!this.entries.has(key)) return this.entries.size > 0 || lookupByWordKey.size <= ANKI_TARGETED_RENDERED_WORD_SELECTOR_THRESHOLD;
37131}
37132return true;
37133}
37134indexedWordsForKey(key, roots) {
37135const words = this.entries.get(key);
37136if (!words) return [];
37137const matches = [];
37138for (const word of words) {
37139if (!word.isConnected || renderedWordElementKey(word) !== key) {
37140words.delete(word);
37141continue;
37142}
37143if (roots.some((root) => rootContainsRenderedWord(root, word))) matches.push(word);
37144}
37145if (!words.size) this.entries.delete(key);
37146return matches;
37147}
37148queryWordsForKey(key, roots) {
37149const selector = renderedWordSelectorForKey(key);
37150if (!selector) return [];
37151const words = new Set();
37152roots.forEach((root) => {
37153if (root instanceof HTMLElement && root.matches(selector)) words.add(root);
37154root.querySelectorAll(selector).forEach((word) => words.add(word));
37155});
37156return [...words].filter((word) => renderedWordElementKey(word) === key);
37157}
37158primeRepaintIndex() {
37159if (this.repaintSeeded) return;
37160this.dependencies.annotationRoots().forEach((root) => this.registerRoot(root));
37161this.repaintSeeded = true;
37162if (this.repaintSeedTimer !== void 0) return;
37163this.repaintSeedTimer = window.setTimeout(() => {
37164this.repaintSeedTimer = void 0;
37165this.repaintSeeded = false;
37166}, 0);
37167}
37168schedulePrune() {
37169if (this.pruneTimer !== void 0 || this.dependencies.isDestroyed()) return;
37170this.pruneTimer = window.setTimeout(() => {
37171this.pruneTimer = void 0;
37172if (this.dependencies.isDestroyed()) return;
37173for (const [key, words] of this.entries) {
37174for (const word of words) {
37175if (!word.isConnected || renderedWordElementKey(word) !== key) words.delete(word);
37176}
37177if (!words.size) this.entries.delete(key);
37178}
37179}, RENDERED_WORD_INDEX_PRUNE_DELAY_MS);
37180}
37181}
37182const AUTHORED_VOCABULARY_ATTRIBUTE = "data-yomu-authored-vocabulary";
37183function applyAuthoredVocabularyOverrides(target, tokens) {
37184const annotations = readAuthoredVocabularyAnnotations(target.parent);
37185if (!annotations.length) return [...tokens];
37186const replacements = authoredVocabularyReplacements(target.text, annotations, tokens);
37187if (!replacements.length) return [...tokens];
37188const kept = tokens.filter((token) => !replacements.some((replacement) => rangesOverlap(token, replacement)));
37189return [...kept, ...replacements].sort((left, right) => left.start - right.start || right.length - left.length);
37190}
37191function readAuthoredVocabularyAnnotations(parent) {
37192const owner = authoredVocabularyOwner(parent);
37193const raw = owner?.getAttribute(AUTHORED_VOCABULARY_ATTRIBUTE);
37194if (!raw || raw.length > 8e3) return [];
37195try {
37196const value = JSON.parse(raw);
37197return Array.isArray(value) ? value.slice(0, 24).map(normalizeAnnotation).filter((item) => item !== null) : [];
37198} catch {
37199return [];
37200}
37201}
37202function authoredVocabularyOwner(parent) {
37203const element = parent instanceof HTMLElement ? parent : parent.parentElement;
37204if (!element) return null;
37205if (element.hasAttribute(AUTHORED_VOCABULARY_ATTRIBUTE)) return element;
37206return element.closest(`[${AUTHORED_VOCABULARY_ATTRIBUTE}]`);
37207}
37208function normalizeAnnotation(value) {
37209if (!value || typeof value !== "object") return null;
37210const record2 = value;
37211const surface = exactJapaneseSurface(record2.surface);
37212const lemma = normalizedJapaneseText(record2.lemma);
37213const reading = normalizedJapaneseText(record2.reading);
37214if (!surface || !lemma || !reading || !isKanaReading(reading)) return null;
37215const pitch = normalizePitch(record2.pitch, reading);
37216return { surface, lemma, reading, ...pitch ? { pitch } : {} };
37217}
37218function exactJapaneseSurface(value) {
37219if (typeof value !== "string") return "";
37220const text2 = value.normalize("NFC").trim();
37221const forbiddenControls = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
37222if (!text2 || text2.length > 80 || forbiddenControls.test(text2)) return "";
37223return text2;
37224}
37225function normalizePitch(value, reading) {
37226if (!value || typeof value !== "object") return void 0;
37227const record2 = value;
37228const pattern = typeof record2.pattern === "string" ? record2.pattern.trim() : "";
37229const source = typeof record2.source === "string" ? record2.source.trim().slice(0, 120) : "";
37230if (!pattern || !source || !pitchClassNameForPattern(pattern, reading)) return void 0;
37231return { pattern, source };
37232}
37233function normalizedJapaneseText(value, allowLayoutWhitespace = false) {
37234if (typeof value !== "string") return "";
37235const text2 = value.normalize("NFKC").trim();
37236const forbiddenControls = allowLayoutWhitespace ? /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u : /[\u0000-\u001f\u007f]/u;
37237if (!text2 || text2.length > 80 || forbiddenControls.test(text2)) return "";
37238return text2;
37239}
37240function isKanaReading(value) {
37241return KANA_ONLY_RUN_RE.test(value);
37242}
37243function authoredVocabularyReplacements(text2, annotations, parsedTokens) {
37244const replacements = [];
37245for (const annotation of annotations) {
37246let start = text2.indexOf(annotation.surface);
37247while (start >= 0) {
37248const end = start + annotation.surface.length;
37249if (!replacements.some((token) => rangesOverlap(token, { start, end }))) {
37250replacements.push(authoredVocabularyToken(annotation, start, end, text2, parsedTokens));
37251}
37252start = text2.indexOf(annotation.surface, end);
37253}
37254}
37255return replacements;
37256}
37257function authoredVocabularyToken(annotation, start, end, sentence, parsedTokens) {
37258const id = -stablePositiveHashId(`authored-vocabulary
37259${annotation.surface}
37260${annotation.lemma}
37261${annotation.reading}`);
37262const card = {
37263vid: id,
37264sid: id,
37265rid: 0,
37266spelling: annotation.surface,
37267reading: annotation.reading,
37268frequencyRank: null,
37269partOfSpeech: [],
37270meanings: [],
37271cardState: ["not-in-deck"],
37272provisionalState: true,
37273pitchAccent: [],
37274wordWithReading: null,
37275source: "fallback",
37276fallbackLookupTerms: [annotation.lemma]
37277};
37278return {
37279card,
37280start,
37281end,
37282length: end - start,
37283rubies: annotation.reading === annotation.surface ? [] : [{ text: annotation.reading, start, end, length: end - start }],
37284pitchClass: annotation.pitch ? pitchClassNameForPattern(annotation.pitch.pattern, annotation.reading) : parsedTokenPitchClass(start, end, parsedTokens),
37285sentence
37286};
37287}
37288function parsedTokenPitchClass(start, end, tokens) {
37289return tokens.find((token) => token.start === start && token.end === end)?.pitchClass ?? "";
37290}
37291function rangesOverlap(first, second) {
37292return first.start < second.end && second.start < first.end;
37293}
37294const log$3 = Logger.scope("VisiblePageScanner");
37295const VISIBLE_SCAN_PARSE_BATCH_SIZE = 80;
37296const VISIBLE_SCAN_PARSE_CHAR_BUDGET = 6e3;
37297const VISIBLE_SCAN_MOBILE_PARSE_CHAR_BUDGET = 3200;
37298const VISIBLE_SCAN_MOBILE_FALLBACK_PARSE_CHAR_BUDGET = 2e3;
37299const VISIBLE_SCAN_TARGET_COLLECTION_LIMIT = 200;
37300const VISIBLE_SCAN_MOBILE_TARGET_COLLECTION_LIMIT = 200;
37301const VISIBLE_SCAN_MOBILE_FALLBACK_TARGET_COLLECTION_LIMIT = 100;
37302const VISIBLE_SCAN_TARGET_TEXT_CHUNK_SIZE = 1800;
37303const VISIBLE_SCAN_MOBILE_TARGET_TEXT_CHUNK_SIZE = 900;
37304const VISIBLE_SCAN_MOBILE_FALLBACK_TARGET_TEXT_CHUNK_SIZE = 700;
37305const VISIBLE_SCAN_TARGET_TEXT_CHUNK_MIN_TAIL = 280;
37306const VISIBLE_SCAN_APPLY_BATCH_SIZE = 48;
37307const VISIBLE_SCAN_MOBILE_APPLY_BATCH_SIZE = 24;
37308const VISIBLE_SCAN_MOBILE_FALLBACK_APPLY_BATCH_SIZE = 12;
37309const VISIBLE_SCAN_MOBILE_VIEWPORT_WIDTH = 700;
37310const VISIBLE_SCAN_PARSE_TIMEOUT_MS = 450;
37311const VISIBLE_SCAN_REMOTE_PARSE_TIMEOUT_MS = 1200;
37312const VISIBLE_SCAN_CLAMP_SWEEP_DELAY_MS = 1500;
37313const VISIBLE_SCAN_SETTLE_REFRESH_DEBOUNCE_MS = 200;
37314const LATE_ANNOTATION_REFRESH_WINDOW_MS = 50;
37315const VISIBLE_SCAN_REMOTE_PARSE_PREFETCH = 2;
37316const YOUTUBE_VISIBLE_SCAN_PARSE_PREFETCH = 2;
37317const ASB_SCAN_BATCH_LIMIT = 12;
37318const ASB_SCAN_DRAIN_DELAY_MS = 80;
37319const MAX_CONSECUTIVE_CONTINUATION_SCANS = 6;
37320const VISIBLE_SCAN_COLLECTION_FRAME_BUDGET_MS = 12;
37321const MAX_VISIBLE_SCAN_COLLECTION_YIELDS = 24;
37322const FORCE_FURIGANA_MODE_ATTRIBUTE = "data-yomu-furigana-mode";
37323const CLAMPED_ROW_READINGS_ATTRIBUTE = "data-yomu-clamped-readings";
37324class VisiblePageScanner {
37325constructor(dependencies) {
37326this.dependencies = dependencies;
37327}
37328scanInFlight = false;
37329scanPending = false;
37330scanPendingSilent = true;
37331destroyed = false;
37332scanGeneration = 0;
37333continuationScans = 0;
37334continuationFailedTargetKeys = new Set();
37335continuationTargetNodeIds = new WeakMap();
37336nextContinuationTargetNodeId = 1;
37337asbScanInFlight = false;
37338asbDrainTimer;
37339clampSweepTimer;
37340settleTriggersInstalled = false;
37341settleRefreshTimer;
37342settleResizeObserver;
37343settleSignalAbort;
37344lastSettleWidth = -1;
37345lateAnnotationStateRoots = new Set();
37346lateAnnotationGeometryRoots = new Set();
37347lateAnnotationRefreshTimer;
37348makeRoomForRuby(root) {
37349return (this.dependencies.makeRoomForRubyInCroppedRows ?? makeRoomForRubyInCroppedRows)(root);
37350}
37351clearPendingGeometryRefreshes() {
37352window.clearTimeout(this.clampSweepTimer);
37353this.clampSweepTimer = void 0;
37354window.clearTimeout(this.settleRefreshTimer);
37355this.settleRefreshTimer = void 0;
37356window.clearTimeout(this.lateAnnotationRefreshTimer);
37357this.lateAnnotationRefreshTimer = void 0;
37358this.lateAnnotationStateRoots.clear();
37359this.lateAnnotationGeometryRoots.clear();
37360}
37361destroy() {
37362this.destroyed = true;
37363this.scanPending = false;
37364window.clearTimeout(this.asbDrainTimer);
37365this.asbDrainTimer = void 0;
37366this.clearPendingGeometryRefreshes();
37367this.settleResizeObserver?.dispose();
37368this.settleResizeObserver = void 0;
37369this.settleSignalAbort?.abort();
37370this.settleSignalAbort = void 0;
37371this.clearPageFuriganaMode();
37372}
37373cancelVisiblePageScan() {
37374this.scanGeneration++;
37375this.scanPending = false;
37376this.scanPendingSilent = true;
37377this.resetContinuationState();
37378}
37379pauseGeometrySweeps() {
37380this.clearPendingGeometryRefreshes();
37381window.clearTimeout(this.asbDrainTimer);
37382this.asbDrainTimer = void 0;
37383}
37384repointGeometrySettleTarget() {
37385const observer = this.settleResizeObserver;
37386if (this.destroyed || !observer) return;
37387if (typeof document === "undefined" || !document.body) return;
37388observer.disconnect();
37389this.lastSettleWidth = -1;
37390observer.observe(document.body);
37391}
37392scheduleLateAnnotationRefresh(roots, geometryRoots = roots) {
37393if (this.destroyed || typeof window === "undefined") return;
37394for (const root of roots) {
37395if (isConnectedNode(root)) this.lateAnnotationStateRoots.add(root);
37396}
37397for (const root of geometryRoots) {
37398if (isConnectedNode(root)) this.lateAnnotationGeometryRoots.add(root);
37399}
37400if (!this.lateAnnotationStateRoots.size && !this.lateAnnotationGeometryRoots.size) return;
37401if (this.lateAnnotationRefreshTimer !== void 0) return;
37402this.lateAnnotationRefreshTimer = window.setTimeout(() => {
37403this.lateAnnotationRefreshTimer = void 0;
37404if (this.destroyed) return;
37405this.flushLateAnnotationRefresh();
37406}, LATE_ANNOTATION_REFRESH_WINDOW_MS);
37407}
37408flushLateAnnotationRefresh() {
37409if (this.destroyed) {
37410this.lateAnnotationStateRoots.clear();
37411this.lateAnnotationGeometryRoots.clear();
37412return;
37413}
37414const stateRoots = compactConnectedRoots(this.lateAnnotationStateRoots);
37415const geometryRoots = compactConnectedRoots(this.lateAnnotationGeometryRoots);
37416this.lateAnnotationStateRoots.clear();
37417this.lateAnnotationGeometryRoots.clear();
37418if (this.destroyed) return;
37419this.dependencies.pauseMutationObserver(() => {
37420stateRoots.forEach((root) => {
37421refreshRenderedMiningInsights(root);
37422this.dependencies.refreshWordContrast?.(root);
37423});
37424if (!geometryRoots.length || typeof document !== "undefined" && document.hidden) return;
37425noteConstrainedRowLayoutSettled();
37426geometryRoots.forEach((root) => this.makeRoomForRuby(root));
37427geometryRoots.forEach((root) => healUngrowableInFlowClampRows(root));
37428geometryRoots.forEach((root) => refreshWrappedScanWordUnderlines(root));
37429});
37430}
37431async scanVisiblePage(options = {}) {
37432const silent = Boolean(options.silent);
37433const settings2 = this.dependencies.getSettings();
37434if (!pageScanHasVisibleAnnotations(settings2)) {
37435this.cancelVisiblePageScan();
37436this.syncPageFuriganaMode();
37437removeNonDestructiveScanMirrors(document);
37438unwrapReaderWords(document);
37439return;
37440}
37441if (!this.beginScan(silent)) return;
37442this.scanGeneration++;
37443const generation = this.scanGeneration;
37444const done = log$3.time("scanVisiblePage", { silent });
37445try {
37446this.syncPageFuriganaMode();
37447await this.runVisiblePageScan(silent, generation);
37448} catch (error) {
37449this.handleVisiblePageScanError(error, silent);
37450} finally {
37451this.finishScan();
37452healTextMirrorPageVisibility();
37453this.scheduleClampedRubySweep(silent);
37454done();
37455}
37456}
37457scheduleClampedRubySweep(silent = false) {
37458if (this.destroyed || typeof document === "undefined") return;
37459this.installSettleTriggers();
37460const sweep = () => this.runGeometrySettleSweep();
37461if (!silent) sweep();
37462window.clearTimeout(this.clampSweepTimer);
37463this.clampSweepTimer = window.setTimeout(sweep, VISIBLE_SCAN_CLAMP_SWEEP_DELAY_MS);
37464}
37465runGeometrySettleSweep() {
37466if (this.destroyed || typeof document === "undefined") return;
37467noteConstrainedRowLayoutSettled();
37468this.makeRoomForRuby(document);
37469healUngrowableInFlowClampRows(document);
37470refreshWrappedScanWordUnderlines(document);
37471projectAdditiveTextMirrors(document);
37472}
37473installSettleTriggers() {
37474if (this.settleTriggersInstalled || this.destroyed) return;
37475if (typeof document === "undefined" || typeof window === "undefined") return;
37476this.settleTriggersInstalled = true;
37477const controller = new AbortController();
37478this.settleSignalAbort = controller;
37479const schedule = () => this.scheduleSettleRefresh();
37480const fonts = document.fonts;
37481if (fonts) {
37482fonts.ready.then(schedule).catch(() => void 0);
37483fonts.addEventListener?.("loadingdone", schedule, { signal: controller.signal });
37484}
37485window.visualViewport?.addEventListener("resize", schedule, { signal: controller.signal });
37486window.addEventListener("resize", schedule, { signal: controller.signal });
37487if (typeof ResizeObserver === "function" && document.body) {
37488const raw = new ResizeObserver((entries2) => {
37489const width = entries2[entries2.length - 1]?.contentRect.width ?? -1;
37490const priming = this.lastSettleWidth < 0;
37491if (Math.abs(width - this.lastSettleWidth) < 0.5) return;
37492this.lastSettleWidth = width;
37493if (!priming) schedule();
37494});
37495const observer = new ParkableObserver(raw, { signal: controller.signal });
37496observer.observe(document.body);
37497this.settleResizeObserver = observer;
37498}
37499}
37500scheduleSettleRefresh() {
37501if (this.destroyed || typeof window === "undefined") return;
37502if (typeof document !== "undefined" && document.hidden) return;
37503window.clearTimeout(this.settleRefreshTimer);
37504this.settleRefreshTimer = window.setTimeout(() => this.runGeometrySettleSweep(), VISIBLE_SCAN_SETTLE_REFRESH_DEBOUNCE_MS);
37505}
37506async scanAsbPlayerSubtitles() {
37507if (this.destroyed || this.asbScanInFlight) return;
37508this.asbScanInFlight = true;
37509try {
37510const batchWasFull = await this.scanAsbPlayerSubtitleBatch();
37511if (batchWasFull) this.scheduleAsbPlayerDrain();
37512} finally {
37513this.asbScanInFlight = false;
37514}
37515}
37516scheduleAsbPlayerDrain() {
37517if (this.destroyed) return;
37518window.clearTimeout(this.asbDrainTimer);
37519this.asbDrainTimer = window.setTimeout(() => {
37520this.asbDrainTimer = void 0;
37521void this.scanAsbPlayerSubtitles();
37522}, ASB_SCAN_DRAIN_DELAY_MS);
37523}
37524async scanAsbPlayerSubtitleBatch() {
37525const roots = Array.from(document.querySelectorAll(".asbplayer-offscreen, .asbplayer-subtitles-container-bottom"));
37526if (!roots.length) return false;
37527roots.sort((a, b) => Number(a.classList.contains("asbplayer-offscreen")) - Number(b.classList.contains("asbplayer-offscreen")));
37528const targets = roots.flatMap((root) => collectTextTargetsIn(root, ASB_SCAN_BATCH_LIMIT, false)).slice(0, ASB_SCAN_BATCH_LIMIT);
37529if (!targets.length) return false;
37530try {
37531const parsed = await this.dependencies.parseJapanese(targets.map((target) => target.text), scanParseOptions(this.dependencies.getSettings()));
37532if (this.destroyed) return false;
37533const tokens = parsed.flat();
37534if (this.dependencies.prepareSubtitleTokensBeforeRender) {
37535this.dependencies.preloadParsedTokens(tokens);
37536await this.dependencies.prepareSubtitleTokensBeforeRender(tokens);
37537if (this.destroyed) return false;
37538}
37539const applyAnkiColors = this.shouldEnrichAnkiWords() ? await this.prepareAnkiColorsBeforeSubtitleRender(tokens) : void 0;
37540if (this.destroyed) return false;
37541const changedRoots = await this.applyTokens(targets, parsed, this.dependencies.getSettings());
37542this.dependencies.reconcileResolvedWordEffects?.(tokens, changedRoots);
37543applyAnkiColors?.(changedRoots);
37544if (this.dependencies.prepareSubtitleTokensBeforeRender) {
37545if (!applyAnkiColors && this.shouldEnrichAnkiWords()) await this.dependencies.enrichAnkiWords(tokens, changedRoots);
37546} else {
37547this.preloadParsed(parsed, changedRoots, { skipAnki: Boolean(applyAnkiColors) });
37548}
37549return targets.length === ASB_SCAN_BATCH_LIMIT;
37550} catch {
37551return false;
37552}
37553}
37554async prepareAnkiColorsBeforeSubtitleRender(tokens) {
37555if (this.dependencies.prepareAnkiWordEnrichmentBeforeRender) {
37556return await this.dependencies.prepareAnkiWordEnrichmentBeforeRender(tokens);
37557}
37558return this.dependencies.beginAnkiWordEnrichment?.(tokens);
37559}
37560beginScan(silent) {
37561if (this.destroyed) return false;
37562if (this.scanInFlight) {
37563this.scanPending = true;
37564this.scanPendingSilent = this.scanPendingSilent && silent;
37565return false;
37566}
37567this.scanInFlight = true;
37568return true;
37569}
37570async runVisiblePageScan(silent, generation) {
37571if (this.isStaleScan(generation)) return;
37572removeStaleControlTextMirrors(document);
37573const settings2 = this.dependencies.getSettings();
37574const targetCollectionLimit = !isNarrowVisibleScanViewport() ? VISIBLE_SCAN_TARGET_COLLECTION_LIMIT : isYouTubeVisibleScanHost() || hasJpdbParseApiKey(settings2) ? VISIBLE_SCAN_MOBILE_TARGET_COLLECTION_LIMIT : VISIBLE_SCAN_MOBILE_FALLBACK_TARGET_COLLECTION_LIMIT;
37575const collection = this.collectScanTargetsWithFrameBudget(targetCollectionLimit, generation, silent);
37576const collected = Array.isArray(collection) ? collection : await collection;
37577if (!collected || this.isStaleScan(generation)) return;
37578const chunkGroups = collected.map((source) => ({
37579source,
37580chunks: chunkLongScanTarget(source, settings2).reverse()
37581}));
37582const targets = chunkGroups.flatMap((group) => group.chunks);
37583if (!targets.length) {
37584this.resetContinuationState();
37585this.handleEmptyVisiblePageScan(silent);
37586return;
37587}
37588const unparsedTargets = await this.parseAndApplyTargets(targets, generation, settings2);
37589if (this.isStaleScan(generation)) return;
37590const effectiveCollectionLimit = effectiveSiteScanCollectionLimit(targetCollectionLimit, window.location.href);
37591if (collected.length >= effectiveCollectionLimit && this.canQueueContinuationScan(targets, silent)) {
37592const unparsed = new Set(unparsedTargets);
37593chunkGroups.filter((group) => group.chunks.length > 0 && group.chunks.every((chunk) =>
37593 unparsed.has(chunk))).forEach((group) => this.continuationFailedTargetKeys.add(this.continuationTargetKey(group.source)));
37594this.queueContinuationScan(silent);
37595return;
37596}
37597this.resetContinuationState();
37598this.reportVisiblePageCoverage(silent);
37599}
37600canQueueContinuationScan(targets, silent) {
37601return this.continuationScans < MAX_CONSECUTIVE_CONTINUATION_SCANS && (silent || targets.some((target) => !target.singlePassScan));
37602}
37603isStaleScan(generation) {
37604return this.destroyed || generation !== this.scanGeneration;
37605}
37606collectScanTargetsWithFrameBudget(limit, generation, silent) {
37607const skipMirroredHosts = silent || this.continuationScans > 0;
37608const steps = collectScanTargetsInSteps(limit, window.location.href, {
37609skipMirroredHosts,
37610mirroredHeadTargetCount: skipMirroredHosts ? Math.max(1, this.continuationScans) * limit : 0,
37611skipTargetCount: this.continuationFailedTargetKeys.size,
37612skipTarget: (target) => this.continuationFailedTargetKeys.has(this.continuationTargetKey(target))
37613});
37614let sliceStartedAt = Date.now();
37615for (; ; ) {
37616const next = steps.next();
37617if (next.done) return next.value;
37618if (Date.now() - sliceStartedAt >= VISIBLE_SCAN_COLLECTION_FRAME_BUDGET_MS) {
37619return (async () => {
37620for (let yields = 0; yields < MAX_VISIBLE_SCAN_COLLECTION_YIELDS; yields += 1) {
37621await waitForVisibleScanTurn();
37622if (this.isStaleScan(generation)) return void 0;
37623sliceStartedAt = Date.now();
37624for (; ; ) {
37625const chunk = steps.next();
37626if (chunk.done) return chunk.value;
37627if (Date.now() - sliceStartedAt >= VISIBLE_SCAN_COLLECTION_FRAME_BUDGET_MS) break;
37628}
37629}
37630for (; ; ) {
37631const chunk = steps.next();
37632if (chunk.done) return chunk.value;
37633}
37634})();
37635}
37636}
37637}
37638async parseAndApplyTargets(targets, generation, scanStartSettings, allowTransientReparse = true) {
37639let cursor = 0;
37640const unparsedTargets = [];
37641const transientTargets = [];
37642const pending = [];
37643const parseCharBudget = !isNarrowVisibleScanViewport() ? VISIBLE_SCAN_PARSE_CHAR_BUDGET : hasJpdbParseApiKey(scanStartSettings) ? VISIBLE_SCAN_MOBILE_PARSE_CHAR_BUDGET : VISIBLE_SCAN_MOBILE_FALLBACK_PARSE_CHAR_BUDGET;
37644const concurrency = hasRemoteParseApiKey(scanStartSettings) ? isYouTubeVisibleScanHost() ? YOUTUBE_VISIBLE_SCAN_PARSE_PREFETCH : VISIBLE_SCAN_REMOTE_PARSE_PREFETCH : 1;
37645const schedule = () => {
37646while (!this.isStaleScan(generation) && pending.length < concurrency && cursor < targets.length) {
37647const next = nextVisibleScanParseBatch(targets, cursor, parseCharBudget);
37648cursor = next.cursor;
37649if (!next.batch.length) continue;
37650const work = {
37651batch: next.batch,
37652transient: false,
37653result: this.parseVisibleScanBatch(next.batch, generation, () => {
37654work.transient = true;
37655})
37656};
37657pending.push(work);
37658}
37659};
37660schedule();
37661while (pending.length) {
37662if (this.isStaleScan(generation)) return unparsedTargets;
37663const work = pending.shift();
37664const parsed = await work.result;
37665parsed.forEach((tokens, index) => {
37666if (tokens.length || !work.batch[index]) return;
37667(work.transient ? transientTargets : unparsedTargets).push(work.batch[index]);
37668});
37669if (this.isStaleScan(generation)) return unparsedTargets;
37670await this.applyParsedBatch(work.batch, parsed, scanStartSettings, generation);
37671schedule();
37672if (pending.length || cursor < targets.length) await waitForVisibleScanTurn();
37673}
37674if (allowTransientReparse && transientTargets.length && !this.isStaleScan(generation)) {
37675const stillUnparsed = await this.parseAndApplyTargets(transientTargets, generation, scanStartSettings, false);
37676return unparsedTargets.concat(stillUnparsed);
37677}
37678return unparsedTargets.concat(transientTargets);
37679}
37680async parseVisibleScanBatch(batch, generation, onTransientFailure) {
37681const paragraphs = batch.map((target) => target.text);
37682const options = scanParseOptions(this.dependencies.getSettings());
37683try {
37684return await this.dependencies.parseJapanese(paragraphs, options);
37685} catch (error) {
37686if (this.isStaleScan(generation)) return paragraphs.map(() => []);
37687log$3.warn("Visible page parse batch failed; retrying locally", error);
37688try {
37689return await this.dependencies.parseJapanese(paragraphs, { ...options, skipApi: true });
37690} catch (fallbackError) {
37691log$3.warn("Visible page local parse recovery failed; continuing with later batches", fallbackError);
37692onTransientFailure?.();
37693return paragraphs.map(() => []);
37694}
37695}
37696}
37697async applyParsedBatch(batch, parsed, scanStartSettings, generation) {
37698const resolved = batch.map((target, index) => applyAuthoredVocabularyOverrides(target, parsed[index] ?? []));
37699const tokens = resolved.flat();
37700const applyAnkiColors = this.shouldEnrichAnkiWords() ? this.dependencies.beginAnkiWordEnrichment?.(tokens) : void 0;
37701const changedRoots = await this.applyTokens(batch, resolved, scanStartSettings, generation);
37702applyAnkiColors?.(changedRoots);
37703this.preloadParsed(resolved, changedRoots, {
37704skipAnki: Boolean(applyAnkiColors)
37705});
37706}
37707async applyTokens(targets, parsed, scanStartSettings, generation) {
37708const allChangedRoots = new Set();
37709const applyBatchSize = !isNarrowVisibleScanViewport() ? VISIBLE_SCAN_APPLY_BATCH_SIZE : hasJpdbParseApiKey(scanStartSettings) ? VISIBLE_SCAN_MOBILE_APPLY_BATCH_SIZE : VISIBLE_SCAN_MOBILE_FALLBACK_APPLY_BATCH_SIZE;
37710for (let index = 0; index < targets.length; index += applyBatchSize) {
37711if (this.shouldStopApplyingTokens(generation)) return [...allChangedRoots];
37712const start = index;
37713const batch = targets.slice(start, start + applyBatchSize);
37714const changedRoots = new Set();
37715this.dependencies.pauseMutationObserver(() => withMirrorTokenApply(() => {
37716if (this.shouldStopApplyingTokens(generation)) return;
37717batch.forEach((target, offset) => {
37718if (this.shouldStopApplyingTokens(generation)) return;
37719if (!isCurrentScanTarget(target)) return;
37720applyTokensToScanTarget(target, parsed[start + offset] ?? [], this.dependencies.getSettings());
37721changedRoots.add(target.parent);
37722});
37723changedRoots.forEach((root) => {
37724normalizeOcrScannerLinesInRoot(root, this.dependencies.getSettings());
37725allChangedRoots.add(root);
37726this.dependencies.refreshWordContrast?.(root);
37727});
37728}));
37729if (changedRoots.size) this.dependencies.noteRenderedRoots?.([...changedRoots]);
37730if (index + applyBatchSize < targets.length) await waitForVisibleScanTurn();
37731}
37732this.reserveRubyRoomForNewRoots(allChangedRoots);
37733return [...allChangedRoots];
37734}
37735reserveRubyRoomForNewRoots(roots) {
37736for (const root of roots) {
37737if (this.destroyed) return;
37738this.makeRoomForRuby(root);
37739}
37740for (const root of roots) {
37741if (this.destroyed) return;
37742healUngrowableInFlowClampRows(root);
37743}
37744for (const root of roots) {
37745if (this.destroyed) return;
37746refreshWrappedScanWordUnderlines(root);
37747}
37748}
37749shouldStopApplyingTokens(generation) {
37750return this.destroyed || generation !== void 0 && this.isStaleScan(generation);
37751}
37752preloadParsed(parsed, changedRoots = [], options = {}) {
37753if (this.destroyed) return;
37754const tokens = parsed.flat();
37755this.dependencies.preloadParsedTokens(tokens);
37756if (!options.skipPitch) void this.dependencies.enrichPitchWords(tokens);
37757if (!options.skipAnki && this.shouldEnrichAnkiWords()) void this.dependencies.enrichAnkiWords(tokens, changedRoots);
37758}
37759shouldEnrichAnkiWords() {
37760const settings2 = this.dependencies.getSettings();
37761return !this.destroyed && (shouldLookupAnkiStatus(settings2) || shouldLookupBunproWordStates(settings2));
37762}
37763handleEmptyVisiblePageScan(silent) {
37764if (silent) return;
37765const interfaceLanguage = this.dependencies.getSettings().interfaceLanguage;
37766this.dependencies.toast(formatUiText(interfaceLanguage, "noUnscannedJapaneseText", {
37767language: activeTargetLanguageDisplayName(interfaceLanguage)
37768}));
37769}
37770handleVisiblePageScanError(error, silent) {
37771log$3.warn("Visible page scan failed", error);
37772if (!silent) {
37773const language = this.dependencies.getSettings().interfaceLanguage;
37774this.dependencies.toast(userFacingErrorText(language, "jpdbScanFailed", error));
37775}
37776}
37777reportVisiblePageCoverage(silent) {
37778if (silent) return;
37779const summary = visiblePageCoverageSummary();
37780if (!summary.total) return;
37781const percent = Math.round(summary.known / summary.total * 100);
37782this.dependencies.toast(formatUiText(this.dependencies.getSettings().interfaceLanguage, "pageCoverageSummary", {
37783percent,
37784known: summary.known,
37785total: summary.total,
37786unknown: summary.unknown,
37787iPlusOne: summary.iPlusOne
37788}));
37789}
37790finishScan() {
37791this.scanInFlight = false;
37792if (this.destroyed) {
37793this.scanPending = false;
37794this.scanPendingSilent = true;
37795return;
37796}
37797if (!this.scanPending) return;
37798const silent = this.scanPendingSilent;
37799this.scanPending = false;
37800this.scanPendingSilent = true;
37801const scheduledFromGeneration = this.scanGeneration;
37802void waitForVisibleScanTurn().then(() => {
37803if (this.isStaleScan(scheduledFromGeneration)) return;
37804return this.scanVisiblePage({ silent });
37805});
37806}
37807queueContinuationScan(silent) {
37808if (this.destroyed) return;
37809this.continuationScans += 1;
37810this.scanPending = true;
37811this.scanPendingSilent = this.scanPendingSilent && silent;
37812}
37813resetContinuationState() {
37814this.continuationScans = 0;
37815this.continuationFailedTargetKeys.clear();
37816this.continuationTargetNodeIds = new WeakMap();
37817this.nextContinuationTargetNodeId = 1;
37818}
37819continuationTargetKey(target) {
37820if (!isFragmentTextTarget(target)) {
37821return `node:${this.continuationNodeId(target.node)}\0${target.text}`;
37822}
37823const source = target.fragments.length ? target.fragments.map((fragment) => `${this.continuationNodeId(fragment.node)}:${fragment.start}:${fragment.end}`).join(",") : `parent:${this.continuationNodeId(target.parent)}`;
37824return `${source}\0${target.text}\0${target.parserId ?? ""}`;
37825}
37826continuationNodeId(node) {
37827const existing = this.continuationTargetNodeIds.get(node);
37828if (existing !== void 0) return existing;
37829const id = this.nextContinuationTargetNodeId;
37830this.nextContinuationTargetNodeId += 1;
37831this.continuationTargetNodeIds.set(node, id);
37832return id;
37833}
37834syncPageFuriganaMode() {
37835if (typeof document === "undefined") return;
37836const root = document.documentElement;
37837if (!root) return;
37838const settings2 = this.dependencies.getSettings();
37839this.syncClampedRowReadingsMode(settings2, root);
37840if (settings2.showFurigana && settings2.furiganaMode === "all") {
37841if (root.getAttribute(FORCE_FURIGANA_MODE_ATTRIBUTE) !== "all") {
37842root.setAttribute(FORCE_FURIGANA_MODE_ATTRIBUTE, "all");
37843}
37844return;
37845}
37846this.clearPageFuriganaMode();
37847}
37848syncClampedRowReadingsMode(settings2, root) {
37849if (settings2.clampedRowReadings === "hover") {
37850if (root.getAttribute(CLAMPED_ROW_READINGS_ATTRIBUTE) !== "hover") {
37851root.setAttribute(CLAMPED_ROW_READINGS_ATTRIBUTE, "hover");
37852}
37853return;
37854}
37855if (root.hasAttribute(CLAMPED_ROW_READINGS_ATTRIBUTE)) {
37856root.removeAttribute(CLAMPED_ROW_READINGS_ATTRIBUTE);
37857}
37858}
37859clearPageFuriganaMode() {
37860if (typeof document === "undefined") return;
37861const root = document.documentElement;
37862if (!root) return;
37863if (root.getAttribute(FORCE_FURIGANA_MODE_ATTRIBUTE) === "all") {
37864root.removeAttribute(FORCE_FURIGANA_MODE_ATTRIBUTE);
37865}
37866}
37867}
37868function pageScanHasVisibleAnnotations(settings2) {
37869if (effectiveFuriganaMode(settings2) !== "off") return true;
37870return effectiveReaderColorSource(settings2, settings2.wordHighlightColorSource, "jpdb") !== "off" || effectiveReaderColorSource(settings2, settings2.wordUnderlineColorSource, "pitch") !== "off" || effectiveReaderTextColorSource(settings2, settings2.wordTextColorSource, "anki") !== "off";
37871}
37872function waitForVisibleScanTurn() {
37873return new Promise((resolve) => window.setTimeout(resolve, 0));
37874}
37875function isNarrowVisibleScanViewport() {
37876return typeof window !== "undefined" && window.innerWidth > 0 && window.innerWidth <= VISIBLE_SCAN_MOBILE_VIEWPORT_WIDTH;
37877}
37878function isYouTubeVisibleScanHost(hostname = location.hostname) {
37879return isYouTubeAppHostname(hostname);
37880}
37881function hasJpdbParseApiKey(settings2) {
37882return Boolean(settings2.apiKey.trim());
37883}
37884function hasRemoteParseApiKey(settings2) {
37885return Boolean(settings2.apiKey.trim() || settings2.jitenApiKey.trim());
37886}
37887function scanParseOptions(settings2) {
37888return {
37889jpdbTimeoutMs: hasRemoteParseApiKey(settings2) ? VISIBLE_SCAN_REMOTE_PARSE_TIMEOUT_MS : VISIBLE_SCAN_PARSE_TIMEOUT_MS,
37890allowJpdbTimeoutFallback: true,
37891includeLocalPitch: false,
37892allowSegmentedFallback: true,
37893publicJitenDetailLimit: 0
37894};
37895}
37896function chunkLongScanTarget(target, settings2) {
37897if (target.nonDestructive) return [target];
37898const chunkSize = !isNarrowVisibleScanViewport() ? VISIBLE_SCAN_TARGET_TEXT_CHUNK_SIZE : hasJpdbParseApiKey(settings2) ? VISIBLE_SCAN_MOBILE_TARGET_TEXT_CHUNK_SIZE : VISIBLE_SCAN_MOBILE_FALLBACK_TARGET_TEXT_CHUNK_SIZE;
37899if (target.text.length <= chunkSize) return [target];
37900if (scanTargetRequiresWholeSourceMirror(target)) return [target];
37901const fragmentTarget = isFragmentTextTarget(target);
37902const sourceStart = fragmentTarget ? 0 : target.node.data.indexOf(target.text);
37903if (sourceStart < 0) return [target];
37904const chunks = [];
37905for (let start = 0; start < target.text.length; ) {
37906const end = nextChunkEnd(target.text, start, chunkSize);
37907const chunk = fragmentTarget ? fragmentTargetChunk(target, start, end) : textTargetChunk(target, sourceStart + start, sourceStart + end);
37908if (chunk) chunks.push({ ...chunk, suppressRepaintLoopMirror: true });
37909start = end;
37910}
37911return chunks;
37912}
37913function textTargetChunk(target, start, end) {
37914if (end <= start) return null;
37915const text2 = target.node.data.slice(start, end);
37916if (!text2) return null;
37917return {
37918text: text2,
37919parent: target.parent,
37920fragments: [{
37921node: target.node,
37922start,
37923end,
37924hasNativeRuby: Boolean(target.hasNativeRuby),
37925layoutSensitive: target.layoutSensitive,
37926passiveInteraction: target.passiveInteraction
37927}],
37928layoutSensitive: target.layoutSensitive,
37929passiveInteraction: target.passiveInteraction,
37930singlePassScan: target.singlePassScan,
37931forceInlineRender: target.forceInlineRender
37932};
37933}
37934function fragmentTargetChunk(target, start, end) {
37935const fragments = fragmentRange(target.fragments, start, end);
37936if (!fragments.length) return null;
37937return {
37938...target,
37939text: target.text.slice(start, end),
37940fragments
37941};
37942}
37943function fragmentRange(fragments, start, end) {
37944const result = [];
37945let cursor = 0;
37946for (const fragment of fragments) {
37947const length = fragment.end - fragment.start;
37948const fragmentStart = cursor;
37949const fragmentEnd = cursor + length;
37950const overlapStart = Math.max(start, fragmentStart);
37951const overlapEnd = Math.min(end, fragmentEnd);
37952if (overlapEnd > overlapStart) {
37953result.push({
37954...fragment,
37955start: fragment.start + overlapStart - fragmentStart,
37956end: fragment.start + overlapEnd - fragmentStart
37957});
37958}
37959cursor = fragmentEnd;
37960if (cursor >
37960= end) break;
37961}
37962return result;
37963}
37964function nextChunkEnd(text2, start, chunkSize) {
37965const hardEnd = Math.min(text2.length, start + chunkSize);
37966if (hardEnd >= text2.length) return text2.length;
37967if (text2.length - hardEnd < VISIBLE_SCAN_TARGET_TEXT_CHUNK_MIN_TAIL) return text2.length;
37968return chunkBoundaryBefore(text2, start, hardEnd, chunkSize) ?? hardEnd;
37969}
37970function chunkBoundaryBefore(text2, start, hardEnd, chunkSize) {
37971const softStart = Math.max(start + Math.floor(chunkSize * 0.6), start + 1);
37972for (let index = hardEnd; index > softStart; index -= 1) {
37973if (/[。!?!?]/u.test(text2[index - 1] ?? "")) return index;
37974}
37975for (let index = hardEnd; index > softStart; index -= 1) {
37976if (/[、,,\n\r\s]/u.test(text2[index - 1] ?? "")) return index;
37977}
37978return null;
37979}
37980function isFragmentTextTarget(target) {
37981return "fragments" in target;
37982}
37983function visiblePageCoverageSummary() {
37984const summary = {
37985cards: new Set(),
37986iPlusOne: new Set(),
37987known: 0,
37988unknown: 0
37989};
37990for (const word of renderedWordsInRoot(document)) {
37991addVisiblePageCoverageWord(summary, word);
37992}
37993return {
37994total: summary.cards.size,
37995known: summary.known,
37996unknown: summary.unknown,
37997iPlusOne: summary.iPlusOne.size
37998};
37999}
38000function addVisiblePageCoverageWord(summary, word) {
38001if (word.closest("[data-jpdb-reader-root]")) return;
38002const key = visiblePageCoverageCardKey(word);
38003countVisiblePageCoverageCard(summary, word, key);
38004countVisiblePageCoverageMiningInsight(summary, word, key);
38005}
38006function visiblePageCoverageCardKey(word) {
38007const cardId = firstRenderedWordIdentityPart(
38008renderedWordPrivateValue(word, "cardId"),
38009renderedWordPrivateValue(word, "vid")
38010);
38011if (!cardId) return "";
38012const source = firstRenderedWordIdentityPart(renderedWordPrivateValue(word, "cardSource"), "jpdb");
38013const readingIndex = firstRenderedWordIdentityPart(
38014renderedWordPrivateValue(word, "readingIndex"),
38015renderedWordPrivateValue(word, "sid")
38016);
38017return `${source}:${cardId}/${readingIndex}`;
38018}
38019function firstRenderedWordIdentityPart(...parts) {
38020return parts.find(Boolean) ?? "";
38021}
38022function countVisiblePageCoverageCard(summary, word, key) {
38023if (!key || summary.cards.has(key)) return;
38024summary.cards.add(key);
38025if (word.matches(".jpdb-known,.jpdb-never-forget,.jpdb-redundant")) summary.known += 1;
38026if (word.matches(".jpdb-new,.jpdb-not-in-deck,.jpdb-in-deck")) summary.unknown += 1;
38027}
38028function countVisiblePageCoverageMiningInsight(summary, word, key) {
38029if (word.dataset.miningInsight !== "i-plus-one") return;
38030summary.iPlusOne.add([word.dataset.sentence, visiblePageCoverageInsightSurface(word, key)].join("\0"));
38031}
38032function visiblePageCoverageInsightSurface(word, key) {
38033return key || word.dataset.expression || word.textContent || "";
38034}
38035function isConnectedNode(root) {
38036if (typeof Node !== "undefined" && root instanceof Node) {
38037return Boolean(root.isConnected);
38038}
38039return Boolean(root && typeof root === "object" && "isConnected" in root && root.isConnected);
38040}
38041function compactConnectedRoots(roots) {
38042const connected = [...new Set(roots)].filter(isConnectedNode);
38043return connected.filter((root) => !connected.some((other) => other !== root && other.contains(root)));
38044}
38045function nextVisibleScanParseBatch(targets, startCursor, charBudget = VISIBLE_SCAN_PARSE_CHAR_BUDGET) {
38046const batch = [];
38047let cursor = startCursor;
38048let budget = 0;
38049while (cursor < targets.length && batch.length < VISIBLE_SCAN_PARSE_BATCH_SIZE) {
38050const target = targets[cursor];
38051if (!target || !isCurrentScanTarget(target)) {
38052cursor += 1;
38053continue;
38054}
38055const length = target.text.length;
38056if (batch.length && budget + length > charBudget) break;
38057batch.push(target);
38058budget += length;
38059cursor += 1;
38060}
38061return { batch, cursor };
38062}
38063function createAsyncReconciliation(reconcile, onError) {
38064let active = true;
38065let running = false;
38066let trailing = false;
38067const request = () => {
38068if (!active) return;
38069if (running) {
38070trailing = true;
38071return;
38072}
38073running = true;
38074void reconcile().catch((error) => onError?.(error)).finally(() => {
38075running = false;
38076if (!active || !trailing) return;
38077trailing = false;
38078request();
38079});
38080};
38081return {
38082request,
38083stop: () => {
38084active = false;
38085trailing = false;
38086}
38087};
38088}
38089const DORMANT_TARGET_POLL_MS = 5e3;
38090function subscribeToReaderSettingsChanges(onSettings) {
38091let active = true;
38092const receive = (settings2) => {
38093if (active) onSettings(settings2);
38094};
38095const unsubscribeStoredChanges = subscribeToSettingsStorageChanges(receive);
38096const hostedBridge = isHostedYomuOrigin();
38097const reconciliation2 = createAsyncReconciliation(async () => {
38098if (!active) return;
38099receive(await loadSettings());
38100}, () => void 0);
38101const onStorageBridgeReady = () => reconciliation2.request();
38102if (hostedBridge) addWindowEventListener(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, onStorageBridgeReady);
38103if (hostedBridge) reconciliation2.request();
38104return () => {
38105active = false;
38106reconciliation2.stop();
38107unsubscribeStoredChanges();
38108if (hostedBridge) removeWindowEventListener(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, onStorageBridgeReady);
38109};
38110}
38111function subscribeToFirstPersistedLearningTarget(currentSettings, onTarget) {
38112let active = true;
38113const receive = (settings2) => {
38114if (active && !currentSettings().learningTargetChosen && settings2.learningTargetChosen) onTarget(settings2);
38115};
38116const unsubscribe = subscribeToReaderSettingsChanges(receive);
38117const stopPolling = pollForFirstPersistedTarget(receive);
38118void loadSettings().then(receive).catch(() => void 0);
38119return () => {
38120active = false;
38121stopPolling();
38122unsubscribe();
38123};
38124}
38125function pollForFirstPersistedTarget(receive) {
38126let inFlight = false;
38127const reconcile = () => {
38128if (inFlight) return;
38129inFlight = true;
38130void loadSettings().then(receive).catch(() => void 0).finally(() => {
38131inFlight = false;
38132});
38133};
38134const timer = window.setInterval(reconcile, DORMANT_TARGET_POLL_MS);
38135const onVisibilityChange = () => {
38136if (document.visibilityState === "visible") reconcile();
38137};
38138document.addEventListener("visibilitychange", onVisibilityChange);
38139return () => {
38140window.clearInterval(timer);
38141document.removeEventListener("visibilitychange", onVisibilityChange);
38142};
38143}
38144class TopLevelTargetLifecycle {
38145initialized = false;
38146canWaitForTarget(destroyed, embedded, targetPolicyActive) {
38147return !destroyed && !embedded && !this.initialized && !targetPolicyActive;
38148}
38149beginTargetOwnedSurfaces(destroyed, embedded, targetPolicyActive) {
38150if (!targetPolicyActive || !this.canWaitForTarget(destroyed, embedded, false)) return false;
38151this.initialized = true;
38152return true;
38153}
38154}
38155function shouldWakeTopLevelTarget(embedded, targetPolicyActive, settings2) {
38156return !embedded && !targetPolicyActive && settings2.learningTargetChosen;
38157}
38158async function applyTargetSurfaceSettingsChange(wakesTarget, embedded, initialize, installFab, refresh) {
38159if (wakesTarget) {
38160await initialize();
38161return true;
38162}
38163if (!embedded) installFab();
38164refresh();
38165return false;
38166}
38167const EXTENSION_DICTIONARY_RPC_CHANNEL = "yomu.dictionary-store.v2";
38168const EXTENSION_DICTIONARY_RPC_PORT = `${EXTENSION_DICTIONARY_RPC_CHANNEL}.operation`;
38169const EXTENSION_DICTIONARY_RPC_VERSION = 2;
38170const EXTENSION_DICTIONARY_BACKGROUND_MARKER = "yomu-extension-dictionary-background";
38171const EXTENSION_DICTIONARY_PROBE_TIMEOUT_MS = 250;
38172const EXTENSION_DICTIONARY_KEEPALIVE_MS = 2e4;
38173function dictionaryRpcEpoch(value) {
38174if (value === void 0) throw new Error("Dictionary RPC requires an explicit caller epoch.");
38175return parseManagedStateEpoch(value);
38176}
38177function dictionaryRpcEpochValue(epoch) {
38178return epoch.generation === 0 ? null : epoch;
38179}
38180const BINARY_CHUNK_BYTES = 256 * 1024;
38181const VALUE_TAG = "__yomuDictionaryRpcValue";
38182function prepareDictionaryRpcValue(input, options = {}) {
38183const binaries = [];
38184const ancestors = new Set();
38185let binarySequence = 0;
38186const encode = (value) => {
38187if (value === void 0) return marker("undefined");
38188if (value === null || typeof value === "string" || typeof value === "boolean") return value;
38189if (typeof value === "number") {
38190if (Number.isFinite(value)) return value;
38191return { ...marker("number"), value: String(value) };
38192}
38193if (typeof value === "function") {
38194if (!options.callbackId) throw new TypeError("Dictionary RPC cannot serialize a function outside a callback slot.");
38195return { ...marker("callback"), id: options.callbackId(value) };
38196}
38197if (typeof value !== "object") throw new TypeError(`Dictionary RPC cannot serialize ${typeof value}.`);
38198if (isLearningTargetModule(value)) {
38199return {
38200...marker("target"),
38201id: value.id,
38202language: value.language,
38203interfaceVersion: value.interfaceVersion
38204};
38205}
38206if (isBlobLike(value)) {
38207const id = `binary-${++binarySequence}`;
38208binaries.push({ id, value });
38209const file = isFileLike(value);
38210return {
38211...marker("binary"),
38212id,
38213binaryKind: file ? "file" : "blob",
38214size: value.size,
38215type: value.type || "",
38216...file ? { name: value.name, lastModified: value.lastModified } : {}
38217};
38218}
38219if (value instanceof Date) return { ...marker("date"), value: value.toISOString() };
38220if (ancestors.has(value)) throw new TypeError("Dictionary RPC cannot serialize a cyclic value.");
38221ancestors.add(value);
38222try {
38223if (Array.isArray(value)) return value.map(encode);
38224const encoded = {};
38225for (const [key, child] of Object.entries(value)) encoded[key] = encode(child);
38226return encoded;
38227} finally {
38228ancestors.delete(value);
38229}
38230};
38231return { value: encode(input), binaries };
38232}
38233function decodeDictionaryRpcValue(input, options = {}) {
38234const decode = (value) => {
38235if (value === null || typeof value !== "object") return value;
38236if (Array.isArray(value)) return value.map(decode);
38237const tagged = value;
38238switch (tagged[VALUE_TAG]) {
38239case "undefined":
38240return void 0;
38241case "number":
38242return Number(tagged.value);
38243case "date":
38244return new Date(String(tagged.value));
38245case "binary":
38246if (!options.binary) throw new Error("Dictionary RPC binary payload was not supplied.");
38247return options.binary(tagged);
38248case "callback":
38249if (!options.callback) throw new Error("Dictionary RPC callback channel was not supplied.");
38250return options.callback(tagged.id);
38251case "target":
38252if (!options.target) throw new Error("Dictionary RPC learning-target resolver was not supplied.");
38253return options.target(tagged);
38254default: {
38255const decoded = {};
38256for (const [key, child] of Object.entries(value)) decoded[key] = decode(child);
38257return decoded;
38258}
38259}
38260};
38261return decode(input);
38262}
38263async function sendDictionaryRpcBinaries(binaries, send) {
38264for (const attachment of binaries) {
38265const size = attachment.value.size;
38266if (size === 0) {
38267send({ kind: "binary", id: attachment.id, data: "", final: true });
38268continue;
38269}
38270for (let offset = 0; offset < size; offset += BINARY_CHUNK_BYTES) {
38271const end = Math.min(size, offset + BINARY_CHUNK_BYTES);
38272const bytes = await localBytesFromBlob(attachment.value.slice(offset, end));
38273send({
38274kind: "binary",
38275id: attachment.id,
38276data: bytesToBase64(bytes),
38277final: end >= size
38278});
38279}
38280}
38281}
38282class DictionaryRpcBinaryReceiver {
38283chunks = new Map();
38284completed = new Set();
38285accept(message) {
38286if (this.completed.has(message.id)) throw new Error(`Dictionary RPC binary ${message.id} was already complete.`);
38287const chunks = this.chunks.get(message.id) ?? [];
38288chunks.push(base64ToBytes(message.data));
38289this.chunks.set(message.id, chunks);
38290if (message.final) this.completed.add(message.id);
38291}
38292has(id) {
38293return this.completed.has(id);
38294}
38295value(marker2) {
38296if (!this.has(marker2.id)) throw new Error(`Dictionary RPC binary ${marker2.id} is incomplete.`);
38297const parts = this.chunks.get(marker2.id) ?? [];
38298const blob = new Blob(parts, { type: marker2.type });
38299if (blob.size !== marker2.size) {
38300throw new Error(`Dictionary RPC binary ${marker2.id} has ${blob.size} bytes; expected ${marker2.size}.`);
38301}
38302if (marker2.binaryKind !== "file") return blob;
38303if (typeof File === "function") {
38304return new File([blob], marker2.name || "dictionary.bin", {
38305type: marker2.type,
38306lastModified: marker2.lastModified
38307});
38308}
38309Object.defineProperties(blob, {
38310name: { value: marker2.name || "dictionary.bin", configurable: true },
38311lastModified: { value: marker2.lastModified ?? Date.now(), configurable: true }
38312});
38313return blob;
38314}
38315}
38316function reviveDictionaryRpcError(value) {
38317const error = new Error(value.message, value.cause ? { cause: reviveDictionaryRpcError(value.cause) } : void 0);
38318error.name = value.name || "Error";
38319if (value.stack) error.stack = value.stack;
38320const record2 = error;
38321if (value.code) record2.code = value.code;
38322if (value.yomuUiCopyKey) record2.yomuUiCopyKey = value.yomuUiCopyKey;
38323if (value.epochMayHaveCommitted !== void 0) record2.epochMayHaveCommitted = value.epochMayHaveCommitted;
38324return error;
38325}
38326function rebindDictionaryRpcInputReferences(args, result) {
38327const requests = Array.isArray(args[0]) ? args[0] : null;
38328if (!requests || !Array.isArray(result)) return result;
38329return result.map((item) => {
38330if (!item || typeof item !== "object") return item;
38331const record2 = item;
38332const index = record2.requestIndex;
38333if (!Number.isInteger(index) || index < 0 || index >= requests.length || !("request" in record2)) {
38334return item;
38335}
38336return { ...record2, request: requests[index] };
38337});
38338}
38339function isDictionaryRpcBinaryChunk(value) {
38340const record2 = value && typeof value === "object" ? value : null;
38341return record2?.kind === "binary" && typeof record2.id === "string" && typeof record2.data === "string" && typeof record2.final === "boolean";
38342}
38343function marker(kind) {
38344return { [VALUE_TAG]: kind };
38345}
38346function isBlobLike(value) {
38347try {
38348const candidate = value;
38349return typeof candidate.size === "number" && typeof candidate.type === "string" && typeof candidate.slice === "function";
38350} catch {
38351return false;
38352}
38353}
38354function isFileLike(value) {
38355try {
38356const candidate = value;
38357return typeof candidate.name === "string" && typeof candidate.lastModified === "number";
38358} catch {
38359return false;
38360}
38361}
38362function isLearningTargetModule(value) {
38363const candidate = value;
38364return typeof candidate.id === "string" && typeof candidate.language === "string" && typeof candidate.interfaceVersion === "number" && typeof candidate.normalizeText === "function" && typeof candidate.lookupCandidates === "function";
38365}
38366function bytesToBase64(bytes) {
38367let binary = "";
38368for (let offset = 0; offset < bytes.length; offset += 32768) {
38369binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
38370}
38371return btoa(binary);
38372}
38373function base64ToBytes(encoded) {
38374const binary = atob(encoded);
38375const bytes = new Uint8Array(binary.length);
38376for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
38377return bytes;
38378}
38379const CAPABILITY_RETRY_MS = 1e3;
38380function extensionDictionaryStoreProxy(directStore, root = globalThis) {
38381const extension = extensionRuntime(root);
38382if (!extension) return directStore;
38383let capability;
38384const requireDictionaryBackground = () => {
38385if (!capability || capability.retryAt <= performance.now()) {
38386const attempt2 = {
38387retryAt: Infinity,
38388promise: probeDictionaryBackground(extension).then((epoch) => managedStateEpochSessionForRealm(root).assertCurrent(async () => dictionaryRpcEpochValue(epoch))).catch((error) => {
38389attempt2.retryAt = performance.now() + CAPABILITY_RETRY_MS;
38390throw error;
38391})
38392};
38393capability = attempt2;
38394}
38395return capability.promise;
38396};
38397const wrappers = new Map();
38398return new Proxy(directStore, {
38399get(target, property, receiver) {
38400const direct = Reflect.get(target, property, receiver);
38401if (typeof direct !== "function") return direct;
38402const existing = wrappers.get(property);
38403if (existing) return existing;
38404if (property === "invalidateCaches") {
38405const invalidate = (...args) => {
38406void requireDictionaryBackground().then((epoch) => invokeRemote(extension, String(property), args, epoch)).catch(() => void 0);
38407};
38408wrappers.set(property, invalidate);
38409return invalidate;
38410}
38411const invoke = (...args) => requireDictionaryBackground().then((epoch) => invokeRemote(extension, String(property), args, epoch));
38412wrappers.set(property, invoke);
38413return invoke;
38414},
38415has: (target, property) => Reflect.has(target, property)
38416});
38417}
38418function probeDictionaryBackground(extension) {
38419return sendExtensionMessage(extension, envelope("ping"), EXTENSION_DICTIONARY_PROBE_TIMEOUT_MS).then((value) => {
38420const response = dictionaryRpcResponse(value);
38421if (response?.error) throw reviveDictionaryRpcError(response.error);
38422if (!(response?.ok && response.kind === "capability" && response.marker === EXTENSION_DICTIONARY_BACKGROUND_MARKER)) {
38423throw userFacingError("extensionDictionaryUnavailable");
38424}
38425try {
38426return dictionaryRpcEpoch(response.epoch);
38427} catch (cause) {
38428throw userFacingError("extensionDictionaryUnavailable", { cause });
38429}
38430}, (cause) => {
38431throw userFacingError("extensionDictionaryUnavailable", { cause });
38432});
38433}
38434function invokeRemote(extension, method, args, epoch) {
38435return invokeRemoteViaPort(extension, method, args, epoch);
38436}
38437function invokeRemoteViaPort(extension, method, args, epoch) {
38438return new Promise((resolve, reject) => {
38439let port;
38440try {
38441port = extension.runtime.connect({ name: EXTENSION_DICTIONARY_RPC_PORT });
38442} catch (error) {
38443reject(userFacingError("extensionDictionaryUnavailable", { cause: error }));
38444return;
38445}
38446let closed = false;
38447let resultDelivered = false;
38448let backgroundPending = false;
38449let completionReceived = false;
38450let resultValue;
38451let resultBinaryIds;
38452const receiver = new DictionaryRpcBinaryReceiver();
38453const callbacks = new Map();
38454let callbackSequence = 0;
38455const prepared = prepareDictionaryRpcValue(args, {
38456callbackId: (callback) => {
38457const id = ++callbackSequence;
38458callbacks.set(id, callback);
38459return id;
38460}
38461});
38462const keepalive = globalThis.setInterval(() => {
38463post({ kind: "keepalive" });
38464}, EXTENSION_DICTIONARY_KEEPALIVE_MS);
38465const close = (callback) => {
38466if (closed) return;
38467closed = true;
38468globalThis.clearInterval(keepalive);
38469callback();
38470try {
38471port.disconnect();
38472} catch {
38473}
38474};
38475const fail = (error) => close(() => {
38476if (!resultDelivered) reject(error);
38477});
38478const post = (message) => {
38479if (closed) return false;
38480try {
38481port.postMessage(message);
38482return !closed;
38483} catch (cause) {
38484fail(userFacingError("extensionDictionaryConnectionLost", { cause }));
38485return false;
38486}
38487};
38488const finishResultIfReady = () => {
38489if (resultDelivered) return;
38490if (resultBinaryIds === void 0 || resultValue === void 0) return;
38491if (resultBinaryIds.some((id) => !receiver.has(id))) return;
38492try {
38493const decoded = decodeDictionaryRpcValue(resultValue, {
38494binary: (marker2) => receiver.value(marker2)
38495});
38496resultDelivered = true;
38497const rebound = rebindDictionaryRpcInputReferences(args, decoded);
38498if (backgroundPending) {
38499resolve(rebound);
38500if (completionReceived) close(() => void 0);
38501} else {
38502close(() => resolve(rebound));
38503}
38504} catch (error) {
38505fail(error);
38506}
38507};
38508port.onDisconnect.addListener(() => {
38509if (!closed) fail(userFacingError("extensionDictionaryConnectionLost", {
38510cause: new Error("Dictionary background operation disconnected before completion.")
38511}));
38512});
38513port.onMessage.addListener((message) => {
38514if (closed) return;
38515if (isDictionaryRpcBinaryChunk(message)) {
38516try {
38517receiver.accept(message);
38518finishResultIfReady();
38519} catch (error) {
38520fail(error);
38521}
38522return;
38523}
38524const record2 = message && typeof message === "object" ? message : {};
38525if (record2.kind === "callback" && Number.isInteger(record2.id)) {
38526const callback = callbacks.get(record2.id);
38527if (!callback) return;
38528try {
38529const values = decodeDictionaryRpcValue(record2.args);
38530Reflect.apply(callback, void 0, Array.isArray(values) ? values : []);
38531} catch {
38532}
38533return;
38534}
38535if (record2.kind === "error") {
38536fail(reviveDictionaryRpcError(record2.error));
38537return;
38538}
38539if (record2.kind === "complete") {
38540completionReceived = true;
38541if (resultDelivered) close(() => void 0);
38542return;
38543}
38544if (record2.kind === "result") {
38545resultValue = record2.value;
38546backgroundPending = record2.backgroundPending === true;
38547resultBinaryIds = Array.isArray(record2.binaryIds) ? record2.binaryIds.filter((id) => typeof id === "string") : [];
38548finishResultIfReady();
38549}
38550});
38551if (!post(envelope("invoke", {
38552method,
38553args: prepared.value,
38554target: currentTarget(),
38555epoch: dictionaryRpcEpochValue(epoch)
38556}))) return;
38557void sendDictionaryRpcBinaries(prepared.binaries, (message) => {
38558if (!post(message)) throw new Error("Dictionary operation transport closed.");
38559}).catch(fail);
38560});
38561}
38562function sendExtensionMessage(extension, message, timeoutMs) {
38563return new Promise((resolve, reject) => {
38564let settled = false;
38565const finish = (callback) => {
38566if (settled) return;
38567settled = true;
38568globalThis.clearTimeout(timer);
38569callback();
38570};
38571const timer = globalThis.setTimeout(
38572() => finish(() => reject(new Error("Dictionary background request timed out."))),
38573timeoutMs
38574);
38575const done = (value) => finish(() => {
38576const lastError = extension.runtime.lastError;
38577if (lastError) reject(new Error(lastError.message || "Dictionary background request failed."));
38578else resolve(value);
38579});
38580try {
38581const maybePromise = extension.promiseBased ? extension.runtime.sendMessage(message) : extension.runtime.sendMessage(message, done);
38582if (isPromiseLike(maybePromise)) void maybePromise.then(done, (error) => finish(() => reject(error)));
38583} catch (error) {
38584finish(() => reject(error));
38585}
38586});
38587}
38588function currentTarget() {
38589const target = activeLearningTarget();
38590return {
38591id: target.id,
38592language: target.language,
38593interfaceVersion: target.interfaceVersion
38594};
38595}
38596function envelope(kind, detail = {}) {
38597return {
38598channel: EXTENSION_DICTIONARY_RPC_CHANNEL,
38599version: EXTENSION_DICTIONARY_RPC_VERSION,
38600kind,
38601...detail
38602};
38603}
38604function dictionaryRpcResponse(value) {
38605if (!value || typeof value !== "object") return null;
38606const response = value;
38607return response.channel === EXTENSION_DICTIONARY_RPC_CHANNEL && response.version === EXTENSION_DICTIONARY_RPC_VERSION ? response : null;
38608}
38609function extensionRuntime(root) {
38610const global = root;
38611try {
38612if (global.browser?.runtime?.id && typeof global.browser.runtime.sendMessage === "function" && typeof global.browser.runtime.connect === "function") {
38613return { promiseBased: true, runtime: global.browser.runtime };
38614}
38615if (global.chrome?.runtime?.id && typeof global.chrome.runtime.sendMessage === "function" && typeof global.chrome.runtime.connect === "function") {
38616return { promiseBased: false, runtime: global.chrome.runtime };
38617}
38618} catch {
38619return null;
38620}
38621return null;
38622}
38623function isPromiseLike(value) {
38624return Boolean(value && typeof value.then === "function");
38625}
38626function createLocalDictionaryStore(getCorsProxyUrl, getInterfaceLanguage) {
38627const companion = yomuLocalDictionaries();
38628const direct = companion ? new companion.YomitanDictionaryStore(getCorsProxyUrl, getInterfaceLanguage) : inertLocalDictionaryStore();
38629return extensionDictionaryStoreProxy(direct);
38630}
38631function inertLocalDictionaryStore() {
38632const inert = {
38633lookup: async () => [],
38634searchTerms: async () => [],
38635lookupKanji: async () => [],
38636listKanjiCharacters: async () => [],
38637lookupTermMeta: async () => [],
38638lookupSimilarTermsByKanji: async () => [],
38639findTermMatches: async () => [],
38640lookupExactTermCandidates: async () => [],
38641listRandomTerms: async () => [],
38642listRandomTopTerms: async () => [],
38643hasDictionaries: async () => false,
38644hasTermDictionaries: async () => false,
38645hasPitchMetaDictionaries: async () => false,
38646prepareTermSearchIndex: async () => void 0,
38647summary: async () => ({ dictionaries: [], terms: 0, kanji: 0, termMeta: 0, kanjiMeta: 0 }),
38648dictionaryStyleCss: async () => "",
38649exportJson: async () => {
38650throw companionMissingError();
38651},
38652importFile: async () => {
38653throw companionMissingError();
38654},
38655importFromUrl: async () => {
38656throw companionMissingError();
38657},
38658importZip: async () => {
38659throw companionMissingError();
38660},
38661importJson: async () => {
38662throw companionMissingError();
38663},
38664importDexieJson: async () => {
38665throw companionMissingError();
38666},
38667clear: async () => void 0,
38668deleteDictionary: async () => void 0,
38669deleteDatabase: async () => void 0,
38670invalidateCaches: () => void 0,
38671invalidateForFactoryReset: async () => void 0
38672};
38673return inert;
38674}
38675function companionMissingError() {
38676return new Error("Local dictionaries unavailable: Yomu Settings Surface companion did not load.");
38677}
38678function uniqueTokensByCard(tokens) {
38679const seen = new Set();
38680return tokens.filter((token) => {
38681const key = cardKey(token.card);
38682if (seen.has(key)) return false;
38683seen.add(key);
38684return true;
38685});
38686}
38687function normalizedNestedParseOptions(options, _settings) {
38688const apiTimeoutMs = nestedParseApiTimeoutMs(options);
38689const allowApiTimeoutFallback = nestedParseAllowApiTimeoutFallback(options);
38690const skipApi = nestedParseSkipApi(options);
38691const requireApi = nestedParseRequireApi(options, skipApi);
38692return {
38693apiTimeoutMs,
38694allowApiTimeoutFallback,
38695jpdbTimeoutMs: options.jpdbTimeoutMs ?? apiTimeoutMs,
38696allowJpdbTimeoutFallback: options.allowJpdbTimeoutFallback ?? allowApiTimeoutFallback,
38697includeLocalPitch: options.includeLocalPitch ?? false,
38698skipApi,
38699skipJpdb: options.skipJpdb ?? skipApi,
38700requireApi,
38701requireJpdb: options.requireJpdb ?? requireApi,
38702allowSegmentedFallback: options.allowSegmentedFallback ?? true,
38703...options.publicJitenDetailLimit === void 0 ? {} : { publicJitenDetailLimit: options.publicJitenDetailLimit }
38704};
38705}
38706function nestedParseApiTimeoutMs(options) {
38707return options.apiTimeoutMs ?? options.jpdbTimeoutMs ?? 1200;
38708}
38709function waitForHoverCardInitialPaint() {
38710return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
38711}
38712function nestedParseAllowApiTimeoutFallback(options) {
38713return options.allowApiTimeoutFallback ?? options.allowJpdbTimeoutFallback ?? false;
38714}
38715function nestedParseSkipApi(options) {
38716return options.skipApi ?? options.skipJpdb ?? false;
38717}
38718function nestedParseRequireApi(options, skipApi) {
38719return options.requireApi ?? options.requireJpdb ?? !skipApi;
38720}
38721function isYouTubeRuntimeHost(hostname = location.hostname) {
38722return isYouTubeHostname(hostname);
38723}
38724function isCompactPitchEnrichmentViewport() {
38725return window.innerWidth <= 700 || navigator.maxTouchPoints > 1;
38726}
38727function pitchEnrichmentQueueOptions(options) {
38728return {
38729publicLookup: options.publicLookup,
38730publicLookupTermLimit: options.publicLookupTermLimit,
38731jpdbPublicLookup: options.jpdbPublicLookup,
38732urgent: options.urgent
38733};
38734}
38735const SUBSTANTIVE_PUBLIC_PITCH_LOOKUP_RE = new RegExp(`[${KANJI_LIKE_WITH_COUNTERS}]|[${KATAKANA_WITH_PROLONGED}]{2,}|[${HIRAGANA_WITH_PROLONGED}]{2,}`, "u");
38736function isSubstantivePublicPitchLookupToken(token) {
38737const surface = token.sentence?.slice(token.start, token.end) ?? "";
38738return SUBSTANTIVE_PUBLIC_PITCH_LOOKUP_RE.test(token.card.spelling) || SUBSTANTIVE_PUBLIC_PITCH_LOOKUP_RE.test(token.card.reading) || SUBSTANTIVE_PUBLIC_PITCH_LOOKUP_RE.test(surface);
38739}
38740function publicLookupCardRequest(readingOrOptions, maybeOptions) {
38741return typeof readingOrOptions === "string" ? { options: maybeOptions, reading: readingOrOptions } : { options: readingOrOptions, reading: "" };
38742}
38743function canSearchPublicLookupCard(settings2, options) {
38744return Boolean(
38745options.allowCandidateLookup || settings2.jpdbDefinitionsEnabled || settings2.showPitchAccent || settings2.showFurigana && settings2.furiganaMode !== "off"
38746);
38747}
38748function publicLookupSearchLimit(reading) {
38749return reading ? 12 : 1;
38750}
38751function publicLookupCardFromResults(cards, term, exact, reading) {
38752if (reading) return cards.find((card) => card.spelling === term && card.reading === reading);
38753const exactMatch = cards.find((card) => card.spelling === term || card.reading === term);
38754return exactMatch ?? (exact ? void 0 : cards[0]);
38755}
38756function publicJitenDetailLimit(requested) {
38757return Math.min(Math.max(0, Math.floor(requested)), PITCH_ENRICHMENT_LIMIT * 2);
38758}
38759function isHydratablePublicJitenCard(card) {
38760return card.source === "jiten" && Number.isFinite(card.jitenWordId ?? card.vid) && Number.isFinite(card.jitenReadingIndex ?? card.sid) && (!card.reading || !card.pitchAccent.length || !card.wordWithReading || !card.meanings.length);
38761}
38762function cardHasContextPitch(card) {
38763if (!card.pitchAccent.length) return false;
38764const reading = cardPronunciationReading(card);
38765if (!reading) return true;
38766return Boolean(contextPitchPattern(card.pitchAccent, reading));
38767}
38768function mergePitchPatterns(preferred, existing) {
38769return [...preferred, ...existing.filter((pattern) => !preferred.includes(pattern))];
38770}
38771const log$2 = Logger.scope("PublicLookupFallback");
38772function normalizedJitenLookupKey(term) {
38773return term.replace(/\s+/g, "");
38774}
38775function jitenFallbackTokenMatches(term, token) {
38776const normalizedTerm = normalizedJitenLookupKey(term);
38777const tokenSurface = normalizedJitenLookupKey(token.sentence?.slice(token.start, token.end) ?? "");
38778return tokenSurface === normalizedTerm || normalizedJitenLookupKey(token.card.spelling) === normalizedTerm || normalizedJitenLookupKey(token.card.reading) === normalizedTerm;
38779}
38780function jitenFallbackCardMatchesTerm(term, card) {
38781const normalizedTerm = normalizedJitenLookupKey(term);
38782return normalizedJitenLookupKey(card.spelling) === normalizedTerm || normalizedJitenLookupKey(card.reading) === normalizedTerm;
38783}
38784function uniqueFallbackLookupEntries(cards, termLimit) {
38785const seen = new Set();
38786const entries2 = [];
38787for (const card of cards) {
38788const key = cardKey(card);
38789if (seen.has(key)) continue;
38790seen.add(key);
38791const allTerms = fallbackLookupTermsForCard(card);
38792const terms = typeof termLimit === "number" ? allTerms.slice(0, Math.max(card.spelling.endsWith("ながら") ? 2 : 1, Math.floor(termLimit))) : allTerms;
38793if (terms.length) entries2.push({ key, terms, validationTerms: allTerms });
38794}
38795return entries2;
38796}
38797function fairFallbackLookupTerms(entries2) {
38798const terms = [];
38799const seen = new Set();
38800const rounds = entries2.reduce((maximum, entry) => Math.max(maximum, entry.terms.length), 0);
38801for (let candidateIndex = 0; candidateIndex < rounds; candidateIndex++) {
38802for (const entry of entries2) {
38803const term = entry.terms[candidateIndex];
38804if (!term || seen.has(term)) continue;
38805seen.add(term);
38806terms.push(term);
38807}
38808}
38809return terms;
38810}
38811async function batchJitenFallbackCards(terms, parse) {
38812const cards = new Map();
38813const uniqueTerms = [...new Set(terms.map((term) => term.trim()).filter(Boolean))];
38814if (!uniqueTerms.length) return cards;
38815const parsed = await parse(uniqueTerms).catch((error) => {
38816log$2.warn("Jiten batch fallback parse failed", { terms: uniqueTerms.length }, error);
38817if (isMissingProxyTransportError(error)) throw error;
38818return [];
38819});
38820uniqueTerms.forEach((term, index) => {
38821const tokens = parsed[index] ?? [];
38822const card = tokens.find((token) => jitenFallbackTokenMatches(term, token))?.card;
38823if (card?.source === "jiten") cards.set(normalizedJitenLookupKey(term), card);
38824});
38825return cards;
38826}
38827async function jitenFallbackCards(terms, entryCount, deps, options) {
38828if (deps.jitenApiActive()) {
38829const batched = await batchJitenFallbackCards(terms, deps.parse).catch((error) => {
38830if (!isMissingProxyTransportError(error)) throw error;
38831return null;
38832});
38833if (batched) return batched;
38834}
38835const loaded = await deps.lookupMany(terms, options.detailLimit ? { detailLimit: options.detailLimit(entryCount) } : void 0).catch((error) => {
38836log$2.warn("Jiten fallback failed", { terms: terms.length }, error);
38837return new Map();
38838});
38839const cards = new Map();
38840loaded.forEach((card, term) => cards.set(normalizedJitenLookupKey(term), card));
38841return cards;
38842}
38843async function publicLookupFallbackCards(cards, deps, options) {
38844const result = new Map();
38845const entries2 = uniqueFallbackLookupEntries(cards, options.termLimit);
38846if (!entries2.length) return result;
38847const terms = fairFallbackLookupTerms(entries2);
38848const jitenCards = await jitenFallbackCards(terms, entries2.length, deps, options);
38849for (const entry of entries2) {
38850let resolved;
38851for (const term of entry.terms) {
38852const card = jitenCards.get(normalizedJitenLookupKey(term));
38853if (!card) continue;
38854if (jitenFallbackCardMatchesTerm(term, card)) {
38855resolved = card;
38856break;
38857}
38858if (!resolved && entry.validationTerms.some((candidate) => jitenFallbackCardMatchesTerm(candidate, card))) resolved = card;
38859}
38860if (resolved) result.set(entry.key, resolved);
38861}
38862if (options.jpdbPublicLookup === false) return result;
38863const unresolved = entries2.filter((entry) => !result.has(entry.key));
38864await runLimited(unresolved, options.concurrency, async (entry) => {
38865for (const term of entry.terms) {
38866const publicCard = await deps.publicSpellingCard(term);
38867if (!publicCard) continue;
38868result.set(entry.key, publicCard);
38869return;
38870}
38871});
38872return result;
38873}
38874function createTextLookupDisplayContext(text2, options, state) {
38875const selected = normalizedLookupText(text2);
38876if (!isLookupableTargetLanguageText(selected)) return null;
38877const trigger = options.trigger ?? state.defaultTrigger;
38878const navigation = options.navigation ?? "reset";
38879return {
38880selected,
38881displaySelected: options.displaySelected ?? text2,
38882anchor: options.anchor ?? connectedElement(state.activePopoverAnchor),
38883trigger,
38884navigation,
38885preservePosition: options.preservePosition ?? textLookupPreservePosition(navigation, state),
38886focusOnMount: options.focusOnMount ?? true,
38887previousNavigationEntry: options.previousNavigationEntry ?? state.previousNavigationEntry(trigger, navigation),
38888insideReaderPopup: options.insideReaderPopup,
38889userGesture: options.userGesture,
38890hoverLookupGeneration: options.hoverLookupGeneration,
38891stackOverSettings: options.stackOverSettings
38892};
38893}
38894function textLookupParseOptions(apiKey) {
38895const apiKeyActive = Boolean(apiKey.trim());
38896return jpdbFirstParseOptions({
38897allowSegmentedFallback: true,
38898...apiKeyActive ? {
38899requireJpdb: false,
38900jpdbTimeoutMs: TEXT_LOOKUP_JPDB_TIMEOUT_MS,
38901allowJpdbTimeoutFallback: true
38902} : {}
38903});
38904}
38905async function showTextLookupResult(context, tokens, sentence, callbacks) {
38906const parsedTokens = lookupResultTokens(tokens, callbacks.isJpdbBackedCard);
38907const relevantTokens = tokensOverlappingSelection(parsedTokens, context.selected, sentence);
38908const selectedToken = pickExactTokenForSelection(relevantTokens, context.selected);
38909if (selectedToken) {
38910callbacks.showCard(selectedToken.card, selectedToken.sentence ?? sentence, context.anchor, textLookupCardOptions(context));
38911return;
38912}
38913if (relevantTokens.length) {
38914callbacks.showTokenList(relevantTokens, context.displaySelected, context.anchor, textLookupCardOptions(context));
38915return;
38916}
38917if (sentence !== context.selected && await showSelectedTextParsedLookupResult(context, callbacks)) return;
38918await callbacks.showLocalOrFallbackLookupCard(context, sentence);
38919}
38920async function showSelectedTextParsedLookupResult(context, callbacks) {
38921const [tokens] = await callbacks.parseJapanese([context.selected], callbacks.textLookupParseOptions());
38922const parsedTokens = lookupResultTokens(tokens, callbacks.isJpdbBackedCard);
38923const selectedToken = pickExactTokenForSelection(parsedTokens, context.selected);
38924if (selectedToken) {
38925callbacks.showCard(selectedToken.card, selectedToken.sentence ?? context.selected, context.anchor, textLookupCardOptions(context));
38926return true;
38927}
38928if (parsedTokens.length) {
38929callbacks.showTokenList(parsedTokens, context.displaySelected, context.anchor, textLookupCardOptions(context));
38930return true;
38931}
38932return false;
38933}
38934function textLookupCardOptions(context) {
38935return {
38936trigger: context.trigger,
38937navigation: context.navigation,
38938preservePosition: context.preservePosition,
38939focusOnMount: context.focusOnMount,
38940previousNavigationEntry: context.previousNavigationEntry,
38941insideReaderPopup: context.insideReaderPopup,
38942userGesture: context.userGesture,
38943hoverLookupGeneration: context.hoverLookupGeneration,
38944stackOverSettings: context.stackOverSettings
38945};
38946}
38947function textLookupPreservePosition(navigation, state) {
38948return navigation !== "reset" && state.hasActivePopover;
38949}
38950function lookupResultTokens(tokens = [], isJpdbBackedCard) {
38951return tokens.filter((token) => isJpdbBackedCard(token.card) || token.card.source === "jiten" || token.card.source === "local" || token.card.source === "fallback");
38952}
38953class ReaderCardLookupSession {
38954constructor(dependencies) {
38955this.dependencies = dependencies;
38956this.language = targetLanguageOf(dependencies.getSettings());
38957}
38958version = 0;
38959language;
38960runtimeGeneration = activeLearningTargetGeneration();
38961captureTarget() {
38962const version = this.version;
38963const language = this.language;
38964const generation = activeLearningTargetGeneration();
38965const target = activeLearningTarget();
38966return {
38967target,
38968isCurrent: () => version === this.version && language === this.language && generation === activeLearningTargetGeneration() && target === activeLearningTarget()
38969};
38970}
38971syncTarget(settings2) {
38972const language = targetLanguageOf(settings2);
38973const runtimeGeneration = activeLearningTargetGeneration();
38974if (language === this.language && runtimeGeneration === this.runtimeGeneration) return;
38975this.language = language;
38976this.runtimeGeneration = runtimeGeneration;
38977this.version += 1;
38978this.dependencies.onTargetChange();
38979}
38980async lookupText(text2, sentence = text2, options = {}, scope = this.captureTarget()) {
38981if (!scope.isCurrent()) return;
38982const context = createTextLookupDisplayContext(text2, options, this.dependencies.textLookupDisplayState());
38983if (!context) return;
38984const done = this.dependencies.log.time("lookupText", { length: context.selected.length, trigger: context.trigger });
38985try {
38986const [tokens] = await this.dependencies.parser().parse([sentence], this.textLookupParseOptions());
38987if (!scope.isCurrent()) return;
38988await showTextLookupResult(context, tokens, sentence, this.textLookupResultCallbacks(scope));
38989} catch (error) {
38990if (!scope.isCurrent()) return;
38991this.dependencies.log.warn("Lookup fallback", { selected: context.selected }, error);
38992await this.showLocalOrFallbackLookupCard(context, sentence, scope, error);
38993} finally {
38994done();
38995}
38996}
38997async resolveLookupCard(card, scope = this.captureTarget()) {
38998if (!scope.isCurrent() || !usesJapaneseProviders()) return card;
38999const contextual = card.source === "jpdb" && Boolean(card.sourceCardKey);
39000if (card.source !== "fallback" && !contextual) return card;
39001const publicCard = card.source === "fallback" ? await this.lookupFallbackApiCard(card, {}, scope) : await this.publicLookupCard(card.spelling, true, contextual ? card.reading : "");
39002if (!publicCard || !scope.isCurrent() || !usesJapaneseProviders()) return card;
39003this.dependencies.parser().cacheCards([publicCard]);
39004return publicCard;
39005}
39006async resolveLookupCardForInitialRender(card, scope = this.captureTarget()) {
39007if (!scope.isCurrent() || !usesJapaneseProviders()) return card;
39008if (card.source !== "fallback" && !(card.source === "jpdb" && card.sourceCardKey)) return card;
39009const resolved = this.resolveLookupCard(card, scope);
39010void resolved.catch(() => void 0);
39011return Promise.race([
39012resolved,
39013delay(FALLBACK_LOOKUP_INITIAL_WAIT_MS).then(() => card)
39014]);
39015}
39016async publicLookupCard(term, exact = false, readingOrOptions = "", maybeOptions = {}) {
39017const scope = this.captureTarget();
39018if (!scope.isCurrent() || !usesJapaneseProviders()) return void 0;
39019const request = publicLookupCardRequest(readingOrOptions, maybeOptions);
39020if (!canSearchPublicLookupCard(this.dependencies.getSettings(), request.options)) return void 0;
39021const cards = await this.dependencies.jpdbVocabulary().search(term, publicLookupSearchLimit(request.reading)).then((result) => result.cards).catch((error) => {
39022this.dependencies.log.warn("Public JPDB lookup failed", { term }, error);
39023return [];
39024});
39025return scope.isCurrent() && usesJapaneseProviders() ? publicLookupCardFromRe
39025sults(cards, term, exact, request.reading) : void 0;
39026}
39027async publicLookupFallbackCards(cards, options = {}, scope = this.captureTarget()) {
39028if (!scope.isCurrent() || !usesJapaneseProviders()) return new Map();
39029if (!canSearchPublicLookupCard(this.dependencies.getSettings(), {})) return new Map();
39030const resolved = await publicLookupFallbackCards(cards, {
39031jitenApiActive: () => scope.isCurrent() && this.dependencies.isJitenApiActive(),
39032parse: (terms) => scope.isCurrent() ? this.dependencies.jiten().parse(terms) : Promise.resolve(terms.map(() => [])),
39033lookupMany: (terms, lookupOptions) => scope.isCurrent() ? this.dependencies.jitenPublicVocabulary().lookupMany(
39034terms,
39035{ ...lookupOptions, detailTimeoutMs: JITEN_BACKGROUND_DETAIL_TIMEOUT_MS }
39036) : Promise.resolve(new Map()),
39037publicSpellingCard: (term) => scope.isCurrent() ? this.publicLookupSpellingCard(term) : Promise.resolve(void 0)
39038}, {
39039concurrency: BACKGROUND_PITCH_ENRICHMENT_CONCURRENCY,
39040termLimit: options.publicLookupTermLimit,
39041jpdbPublicLookup: options.jpdbPublicLookup,
39042detailLimit: publicJitenDetailLimit
39043});
39044return scope.isCurrent() && usesJapaneseProviders() ? resolved : new Map();
39045}
39046async publicLookupHydratableJitenCards(cards, scope = this.captureTarget()) {
39047if (!scope.isCurrent() || !usesJapaneseProviders() || !cards.length) return new Map();
39048const resolved = await this.dependencies.jitenPublicVocabulary().hydrateCards(cards, { detailLimit: publicJitenDetailLimit(cards.length) }).catch((error) => {
39049this.dependencies.log.warn("Jiten parsed-card hydration failed", { cards: cards.length }, error);
39050return new Map();
39051});
39052return scope.isCurrent() && usesJapaneseProviders() ? resolved : new Map();
39053}
39054async lookupFallbackApiCard(card, options = {}, scope = this.captureTarget()) {
39055if (!scope.isCurrent() || !usesJapaneseProviders()) return void 0;
39056if (!this.dependencies.isJitenApiActive()) return this.publicLookupFallbackCard(card, options, scope);
39057try {
39058const resolved = await this.jitenLookupFallbackCard(card, scope);
39059return scope.isCurrent() ? resolved : void 0;
39060} catch (error) {
39061if (!isMissingProxyTransportError(error)) throw error;
39062if (!scope.isCurrent()) return void 0;
39063return this.publicLookupFallbackCard(card, options, scope);
39064}
39065}
39066textLookupParseOptions() {
39067return textLookupParseOptions(effectiveJpdbApiKey(this.dependencies.getSettings()));
39068}
39069textLookupResultCallbacks(scope) {
39070return {
39071isJpdbBackedCard: (card) => this.dependencies.parser().isJpdbBackedCard(card),
39072parseJapanese: (paragraphs, options) => scope.isCurrent() ? this.dependencies.parser().parse(paragraphs, options) : Promise.resolve(paragraphs.map(() => [])),
39073showCard: (card, sentence, anchor, options) => {
39074if (scope.isCurrent()) this.dependencies.showCard(card, sentence, anchor, options);
39075},
39076showLocalOrFallbackLookupCard: (context, sentence, error) => this.showLocalOrFallbackLookupCard(
39077context,
39078sentence,
39079scope,
39080error
39081),
39082showTokenList: (tokens, selected, anchor, options) => {
39083if (scope.isCurrent()) this.dependencies.showTokenList(tokens, selected, anchor, options);
39084},
39085textLookupParseOptions: () => this.textLookupParseOptions()
39086};
39087}
39088async publicLookupSpellingCard(term) {
39089if (!canSearchPublicLookupCard(this.dependencies.getSettings(), {})) return void 0;
39090const cards = await this.dependencies.jpdbVocabulary().search(term, PUBLIC_FALLBACK_SPELLING_SEARCH_LIMIT).then((result) => result.cards).catch((error) => {
39091this.dependencies.log.warn("Public JPDB fallback search failed", { term }, error);
39092return [];
39093});
39094return cards.find((card) => card.spelling === term);
39095}
39096async publicLookupFallbackCard(card, options = {}, scope = this.captureTarget()) {
39097return (await this.publicLookupFallbackCards([card], options, scope)).get(cardKey(card));
39098}
39099async jitenLookupFallbackCard(card, scope = this.captureTarget()) {
39100if (!scope.isCurrent() || !usesJapaneseProviders()) return void 0;
39101const terms = fallbackLookupTermsForCard(card);
39102const cards = await batchJitenFallbackCards(terms, (parseTerms) => this.dependencies.jiten().parse(parseTerms));
39103if (!scope.isCurrent()) return void 0;
39104for (const term of terms) {
39105const candidate = cards.get(normalizedJitenLookupKey(term));
39106if (candidate) return candidate;
39107}
39108return void 0;
39109}
39110async showLocalLookupCard(context, sentence, scope) {
39111const entries2 = await this.dependencies.lookupLocalEntries(context.selected);
39112if (!scope.isCurrent() || !entries2.length) return false;
39113this.dependencies.showCard(
39114this.dependencies.parser().localCardFromEntry(entries2[0], scope.target),
39115sentence,
39116context.anchor,
39117textLookupCardOptions(context)
39118);
39119return true;
39120}
39121async showLocalOrFallbackLookupCard(context, sentence, scope, error) {
39122if (!scope.isCurrent()) return;
39123if (await this.showLocalLookupCard(context, sentence, scope)) return;
39124if (!scope.isCurrent()) return;
39125if (error) {
39126this.dependencies.toast(userFacingErrorText(
39127this.dependencies.getSettings().interfaceLanguage,
39128"jpdbLookupFailed",
39129error
39130));
39131}
39132this.dependencies.showCard(
39133this.dependencies.parser().fallbackCardFromText(context.selected, scope.target),
39134sentence,
39135context.anchor,
39136textLookupCardOptions(context)
39137);
39138}
39139}
39140function currentFullscreenElement() {
39141const fullscreenDocument = document;
39142return document.fullscreenElement ?? fullscreenDocument.webkitFullscreenElement ?? fullscreenDocument.mozFullScreenElement ?? fullscreenDocument.msFullscreenElement ?? null;
39143}
39144const READER_ROOT_GESTURE_EVENTS = ["touchstart", "touchend", "pointerdown", "pointerup", "mousedown", "mouseup", "click"];
39145const READER_ROOT_SELECTOR = "[data-jpdb-reader-root]";
39146const MIRROR_STALE_SCAN_MIN_INTERVAL_MS = 2500;
39147const VISIBLE_AUTO_SCAN_WORK_VERDICT_TTL_MS = 1500;
39148function eventTargetsReaderRoot(event) {
39149return Boolean(event.target?.closest?.(READER_ROOT_SELECTOR));
39150}
39151function pointOverReaderRoot(event) {
39152const touch = event.changedTouches?.[0] ?? event.touches?.[0];
39153const x = touch ? touch.clientX : event.clientX;
39154const y = touch ? touch.clientY : event.clientY;
39155if (typeof x !== "number" || typeof y !== "number") return false;
39156return Boolean(document.elementFromPoint?.(x, y)?.closest?.(READER_ROOT_SELECTOR));
39157}
39158function readerRootGestureLeaks(event) {
39159return !eventTargetsReaderRoot(event) && pointOverReaderRoot(event);
39160}
39161const READER_ROOT_SCROLL_BODY_SELECTOR = ".jpdb-reader-settings-scroll, .jpdb-reader-popover-body, .jpdb-reader-onboarding";
39162function readerScrollBodyForEvent(event) {
39163return event.target?.closest?.(READER_ROOT_SCROLL_BODY_SELECTOR) ?? null;
39164}
39165const READER_INTERACTIVE_CONTROL_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
39166function eventTargetsInteractiveControl(event) {
39167return Boolean(event.target?.closest?.(READER_INTERACTIVE_CONTROL_SELECTOR));
39168}
39169function knownStateBackfillSurface(word) {
39170const surface = (word.dataset.expression || readerWordSurfaceText(word)).trim();
39171return surface && isTargetLanguageText(surface) ? surface : "";
39172}
39173function knownStateBackfillCardForSurface(surface, tokens) {
39174const card = (tokens.find((token) => token.card.spelling === surface) ?? tokens.find((token) => token.start === 0) ?? tokens[0])?.card;
39175if (!card || card.provisionalState) return null;
39176const wordId = card.jitenWordId ?? card.vid;
39177return Number.isInteger(wordId) && wordId > 0 ? card : null;
39178}
39179function manualScrollReaderBody(body, deltaY) {
39180const maxTop = body.scrollHeight - body.clientHeight;
39181if (maxTop <= 0 || !deltaY) return false;
39182body.scrollTop = Math.max(0, Math.min(maxTop, body.scrollTop + deltaY));
39183return true;
39184}
39185const HOST_THEME_ENFORCE_STEPS = 12;
39186const HOST_THEME_ENFORCE_STEP_MS = 200;
39187const MINING_PAUSE_REASSERT_WINDOW_MS = 2500;
39188const BUNPRO_WORD_STATE_WARMUP_DELAY_MS = 1500;
39189const KNOWN_STATE_BACKFILL_DELAY_MS = 2e3;
39190const KNOWN_STATE_BACKFILL_IDLE_TIMEOUT_MS = 5e3;
39191const KNOWN_STATE_BACKFILL_BATCH_LIMIT = 60;
39192const KNOWN_STATE_BACKFILL_BACKOFF_MS = 6e4;
39193const LINK_PRESS_LOOKUP_MS = 450;
39194const SUBTITLE_HOVER_MINING_RESUME_GRACE_MS = 520;
39195const HOVER_POPOVER_RESIZE_STICKY_MS = 4e3;
39196const HOVER_WORD_HOST_CONTROL_SELECTOR = 'button,[role="button"],a[href],[aria-controls],[aria-expanded]';
39197const HOVER_READER_WORD_GEOMETRY_SCOPE_SELECTOR = [
39198".textBox",
39199".ocr-line",
39200".markdown",
39201".markdown-body",
39202".markdown-content",
39203".message",
39204".message-body",
39205".message-content",
39206".messageContent",
39207".chat-message",
39208".conversation-turn",
39209".model-response",
39210".model-response-text",
39211".response-content",
39212".lesson-canvas-clipper",
39213"p",
39214"li",
39215"blockquote",
39216"td",
39217"th",
39218"article",
39219"main",
39220"[data-jpdb-reader-root]",
39221'[role="article"]',
39222"[data-message-author-role]",
39223"[data-message-id]",
39224'[data-testid*="conversation-turn" i]',
39225'[data-testid*="chat-message" i]',
39226'[data-testid*="message-content" i]',
39227'[data-testid*="message-bubble" i]',
39228'[data-test-id*="chat-message" i]',
39229'[data-test-id*="message-content" i]',
39230"a[href]",
39231"button",
39232"summary",
39233'[role="link"]',
39234'[role="button"]',
39235'[role="tab"]',
39236'[role="menuitem"]'
39237].join(",");
39238const JPDB_REVIEW_EXAMPLES_VISIBLE_STORAGE_KEY = "yomu:jpdb-review-examples-visible:v1";
39239const REVIEW_PAGE_TARGET_SETTLE_MS = 20;
39240const READER_POINTER_SURFACE_SELECTOR = [
39241".jpdb-reader-popover",
39242".jpdb-reader-settings",
39243".jpdb-subtitle-player",
39244".jpdb-subtitle-list",
39245".jpdb-ocr-layer",
39246"[data-jpdb-reader-root]"
39247].join(",");
39248const TOKEN_LIST_POPOVER_CONTROL_SELECTOR = [
39249".jpdb-reader-popover button[data-token-choice]",
39250".jpdb-reader-popover [data-action]",
39251".jpdb-reader-popover a.jpdb-reader-pill",
39252".jpdb-reader-popover .jpdb-reader-action-pill"
39253].join(",");
39254const NATIVE_CAPTION_SELECTION_SURFACE_SELECTOR = [
39255".ytp-caption-segment",
39256".caption-window",
39257".caption-visual-line",
39258".captions-text",
39259'[data-purpose="captions-text"]'
39260].join(", ");
39261const VIDEO_LOOKUP_ANCHOR_SELECTOR = [
39262SUBTITLE_SURFACE_SELECTOR,
39263NATIVE_CAPTION_SELECTION_SURFACE_SELECTOR
39264].join(", ");
39265const PLAIN_SUBTITLE_HOVER_PAUSE_SELECTOR = [
39266".jpdb-subtitle-primary",
39267".jpdb-subtitle-secondary",
39268".jpdb-subtitle-row-text",
39269".jpdb-subtitle-row-secondary",
39270".asbplayer-subtitles-container-bottom",
39271".jpdb-reader-subtitle-surface",
39272NATIVE_CAPTION_SELECTION_SURFACE_SELECTOR
39273].join(", ");
39274function createNoopImageOcrController() {
39275const noop2 = () => void 0;
39276return {
39277init: noop2,
39278refresh: noop2,
39279destroy: noop2,
39280scanVisible: noop2,
39281refreshForModeChange: noop2,
39282pinLineForElement: noop2,
39283unpinLineForElement: noop2,
39284retainLineForLookup: () => void 0,
39285captureSourceImageForElement: () => void 0,
39286reconcileRenderedWordVocabulary: noop2
39287};
39288}
39289function ocrModeToastKey(mode) {
39290if (mode === "auto") return "ocrModeAutoToast";
39291if (mode === "manual") return "ocrModeManualToast";
39292return "ocrModeOffToast";
39293}
39294function noopKanjiPracticeDoodle() {
39295const noop2 = () => void 0;
39296return { reassess: noop2, clear: noop2 };
39297}
39298const READER_SURFACE_INTERACTIVE_SELECTOR = [
39299"button",
39300"a[href]",
39301"input",
39302"select",
39303"textarea",
39304"summary",
39305'[role="button"]',
39306'[role="checkbox"]',
39307'[role="switch"]',
39308'[role="tab"]',
39309'[role="menuitem"]',
39310'[role="slider"]',
39311'[contenteditable=""]',
39312'[contenteditable="true"]',
39313'[contenteditable="plaintext-only"]',
39314"[data-action]",
39315"[data-immersion-action]",
39316"[data-yomu-immersion-action]",
39317".jpdb-reader-word",
39318".jpdb-reader-popover"
39319].join(",");
39320const CONTENT_OVERLAY_READER_SURFACE_SELECTOR = [
39321".jpdb-ocr-layer",
39322SUBTITLE_SURFACE_SELECTOR,
39323".yomu-jpdb-page-addon",
39324".jpdb-reader-toast"
39325].join(", ");
39326function isPointerOnInertReaderSurface(element) {
39327const surface = element?.closest(CONTENT_OVERLAY_READER_SURFACE_SELECTOR);
39328if (!surface) return false;
39329const control = element?.closest(READER_SURFACE_INTERACTIVE_SELECTOR);
39330return !(control && surface.contains(control));
39331}
39332const OWNED_MODAL_OUTSIDE_POINTER_TARGET_SELECTOR = [
39333"[data-jpdb-reader-root]:not(.jpdb-reader-backdrop)",
39334".jpdb-ocr-layer",
39335".jpdb-subtitle-player",
39336".jpdb-subtitle-list",
39337".jpdb-reader-toast"
39338].join(",");
39339const REVIEW_MODAL_OUTSIDE_POINTER_TARGET_SELECTOR = [
39340".review-reveal",
39341".answer-box",
39342".review-hidden",
39343'form[action*="/review"]',
39344'button[name="r"]',
39345'input[name="r"]'
39346].join(",");
39347function keepsModalPopoverForOwnedSurface(element) {
39348if (isPointerOnInertReaderSurface(element)) return false;
39349return Boolean(element?.closest(OWNED_MODAL_OUTSIDE_POINTER_TARGET_SELECTOR) || element?.closest(REVIEW_MODAL_OUTSIDE_POINTER_TARGET_SELECTOR));
39350}
39351function fullscreenPopoverMountParent(anchor) {
39352const fullscreenElement = currentFullscreenElement();
39353if (!(fullscreenElement instanceof HTMLElement) || fullscreenElement instanceof HTMLVideoElement) return void 0;
39354if (anchor && fullscreenElement.contains(anchor)) return fullscreenElement;
39355return void 0;
39356}
39357function isJsdomRuntime() {
39358return navigator.userAgent.includes("jsdom");
39359}
39360function firstLocalPitchPattern(resolution) {
39361return resolution.patterns[0] ?? "";
39362}
39363function japaneseSiteLanguageDisabled(previous, next) {
39364return previous.preferJapaneseSiteLanguage && !next.preferJapaneseSiteLanguage;
39365}
39366function pageAddonKeysMatch(expected, mounted) {
39367if (expected === mounted) return true;
39368const expectedParts = expected.split(":");
39369const mountedParts = mounted.split(":");
39370if (expectedParts.length !== mountedParts.length || expectedParts[0] !== mountedParts[0] || expectedParts[1] !== mountedParts[1]) return false;
39371if (expectedParts.length < 3) return true;
39372const [, spelling, expectedReading] = expectedParts;
39373return expectedReading === spelling;
39374}
39375class HoverWordOwnership {
39376constructor(queries) {
39377this.queries = queries;
39378}
39379hostControl(word) {
39380return word.closest(HOVER_WORD_HOST_CONTROL_SELECTOR);
39381}
39382isActive(word, position, options = {}) {
39383if (this.hasCssHover(word, options.ignoreCssHover)) return true;
39384return this.pointerOwnsWord(word, position, options);
39385}
39386pointerOwnsWord(word, position, options) {
39387if (!position) return false;
39388const target = document.elementFromPoint(position.x, position.y);
39389const renderedHover = this.renderedHoverAtPointer(
39390word,
39391target,
39392position,
39393Boolean(options.ignorePointerPosition)
39394);
39395if (renderedHover !== void 0) return renderedHover;
39396return this.hasLooseContainment(word, target, Boolean(options.ignorePointerPosition));
39397}
39398hasCssHover(word, ignoreCssHover = false) {
39399if (ignoreCssHover) return false;
39400return word.matches(":hover") || Boolean(this.hostControl(word)?.matches(":hover"));
39401}
39402renderedHoverAtPointer(word, target, position, ignorePointerPosition) {
39403if (!(target instanceof Element)) return void 0;
39404const ocrLineHover = this.ocrLineHoverAtPointer(word, target, position, ignorePointerPosition);
39405if (ocrLineHover !== void 0) return ocrLineHover;
39406return this.matchesExactWordGeometry(word, target, position) || void 0;
39407}
39408ocrLineHoverAtPointer(word, target, position, ignorePointerPosition) {
39409const line = word.closest(".jpdb-ocr-line");
39410if (ignorePointerPosition || !line?.contains(target)) return void 0;
39411const pointedWord = this.queries.ocrLineWordForPointer(target, position.x, position.y);
39412return pointedWord ? pointedWord === word : true;
39413}
39414matchesExactWordGeometry(word, target, position) {
39415return this.queries.wordFromPointStack(position.x, position.y) === word || this.queries.ocrLineWordForPointer(target, position.x, position.y) === word || this.queries.wordFromRenderedGeometry(target, position.x, position.y) === word;
39416}
39417hasLooseContainment(word, target, ignorePointerPosition) {
39418if (ignorePointerPosition || !target) return false;
39419return target === word || word.contains(target);
39420}
39421}
39422const log$1 = Logger.scope("DeferredPublicJitenReadings");
39423const FOREGROUND_WAIT_MS = 1200;
39424const MAX_ATTEMPTS_PER_ID = 2;
39425const PER_URL_BUDGET = DEFERRED_PUBLIC_PITCH_PER_URL_CAP;
39426const BACKGROUND_PER_URL_BUDGET = Math.max(0, PER_URL_BUDGET - PITCH_ENRICHMENT_LIMIT);
39427const VISIBLE_REFILL_INSPECTION_LIMIT = 64;
39428class DeferredPublicJitenReadingCoordinator {
39429constructor(dependencies) {
39430this.dependencies = dependencies;
39431}
39432queue = [];
39433work = new Map();
39434requestAttempts = 0;
39435backgroundRequestAttempts = 0;
39436visibleRefillPending = false;
39437generation = 0;
39438drainPromise;
39439retryTimer;
39440href = location.href;
39441admittedKeys = new Set();
39442resolvedCards = new Map();
39443visibleRefillCursor = 0;
39444visibleRefillRemaining = 0;
39445async hydrate(tokens, scope, href, foreground) {
39446if (!this.dependencies.shouldEnrich()) return;
39447this.resetIfNeeded();
39448if (!this.targetIsCurrent(scope, href)) return;
39449await this.applyCachedReadings(tokens, scope, href);
39450const candidates = tokens.filter((token) => this.needsHydration(token));
39451if (!candidates.length || !this.targetIsCurrent(scope, href)) return;
39452this.admitCandidates(candidates, foreground, scope, href);
39453if (foreground) this.promoteTokens(candidates);
39454if (!foreground) {
39455this.scheduleDrain();
39456return;
39457}
39458await this.awaitForegroundDrain(candidates, scope, href);
39459if (this.queue.length || this.visibleRefillPending) this.scheduleDrain();
39460}
39461needsHydration(token) {
39462if (!isHydratablePublicJitenCard(token.card) || token.card.reading.trim()) return false;
39463const surface = (token.sentence?.slice(token.start, token.end) || token.card.spelling).trim();
39464return Boolean(surface) && [...surface].some(isKanjiCharacter);
39465}
39466resetIfNeeded() {
39467if (this.href === location.href) return;
39468this.clear({ resetUrlBudget: true });
39469this.href = location.href;
39470}
39471clear(options = {}) {
39472window.clearTimeout(this.retryTimer);
39473this.retryTimer = void 0;
39474const unfinishedKeys = [...this.work.keys()];
39475this.queue = [];
39476this.work.clear();
39477if (options.resetUrlBudget) {
39478this.admittedKeys.clear();
39479this.resolvedCards.clear();
39480this.requestAttempts = 0;
39481this.backgroundRequestAttempts = 0;
39482} else {
39483unfinishedKeys.forEach((key) => this.admittedKeys.delete(key));
39484}
39485this.stopVisibleRefill();
39486this.generation += 1;
39487}
39488queueToken(token, urgent = false) {
39489if (this.dependencies.hasUnresolvedFallback(fallbackResolutionCacheKey(token))) return "retained";
39490const key = parsedCardHydrationKey(token.card);
39491const existing = this.work.get(key);
39492if (existing) {
39493existing.tokens.add(token);
39494if (urgent) existing.urgent = true;
39495return "retained";
39496}
39497if (this.admittedKeys.has(key)) return "retained";
39498const admissionLimit = urgent ? PER_URL_BUDGET : BACKGROUND_PER_URL_BUDGET;
39499if (this.admittedKeys.size >= admissionLimit) return "url-budget";
39500if (this.work.size >= DEFERRED_PUBLIC_PITCH_PER_URL_CAP) return "live-capacity";
39501this.admittedKeys.add(key);
39502this.work.set(key, { card: token.card, tokens: new Set([token]), attempts: 0, urgent });
39503this.queue.push(key);
39504return "queued";
39505}
39506promoteTokens(tokens) {
39507const promoted = [];
39508const promotedKeys = new Set();
39509const queuedKeys = new Set(this.queue);
39510for (const token of tokens) {
39511const key = parsedCardHydrationKey(token.card);
39512const queuedWork = this.work.get(key);
39513if (promotedKeys.has(key) || !queuedWork) continue;
39514queuedWork.urgent = true;
39515if (!queuedKeys.has(key)) continue;
39516promotedKeys.add(key);
39517promoted.push(key);
39518}
39519if (!promoted.length) return;
39520this.queue = [...promoted, ...this.queue.filter((key) => !promotedKeys.has(key))];
39521}
39522drain(options = {}) {
39523const previous = this.drainPromise;
39524const drain = (previous ? previous.catch(() => void 0) : Promise.resolve()).then(async () => {
39525await this.run({ ...options, maxBatches: options.maxBatches ?? 1 });
39526if (this.queue.length || this.visibleRefillPending) this.scheduleDrain();
39527});
39528this.drainPromise = drain;
39529void drain.finally(() => {
39530if (this.drainPromise === drain) this.drainPromise = void 0;
39531}).catch(() => void 0);
39532return drain;
39533}
39534async run(options) {
39535let completedBatches = 0;
39536while (this.shouldContinue(options)) {
39537const readiness = options.foreground ? this.prepareForegroundQueue() : await this.prepareBackgroundQueue();
39538if (readiness === "stop") return;
39539const works = this.claimNextBatch();
39540if (!works.length) continue;
39541completedBatches += 1;
39542const disposition = await this.resolveBatch(works, options);
39543if (disposition === "stop") return;
39544if (disposition === "continue") continue;
39545this.refillAfterBatch(options);
39546if (completedBatches >= (options.maxBatches ?? Number.POSITIVE_INFINITY)) return;
39547}
39548}
39549refillFromVisibleWords() {
39550let reachedCapacity = false;
39551const words = this.sparseRenderedWords();
39552if (!words.length) {
39553this.stopVisibleRefill();
39554return;
39555}
39556this.normalizeRefillWindow(words.length);
39557const inspectionLimit = Math.min(VISIBLE_REFILL_INSPECTION_LIMIT, this.visibleRefillRemaining);
39558let inspected = 0;
39559let consumed = 0;
39560while (inspected < inspectionLimit) {
39561const word = words[(this.visibleRefillCursor + consumed) % words.length];
39562inspected += 1;
39563if (!this.isViewportVisible(word)) {
39564consumed += 1;
39565continue;
39566}
39567const token = this.dependencies.renderedWordToken(word) ?? this.tokenFromRenderedWord(word);
39568if (!token || !this.needsHydration(token)) {
39569consumed += 1;
39570continue;
39571}
39572const queued = this.queueToken(token);
39573if (queued === "url-budget") {
39574this.stopVisibleRefill();
39575return;
39576}
39577if (queued === "live-capacity") {
39578reachedCapacity = true;
39579break;
39580}
39581consumed += 1;
39582}
39583this.advanceRefillWindow(words.length, consumed, reachedCapacity);
39584}
39585shouldQueueResolvedPublicPitch(card, publicLookup) {
39586return this.dependencies.showPitchAccent() && !cardHasContextPitch(card) && publicLookup;
39587}
39588targetIsCurrent(scope, href) {
39589return !this.dependencies.isDestroyed() && location.href === href && scope.isCurrent();
39590}
39591async applyCachedReadings(tokens, scope, href) {
39592for (const token of tokens) {
39593if (!this.needsHydration(token)) continue;
39594const cached = this.resolvedCards.get(parsedCardHydrationKey(token.card));
39595if (!cached?.reading.trim()) continue;
39596if (!this.targetIsCurrent(scope, href)) return;
39597const pitchClass = getPitchClass(cached.pitchAccent, cached.reading || cached.spelling) || "unknown";
39598await this.dependencies.applyResolvedCard(token, token.card, cached, pitchClass);
39599if (!this.targetIsCurrent(scope, href)) return;
39600this.dependencies.queueSubtitleRefresh(token.sentence);
39601}
39602}
39603admitCandidates(candidates, foreground, scope, href) {
39604let urlBudgetExhausted = false;
39605for (const token of candidates) {
39606if (!this.targetIsCurrent(scope, href)) return;
39607const queued = this.queueToken(token, foreground);
39608if (queued === "live-capacity") this.visibleRefillPending = true;
39609if (queued === "url-budget") urlBudgetExhausted = true;
39610}
39611if (urlBudgetExhausted) this.stopVisibleRefill();
39612}
39613async awaitForegroundDrain(candidates, scope, href) {
39614const generation = this.generation;
39615const foregroundDrain = this.drain({ foreground: true, maxBatches: 1 });
39616const timeoutMessage = "Foreground public Jiten reading hydration timed out.";
39617let timedOut = false;
39618await promiseWithTimeout(foregroundDrain, FOREGROUND_WAIT_MS, timeoutMessage).cat
39618ch((error) => {
39619timedOut = error instanceof Error && error.message === timeoutMessage;
39620if (!timedOut) log$1.warn("Foreground public Jiten reading hydration failed", error);
39621});
39622if (!timedOut) return;
39623void foregroundDrain.then(() => {
39624if (!this.targetIsCurrent(scope, href) || generation !== this.generation) return;
39625const pitchTokens = candidates.filter((token) => !this.needsHydration(token) && this.shouldQueueResolvedPublicPitch(token.card, true));
39626if (pitchTokens.length) this.dependencies.scheduleDeferredPitch(pitchTokens);
39627}).catch((error) => log$1.warn("Timed-out public Jiten reading continuation failed", error));
39628}
39629stopVisibleRefill() {
39630this.visibleRefillPending = false;
39631this.visibleRefillCursor = 0;
39632this.visibleRefillRemaining = 0;
39633}
39634attemptAvailability(work) {
39635if (work.attempts >= MAX_ATTEMPTS_PER_ID) return "attempt-limit";
39636if (this.requestAttempts >= PER_URL_BUDGET) return "url-budget";
39637if (!work.urgent && this.backgroundRequestAttempts >= BACKGROUND_PER_URL_BUDGET) {
39638return "background-budget";
39639}
39640return null;
39641}
39642claimAttempt(work) {
39643const unavailable = this.attemptAvailability(work);
39644if (unavailable) return unavailable;
39645work.attempts += 1;
39646this.requestAttempts += 1;
39647if (!work.urgent) this.backgroundRequestAttempts += 1;
39648return "claimed";
39649}
39650scheduleDrain(delayMs = 0) {
39651if (this.dependencies.isDestroyed() || !this.dependencies.shouldEnrich()) return;
39652if (this.retryTimer !== void 0) return;
39653this.retryTimer = window.setTimeout(() => {
39654this.retryTimer = void 0;
39655void this.drain().catch((error) => log$1.warn("Deferred public Jiten reading hydration failed", error));
39656}, Math.max(0, delayMs));
39657}
39658shouldContinue(options) {
39659return !this.dependencies.isDestroyed() && this.dependencies.shouldEnrich() && (this.queue.length > 0 || !options.foreground && this.visibleRefillPending);
39660}
39661prepareForegroundQueue() {
39662this.resetIfNeeded();
39663if (!this.queue.length) return "stop";
39664return this.backoffReadiness();
39665}
39666async prepareBackgroundQueue() {
39667this.resetIfNeeded();
39668let yieldedForRefill = false;
39669if (!this.queue.length) {
39670await this.dependencies.waitForIdle(DEFERRED_PUBLIC_PITCH_ENRICHMENT_IDLE_TIMEOUT_MS);
39671yieldedForRefill = true;
39672this.refillFromVisibleWords();
39673}
39674if (!this.queue.length) return "stop";
39675if (!yieldedForRefill) {
39676await this.dependencies.waitForIdle(DEFERRED_PUBLIC_PITCH_ENRICHMENT_IDLE_TIMEOUT_MS);
39677}
39678if (this.dependencies.shouldPauseBackground()) {
39679this.scheduleDrain(DEFERRED_PUBLIC_PITCH_HOVER_PAUSE_MS);
39680return "stop";
39681}
39682return this.backoffReadiness();
39683}
39684backoffReadiness() {
39685const backoffMs = publicJitenBackoffRemainingMs();
39686if (backoffMs <= 0) return "ready";
39687this.scheduleDrain(Math.min(backoffMs, DEFERRED_PUBLIC_PITCH_BACKOFF_WAIT_MS));
39688return "stop";
39689}
39690claimNextBatch() {
39691const works = [];
39692while (works.length < PITCH_ENRICHMENT_LIMIT && this.queue.length) {
39693const key = this.queue.shift();
39694const queuedWork = this.work.get(key);
39695if (!queuedWork) continue;
39696if (![...queuedWork.tokens].some((token) => this.needsHydration(token))) {
39697this.work.delete(key);
39698continue;
39699}
39700const attempt2 = this.claimAttempt(queuedWork);
39701if (attempt2 === "claimed") {
39702works.push([key, queuedWork]);
39703continue;
39704}
39705if (attempt2 === "background-budget" || attempt2 === "url-budget") this.stopVisibleRefill();
39706if (queuedWork.attempts > 0) {
39707queuedWork.tokens.forEach((token) => {
39708this.dependencies.rememberUnresolvedFallback(fallbackResolutionCacheKey(token));
39709});
39710}
39711this.work.delete(key);
39712}
39713return works;
39714}
39715async resolveBatch(works, options) {
39716const generation = this.generation;
39717const href = location.href;
39718const scope = this.dependencies.captureTarget();
39719const resolved = await this.dependencies.lookupCards(works.map(([, work]) => work.card), scope);
39720if (this.dependencies.isDestroyed()) return "stop";
39721if (generation !== this.generation) return "continue";
39722if (location.href !== href) {
39723this.resetIfNeeded();
39724return "continue";
39725}
39726if (!scope.isCurrent()) {
39727this.clear();
39728return "continue";
39729}
39730await this.applyResolvedBatch(works, resolved, options);
39731return "applied";
39732}
39733async applyResolvedBatch(works, resolved, options) {
39734const cardsToCache = [];
39735const resolvedPitchTokens = [];
39736const retryAfterBackoff = publicJitenBackoffRemainingMs() > 0;
39737for (const [key, queuedWork] of works) {
39738const card = resolved.get(parsedCardHydrationKey(queuedWork.card));
39739if (!card?.reading.trim()) {
39740this.requeueOrDiscardMissingCard(key, queuedWork, retryAfterBackoff);
39741continue;
39742}
39743const hydratedTokens = await this.applyResolvedCard(key, queuedWork, card);
39744cardsToCache.push(card);
39745if (!options.foreground && this.shouldQueueResolvedPublicPitch(card, true)) {
39746resolvedPitchTokens.push(...hydratedTokens);
39747}
39748}
39749if (cardsToCache.length) this.dependencies.cacheCards(cardsToCache);
39750if (resolvedPitchTokens.length) this.dependencies.scheduleDeferredPitch(resolvedPitchTokens);
39751}
39752requeueOrDiscardMissingCard(key, queuedWork, retryAfterBackoff) {
39753if (retryAfterBackoff && !this.attemptAvailability(queuedWork)) {
39754if (queuedWork.urgent) this.queue.unshift(key);
39755else this.queue.push(key);
39756return;
39757}
39758queuedWork.tokens.forEach((token) => {
39759this.dependencies.rememberUnresolvedFallback(fallbackResolutionCacheKey(token));
39760});
39761this.work.delete(key);
39762}
39763async applyResolvedCard(key, queuedWork, card) {
39764queuedWork.tokens.forEach((token) => {
39765this.dependencies.forgetUnresolvedFallback(fallbackResolutionCacheKey(token));
39766});
39767this.resolvedCards.set(key, card);
39768const hydratedTokens = [];
39769for (const token of queuedWork.tokens) {
39770if (!this.needsHydration(token)) continue;
39771const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown";
39772await this.dependencies.applyResolvedCard(token, token.card, card, pitchClass);
39773this.dependencies.queueSubtitleRefresh(token.sentence);
39774hydratedTokens.push(token);
39775}
39776this.work.delete(key);
39777return hydratedTokens;
39778}
39779refillAfterBatch(options) {
39780if (!options.foreground && this.visibleRefillPending && this.work.size < DEFERRED_PUBLIC_PITCH_PER_URL_CAP) {
39781this.refillFromVisibleWords();
39782}
39783}
39784sparseRenderedWords() {
39785const words = Array.from(this.dependencies.renderedAnnotationRoots()).flatMap((root) => renderedWordsInRoot(root));
39786return [...new Set(words)].filter(isSparseRenderedJitenWord);
39787}
39788normalizeRefillWindow(wordCount) {
39789if (this.visibleRefillCursor >= wordCount) this.visibleRefillCursor = 0;
39790if (this.visibleRefillRemaining <= 0 || this.visibleRefillRemaining > wordCount) {
39791this.visibleRefillRemaining = wordCount;
39792}
39793}
39794advanceRefillWindow(wordCount, consumed, reachedCapacity) {
39795this.visibleRefillCursor = (this.visibleRefillCursor + consumed) % wordCount;
39796this.visibleRefillRemaining = Math.max(0, this.visibleRefillRemaining - consumed);
39797const inspectionPassIncomplete = this.visibleRefillRemaining > 0;
39798this.visibleRefillPending = reachedCapacity || inspectionPassIncomplete;
39799if (!this.visibleRefillPending) this.visibleRefillCursor = 0;
39800}
39801isViewportVisible(word) {
39802if (!word.isConnected) return false;
39803const rect = word.getBoundingClientRect();
39804return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight;
39805}
39806tokenFromRenderedWord(word) {
39807const card = jitenWordCardForMassReview(word);
39808if (!Number.isFinite(card.vid) || !Number.isFinite(card.sid)) return null;
39809const surface = readerWordSurfaceText(word);
39810const recordedStart = Number(word.dataset.tokenStart);
39811const recordedEnd = Number(word.dataset.tokenEnd);
39812const hasRecordedSpan = Number.isInteger(recordedStart) && Number.isInteger(recordedEnd) && recordedStart >= 0 && recordedEnd > recordedStart;
39813const start = hasRecordedSpan ? recordedStart : 0;
39814const end = hasRecordedSpan ? recordedEnd : surface.length;
39815return {
39816card,
39817start,
39818end,
39819length: end - start,
39820rubies: [],
39821pitchClass: word.dataset.pitchClass ?? "",
39822sentence: word.dataset.sentence || surface
39823};
39824}
39825}
39826function isSparseRenderedJitenWord(word) {
39827return [renderedWordPrivateValue(word, "cardSource") === "jiten", !word.dataset.reading].every(Boolean);
39828}
39829async function resolvePublicFallbackPitchTokens(tokens, options, dependencies) {
39830const scope = dependencies.captureTarget();
39831const queuedTokens = [];
39832const fallbackGroups = new Map();
39833const jitenGroups = new Map();
39834for (const token of tokens) {
39835if (token.card.source === "fallback") {
39836addPublicFallbackGroup(token, fallbackGroups, options, dependencies);
39837continue;
39838}
39839if (isHydratablePublicJitenCard(token.card)) {
39840addPublicFallbackGroup(token, jitenGroups, options, dependencies);
39841continue;
39842}
39843queuedTokens.push(token);
39844}
39845if (!fallbackGroups.size && !jitenGroups.size) return queuedTokens;
39846const resolved = await resolvedFallbackAndJitenCards(fallbackGroups, jitenGroups, options, scope, dependencies);
39847if (!scope.isCurrent()) return [];
39848const cardsToCache = [];
39849const localOnlyTokens = [];
39850for (const [key, group] of [...fallbackGroups, ...jitenGroups]) {
39851const card = resolved.get(key);
39852if (!card || card.source === "fallback") {
39853dependencies.noteFallbackMiss(key, group.tokens);
39854if (dependencies.showPitchAccent() && options.jpdbPublicLookup !== false) {
39855queuedTokens.push(...group.tokens);
39856} else {
39857localOnlyTokens.push(...group.tokens);
39858}
39859continue;
39860}
39861cardsToCache.push(card);
39862applyResolvedFallbackGroup(group, card, queuedTokens, options, dependencies);
39863}
39864if (cardsToCache.length) dependencies.cacheCards(cardsToCache);
39865if (localOnlyTokens.length) await dependencies.enrichLocalPitch(localOnlyTokens);
39866return queuedTokens;
39867}
39868function addPublicFallbackGroup(token, groups, options, dependencies) {
39869const key = token.card.source === "fallback" ? fallbackVocabularySpanCacheKey(token.card, token) : cardKey(token.card);
39870if (!options.urgent && dependencies.hasUnresolvedFallback(key)) return;
39871const group = groups.get(key) ?? { card: token.card, tokens: [] };
39872group.tokens.push(token);
39873groups.set(key, group);
39874}
39875function fallbackResolutionCacheKey(token) {
39876return token.card.source === "fallback" ? fallbackVocabularySpanCacheKey(token.card, token) : cardKey(token.card);
39877}
39878async function resolvedFallbackAndJitenCards(fallbackGroups, jitenGroups, options, scope, dependencies) {
39879const [fallbackCards, jitenCards] = await Promise.all([
39880fallbackGroups.size ? dependencies.lookupFallbackCards([...fallbackGroups.values()].map((group) => group.card), options, scope) : Promise.resolve(new Map()),
39881jitenGroups.size ? dependencies.lookupJitenCards([...jitenGroups.values()].map((group) => group.card), scope) : Promise.resolve(new Map())
39882]);
39883const resolved = new Map();
39884for (const [key, group] of fallbackGroups) {
39885const card = fallbackCards.get(cardKey(group.card));
39886if (card) resolved.set(key, card);
39887}
39888for (const [key, group] of jitenGroups) {
39889const card = jitenCards.get(parsedCardHydrationKey(group.card));
39890if (card) resolved.set(key, card);
39891}
39892return resolved;
39893}
39894function applyResolvedFallbackGroup(group, card, queuedTokens, options, dependencies) {
39895for (const token of group.tokens) {
39896const fallback = token.card;
39897const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown";
39898if (fallback.source === "fallback") dependencies.rememberResolvedFallback(token, fallback, card);
39899void dependencies.applyResolvedCard(token, fallback, card, pitchClass);
39900if (dependencies.shouldQueueResolvedPublicPitch(card, options.jpdbPublicLookup !== false)) {
39901queuedTokens.push(token);
39902}
39903dependencies.queueSubtitleRefresh(token.sentence);
39904}
39905}
39906const HOST_DARK_CLASS = "dark-mode";
39907const JITEN_THEME_COOKIE = "jiten-theme-mode";
39908function currentThemeHost() {
39909if (isYomuHostedAppUrl(location.href)) return "yomu-hosted";
39910const host = location.hostname;
39911if (host === "jpdb.io" || host.endsWith(".jpdb.io")) return "jpdb";
39912if (host === "jiten.moe" || host.endsWith(".jiten.moe")) return "jiten";
39913return null;
39914}
39915function isThemeSyncHost() {
39916return currentThemeHost() !== null;
39917}
39918function prefersDark() {
39919return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches;
39920}
39921function detectHostTheme() {
39922if (currentThemeHost() === "yomu-hosted") return document.documentElement.classList.contains("dark") ? "dark" : "light";
39923if (document.documentElement.classList.contains(HOST_DARK_CLASS)) return "dark";
39924if (currentThemeHost() === "jiten") {
39925const mode = readJitenThemeCookie();
39926if (mode === "dark" || mode === "light") return mode;
39927return prefersDark() ? "dark" : "light";
39928}
39929return "light";
39930}
39931function applyHostTheme(theme) {
39932const root = document.documentElement;
39933if (currentThemeHost() === "yomu-hosted") {
39934root.classList.toggle("dark", theme === "dark");
39935root.style.colorScheme = theme;
39936return;
39937}
39938root.classList.toggle(HOST_DARK_CLASS, theme === "dark");
39939root.style.colorScheme = theme;
39940if (currentThemeHost() === "jiten") writeJitenThemeCookie(theme);
39941}
39942function jitenThemeCookieMatches(theme) {
39943return currentThemeHost() === "jiten" && readJitenThemeCookie() === theme;
39944}
39945function readJitenThemeCookie() {
39946const match = document.cookie.match(/(?:^|;\s*)jiten-theme-mode=([^;]+)/);
39947return match ? decodeURIComponent(match[1]).trim() : "";
39948}
39949function writeJitenThemeCookie(theme) {
39950document.cookie = `${JITEN_THEME_COOKIE}=${theme}; path=/; max-age=31536000; samesite=lax`;
39951}
39952function observeHostTheme(onChange) {
39953if (typeof MutationObserver !== "function") return () => void 0;
39954const root = document.documentElement;
39955let last = detectHostTheme();
39956const observer = new MutationObserver(() => {
39957const next = detectHostTheme();
39958if (next === last) return;
39959last = next;
39960onChange(next);
39961});
39962observer.observe(root, { attributes: true, attributeFilter: ["class", "style"] });
39963return () => observer.disconnect();
39964}
39965class HostThemeController {
39966constructor(options) {
39967this.options = options;
39968}
39969disposeObserver;
39970enforceTimer;
39971sync(settings2) {
39972if (!isThemeSyncHost()) {
39973this.applyAmbient(settings2);
39974return;
39975}
39976this.observe();
39977window.clearTimeout(this.enforceTimer);
39978this.syncHost(settings2);
39979}
39980refreshAmbient(settings2) {
39981if (!isThemeSyncHost()) this.applyAmbient(settings2);
39982}
39983destroy() {
39984this.disposeObserver?.();
39985window.clearTimeout(this.enforceTimer);
39986}
39987observe() {
39988if (this.disposeObserver) return;
39989this.disposeObserver = observeHostTheme((theme) => this.handleChange(theme));
39990}
39991syncHost(settings2) {
39992if (isYomuHostedPassivePage(location.href)) {
39993this.applyPassive(settings2.theme);
39994return;
39995}
39996this.syncOrdinary(settings2.theme);
39997}
39998syncOrdinary(setting) {
39999if (setting === "auto") this.applyClasses(detectHostTheme());
40000else this.enforce(setting, HOST_THEME_ENFORCE_STEPS);
40001}
40002applyPassive(setting, detected = detectHostTheme()) {
40003const theme = setting === "auto" ? detected : setting;
40004applyHostTheme(theme);
40005this.applyClasses(theme);
40006}
40007syncAuthoritative(settings2) {
40008const hostTheme = detectHostTheme();
40009this.applyClasses(hostTheme, settings2);
40010if (settings2 !== this.options.getSettings() || settings2.theme === "auto" || settings2.theme === hostTheme) return;
40011this.options.adoptTheme(hostTheme);
40012this.options.publishThemeChange();
40013}
40014enforce(theme, remaining) {
40015applyHostTheme(theme);
40016if (remaining <= 0 || this.options.isDestroyed()) return;
40017this.enforceTimer = window.setTimeout(() => this.enforce(theme, remaining - 1), HOST_THEME_ENFORCE_STEP_MS);
40018}
40019applyAmbient(settings2) {
40020if (settings2.theme === "dark" || settings2.theme === "light") return;
40021this.applyClasses(documentBackgroundLooksDark() ? "dark" : "light", settings2);
40022}
40023applyClasses(theme, settings2 = this.options.getSettings()) {
40024const root = document.documentElement;
40025if (root) applyResolvedReaderTheme(settings2, theme, root);
40026}
40027handleChange(hostTheme) {
40028if (this.options.isDestroyed()) return;
40029const setting = this.options.getSettings().theme;
40030if (isYomuHostedPassivePage(location.href)) {
40031this.applyPassive(setting, hostTheme);
40032refreshReaderWordContrast(document);
40033return;
40034}
40035this.applyActiveChange(setting, hostTheme);
40036}
40037applyActiveChange(setting, hostTheme) {
40038if (setting === hostTheme || this.restoreExplicitJitenTheme(setting)) return;
40039if (setting === "auto") {
40040this.applyContrast(hostTheme);
40041return;
40042}
40043const settings2 = this.options.adoptTheme(hostTheme);
40044applyReaderTheme(settings2);
40045refreshReaderWordContrast(document);
40046this.options.publishThemeChange();
40047}
40048restoreExplicitJitenTheme(setting) {
40049if (setting !== "light" && setting !== "dark") return false;
40050if (!jitenThemeCookieMatches(setting)) return false;
40051applyHostTheme(setting);
40052return true;
40053}
40054applyContrast(hostTheme) {
40055this.applyClasses(hostTheme);
40056refreshReaderWordContrast(document);
40057}
40058}
40059const log = Logger.scope("ReaderApp");
40060class ReaderApp {
40061constructor(persistSettings = saveSettings, observesSettingsStorage = true) {
40062this.persistSettings = persistSettings;
40063this.observesSettingsStorage = observesSettingsStorage;
40064configureLogger({ settingsProvider: () => this.settings });
40065}
40066abortController = new AbortController();
40067isDestroyed = false;
40068settings = DEFAULT_SETTINGS;
40069/** Non-persisted policy owned by a deliberate hosted reading surface. */
40070pageOwnedLearningTargetActive = false;
40071targetOwnedCoreInstalled = false;
40072settingsSurface;
40073topLevelTargetLifecycle = new TopLevelTargetLifecycle();
40074hostTheme = new HostThemeController({
40075getSettings: () => this.settings,
40076adoptTheme: (theme) => {
40077this.settings = { ...this.settings, theme };
40078void this.persistSettings(this.settings, { explicitUserChoiceKeys: NO_EXPLICIT_USER_CHOICE });
40079return this.settings;
40080},
40081publishThemeChange: () => this.publishThemeSettingsChange(),
40082isDestroyed: () => this.isDestroyed
40083});
40084themeContrastRefreshFrame;
40085themeContrastRefreshTimer;
40086cardHydrationRenderPasses = new WeakMap();
40087setImmersionTranslationBlurred = (blurred) => {
40088if (this.settings.immersionKitRevealTranslationOnClick === blurred) return;
40089this.settings = {
40090...this.settings,
40091immersionKitRevealTranslationOnClick: blurred
40092};
40093document.querySelectorAll('input[name="immersionKitRevealTranslationOnClick"]').forEach((input) => {
40094input.checked = blurred;
40095});
40096void this.persistSettings(this.settings, { explicitUserChoiceKeys: ["immersionKitRevealTranslationOnClick"] });
40097};
40098jpdb = new CompanionBackedJpdbClient(() => effectiveJpdbApiKey(this.settings), () => this.settings.corsProxyUrl);
40099jiten = new JitenApiClient(() => effectiveJitenApiKey(this.settings), { proxyUrl: () => this.settings.corsProxyUrl });
40100get kanjiCompanion() {
40101return yomuKanjiStudyCompanion();
40102}
40103jpdbKanjiInstance = null;
40104get jpdbKanji() {
40105const companion = this.kanjiCompanion;
40106if (!this.jpdbKanjiInstance && companion) this.jpdbKanjiInstance = new companion.JpdbKanjiClient(() => this.settings.corsProxyUrl);
40107return this.jpdbKanjiInstance;
40108}
40109set jpdbKanji(value) {
40110this.jpdbKanjiInstance = value;
40111}
40112jpdbPublicPitch = new CompanionBackedJpdbPublicPitchClient(() => this.settings.corsProxyUrl);
40113jpdbVocabulary = new CompanionBackedJpdbVocabularyClient(() => this.settings.corsProxyUrl);
40114jitenPublicVocabulary = new CompanionBackedJitenPublicVocabularyClient({ proxyUrl: () => this.settings.corsProxyUrl });
40115kanjiVGInstance = null;
40116get kanjiVG() {
40117const companion = this.kanjiCompanion;
40118if (!this.kanjiVGInstance && companion) this.kanjiVGInstance = new companion.KanjiVGClient();
40119return this.kanjiVGInstance;
40120}
40121set kanjiVG(value) {
40122this.kanjiVGInstance = value;
40123}
40124kanjiOriginInstance = null;
40125get kanjiOrigin() {
40126const companion = this.kanjiCompanion;
40127if (!this.kanjiOriginInstance && companion) this.kanjiOriginInstance = new companion.KanjiOriginClient();
40128return this.kanjiOriginInstance;
40129}
40130set kanjiOrigin(value) {
40131this.kanjiOriginInstance = value;
40132}
40133immersionKitInstance = null;
40134get immersionKit() {
40135const companion = this.kanjiCompanion;
40136if (!this.immersionKitInstance && companion) this.immersionKitInstance = new companion.ImmersionKitClient();
40137return this.immersionKitInstance;
40138}
40139set immersionKit(value) {
40140this.immersionKitInstance = value;
40141}
40142audio = new CompanionBackedAudioPlayer(() => this.settings);
40143anki = new AnkiConnectClient(() => this.settings);
40144bunproCompanion = yomuBunproCompanion();
40145bunpro = this.bunproCompanion ? new this.bunproCompanion.BunproClient({
40146getFrontendToken: () => this.activeBunproFrontendApiToken(),
40147getLegacyApiKey: () => effectiveBunproLegacyApiKey(this.settings),
40148getProxyUrl: () => this.settings.corsProxyUrl
40149}) : null;
40150bunproSrs = this.bunproCompanion && this.bunpro ? this.bunproCompanion.createBunproSrsAdapter(this.bunpro) : null;
40151wanikani = new CompanionBackedWanikaniClient({ getToken: () => this.settings.wanikaniApiToken });
40152wanikaniSrs = createWanikaniSrsAdapter(this.wanikani);
40153wanikaniSources = new CompanionBackedWanikaniSourceController(
40154new CompanionBackedWanikaniLookupClient(this.wanikani),
40155() => this.settings,
40156(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
40157(mount) => {
40158this.repositionActivePopover();
40159const installDefinitionTranslationBehaviors = yomuSettingsSurfaceCompanion()?.installDefinitionTranslationBehaviors;
40160if (!installDefinitionTranslationBehaviors) return;
40161void installDefinitionTranslationBehaviors(mount, this.settings).then(() => this.repositionActivePopover());
40162}
40163);
40164bunproWordStates = this.bunproCompanion && this.bunpro ? new this.bunproCompanion.BunproWordStateStore(this.bunpro) : null;
40165yomuLocalSrs = createYomuLocalSrsAdapter(new LocalYomuSrsRepository());
40166rtkInstance = null;
40167get rtk() {
40168const companion = this.kanjiCompanion;
40169if (!this.rtkInstance && companion) this.rtkInstance = new companion.RtkClient();
40170return this.rtkInstance;
40171}
40172set rtk(value) {
40173this.rtkInstance = value;
40174}
40175dictionaries = createLocalDictionaryStore(() => this.settings.corsProxyUrl, () => this.settings.interfaceLanguage);
40176cardRenderData = new CardRenderDataLoader({
40177getSettings: () => this.settings,
40178dictionaries: this.dictionaries,
40179jpdbPublicPitch: this.jpdbPublicPitch,
40180jpdbVocabulary: this.jpdbVocabulary,
40181anki: this.anki,
40182jpdb: this.jpdb,
40183jiten: this.jiten,
40184bunpro: this.bunpro ?? void 0,
40185isJpdbBackedCard: (card) => this.isJpdbBackedCard(card)
40186});
40187navigation = new PopupNavigationController(() => Boolean(
40188this.activePopover?.isConnected && this.activePopover.querySelector(".jpdb-reader-kanji-display")
40189));
40190dictionarySourceState = new DictionarySourceStateController({
40191getSettings: () => this.settings,
40192onStateChange: () => this.repositionActivePopover()
40193});
40194cardPopoverRenderer = new CardPopoverRenderer({
40195getSettings: () => this.settings,
40196isJpdbBackedCard: (card) => this.isJpdbBackedCard(card),
40197renderWordHistory: (language, trigger) => this.navigation.renderWordHistory(language, trigger),
40198renderWordPills: (card, jpdbUrl, metaEntries, overrideQuery, _trigger, ankiLookup, frequencyRanks) => renderWordPills({
40199card,
40200jpdbUrl,
40201settings: this.settings,
40202metaEntries,
40203overrideQuery,
40204ankiLookup,
40205frequencyRanks,
40206isJpdbBackedCard: (value) => this.isJpdbBackedCard(value),
40207dictionaryLabel: (name) => this.dictionaryLabel(name)
40208}),
40209renderDefinitionSources: (card, entries2, sentence, jpdbVocabularyInfo, jitenVocabularyInfo, bunproDefinitionInfo, extraSections) => this.renderDefinitionSources(card, entries2, sentence, jpdbVocabularyInfo, jitenVocabularyInfo, bunproDefinitionInfo, extraSections),
40210dictionarySourceAttributes: (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
40211dictionaryLabel: (name) => this.dictionaryLabel(name)
40212});
40213dictionaryStyles = createReaderDictionaryStyleController(() => this.settings, (preferences) => this.dictionaries.dictionaryStyleCss(preferences), (error) => log.warn("Dictionary styles unavailable", error));
40214offlineDictionaries = new OfflineDictionarySetupController({
40215dictionaries: this.dictionaries,
40216getSettings: () => this.settings,
40217applySettings: async (settings2) => {
40218this.settings = settings2;
40219await this.persistSettings(settings2, { explicitUserChoiceKeys: NO_EXPLICIT_USER_CHOICE });
40220},
40221notify: (message) => this.toast(message),
40222afterInstalled: async () => {
40223await this.refreshDictionaryStyles();
40224this.scheduleDictionaryRescan();
40225}
40226});
40227studySources = new CompanionBackedStudySourceController({
40228getSettings: () => this.settings,
40229dictionarySourceAttributes: (key) => this.dictionarySourceState.attributes(key),
40230parseJapanese: (paragraphs, options) => this.parseJapanese(paragraphs, options),
40231parsePopoverJapanese: (popover) => this.isJpdbPageAddonRoot(popover) ? this.parseJpdbPageAddonJapanese(popover) : this.parsePopoverJapanese(popover),
40232enrichPitchWords: (tokens) => this.enrichPitchWords(tokens, this.backgroundPitchEnrichmentOptions()),
40233enrichAnkiWords: (tokens, roots) => this.queueAnkiWordEnrichment(tokens, roots ?? [document]),
40234isCurrentPopoverRoot: (root) => this.isCurrentPopoverRoot(root) || this.isJpdbPageAddonRoot(root)
40235});
40236cardActions = new CardActionController({
40237getSettings: () => this.settings,
40238jpdb: this.jpdb,
40239jiten: this.jiten,
40240srsAdapters: {
40241bunpro: this.bunproSrs ?? void 0,
40242wanikani: this.wanikaniSrs,
40243"yomu-local": this.yomuLocalSrs
40244},
40245anki: this.anki,
40246dictionaries: this.dictionaries,
40247isJpdbBackedCard: (card) => this.isJpdbBackedCard(card),
40248resolveMiningContext: (card, sentence) => this.resolveMiningContext(card, sentence),
40249showCard: (card, sentence, anchor, options) => this.showCard(card, sentence, anchor, options),
40250getActivePopoverAnchor: () => this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0,
40251getActivePopoverMode: () => this.activePopoverMode,
40252showSettings: (panel) => this.showSettings(panel),
40253playAudio: (card, options) => this.audioActions.playTermAudio(card, options),
40254playMediaUrl: (audioUrl) => this.audioActions.playMediaUrl(audioUrl),
40255playSentenceAudio: (sentence) => this.audioActions.playSentenceAudio(sentence),
40256playJpdbExampleAudio: (audioIds, fallbackSentence) => this.audioActions.playJpdbExampleAudio(audioIds, fallbackSentence),
40257detectGrammarHints: (sentence) => this.studySources.detectGrammarHints(sentence),
40258parsePopoverJapanese: (popover) => this.parsePopoverJapanese(popover),
40259toast: (message) => this.toast(message),
40260invalidateCardData: () => this.cardRenderData.clear(),
40261onAnkiStatusChanged: (card) => this.handleAnkiStatusChanged(card),
40262onApiCardStateChanged: (card) => {
40263this.applyPublicVocabularyToRenderedWords(card, card);
40264repaintYomuLocalSrsRenderedWords(card, this.renderedAnnotationRoots());
40265}
40266});
40267immersionPopoverInstance = null;
40268get immersionPopover() {
40269const companion = this.kanjiCompanion;
40270const immersionKit = this.immersionKit;
40271if (!this.immersionPopoverInstance && companion && immersionKit) {
40272this.immersionPopoverInstance = new companion.ImmersionPopoverController({
40273getSettings: () => this.settings,
40274client: immersionKit,
40275audio: this.audio,
40276parseJapanese: (paragraphs, options) => this.parseJapanese(paragraphs, options),
40277canParseJapanese: () => this.canParseJapanese(),
40278parsePopoverJapanese: (popover) => this.parsePopoverJapanese(popover),
40279enrichPitchWords: (tokens) => this.enrichPitchWords(tokens, this.backgroundPitchEnrichmentOptions()),
40280enrichAnkiWords: (tokens, roots) => this.queueAnkiWordEnrichment(tokens, roots ?? [document]),
40281repositionPopover: () => this.repositionActivePopover(),
40282setImmersionTranslationBlurred: this.setImmersionTranslationBlurred,
40283toast: (message) => this.toast(message)
40284});
40285}
40286return this.immersionPopoverInstance;
40287}
40288set immersionPopover(value) {
40289this.immersionPopoverInstance = value;
40290}
40291audioActions = new CompanionBackedReaderAudioActions({
40292audio: this.audio,
40293getSettings: () => this.settings,
40294getActivePopover: () => this.activePopover,
40295getHoverLookupGeneration: () => this.hoverLookupGeneration,
40296stopImmersionAudio: () => this.immersionPopover?.stopAudio(),
40297toast: (message) => this.toast(message)
40298});
40299floatingButton = new FloatingButtonController();
40300parser = new ReaderParser({
40301getSettings: () => this.settings,
40302jpdb: this.jpdb,
40303jiten: this.jiten,
40304jitenPublicVocabulary: {
40305parse: (paragraphs, options) => this.jitenPublicVocabulary.parse(paragraphs, options),
40306lookupMany: (terms, options) => this.jitenPublicVocabulary.lookupMany(terms, options)
40307},
40308dictionaries: new Proxy({}, {
40309get: (_target, property) => {
40310const store = this.dictionaries;
40311const value = store[property];
40312return typeof value === "function" ? value.bind(this.dictionaries) : value;
40313},
40314has: (_target, property) => property in this.dictionaries
40315}),
40316yomuLocalSrs: this.yomuLocalSrs
40317});
40318onboarding = this.createOnboardingController();
40319subtitles = this.createSubtitlePlayer();
40320ocr = this.createImageOcrController();
40321youtube = this.createYoutubeFilter();
40322pageScanner = new VisiblePageScanner({
40323getSettings: () => this.settings,
40324parseJapanese: (paragraphs, options) =>
40324 this.parseJapanese(paragraphs, options),
40325pauseMutationObserver: (callback) => this.pauseAutoScanObserver(callback),
40326preloadParsedTokens: (tokens) => this.preloadParsedTokens(tokens),
40327enrichPitchWords: (tokens) => this.enrichPitchWords(tokens, this.backgroundPitchEnrichmentOptions()),
40328enrichAnkiWords: (tokens, roots) => this.queueAnkiWordEnrichment(tokens, roots ?? [document]),
40329beginAnkiWordEnrichment: (tokens) => this.beginAnkiWordEnrichment(tokens),
40330prepareAnkiWordEnrichmentBeforeRender: (tokens) => this.prepareAnkiWordEnrichmentBeforeRender(tokens),
40331prepareSubtitleTokensBeforeRender: (tokens) => this.enrichSubtitleTokensBeforeRender(tokens),
40332reconcileResolvedWordEffects: (tokens, roots) => this.queueResolvedWordEffects(tokens, roots),
40333noteRenderedRoots: (roots) => roots.forEach((root) => this.registerRenderedWordsInRoot(root)),
40334refreshWordContrast: (root) => refreshReaderWordContrast(root),
40335toast: (message) => this.toast(message)
40336});
40337unsubscribeCardStateSignals;
40338unsubscribeSettingsStorageChanges;
40339disposeMokuroOcrToggleWatch;
40340disposeJpdbReviewBridge;
40341factoryReset = createFactoryResetCoordinator({
40342dictionaries: this.dictionaries,
40343isDestroyed: () => this.isDestroyed,
40344getLanguage: () => this.settings.interfaceLanguage,
40345invalidateRuntimeStores: () => this.invalidateRuntimeStoresForFactoryReset(),
40346toast: (message) => this.toast(message),
40347reload: () => location.reload()
40348});
40349settingsDialog;
40350stackedSettingsDialog;
40351activeBunproFrontendApiToken() {
40352return isBunproFrontendCredentialExpired(this.settings) ? "" : effectiveBunproFrontendApiToken(this.settings);
40353}
40354activePopover;
40355activeBackdrop;
40356lookupModal = new (yomuSettingsSurfaceCompanion()).LookupModalAccessibility();
40357lastCard;
40358lastCardSentence;
40359lastAnkiLookup;
40360autoScanTimer;
40361autoScanDeadline = 0;
40362autoScanForced = false;
40363autoScanDebounced = false;
40364autoScanDebounceStartedAt = 0;
40365visibleAutoScanWorkVerdict;
40366lastAutoScanStartedAt = 0;
40367autoScanObserver;
40368documentBodyObserver;
40369observedDocumentBody;
40370documentBodyRecoveryPending = false;
40371disposeShadowRootDiscovery;
40372lastMirrorStaleScanAt = 0;
40373handleNonDestructiveMirrorStale = () => {
40374if (!this.canParseJapanese()) return;
40375const now = Date.now();
40376const baseDelay = visibleAutoScanMutationDelay();
40377const throttled = Math.max(baseDelay, this.lastMirrorStaleScanAt + MIRROR_STALE_SCAN_MIN_INTERVAL_MS - now);
40378this.lastMirrorStaleScanAt = now + throttled;
40379this.scheduleAutoScan(throttled, { force: true });
40380};
40381asbScanTimer;
40382hoverLookupTimer;
40383hoverClose = new HoverCloseController(domHoverCloseTimers, {
40384isHoverPopoverActive: () => this.activePopoverMode === "hover",
40385closeDelayMs: () => this.settings.hoverCloseDelayMs,
40386isHoverContextActive: (query) => this.isHoverContextActive(query),
40387close: () => this.dismiss({
40388suppressHoverTarget: false,
40389deferSubtitleMiningResume: this.shouldDeferSubtitleMiningResumeForHoverClose()
40390})
40391});
40392hoverResizeStickyPointer;
40393hoverResizeStickyExpiry = 0;
40394hoverPendingWord;
40395hoverPendingLookupKey = "";
40396pendingPointerTextLookup;
40397hoverLookupInFlightKey = "";
40398hoverLookupGeneration = 0;
40399activeHoverWord;
40400activeHoverLookupKey = "";
40401releaseActiveOcrLookupLine;
40402ownedModalOcrPin;
40403activePointerTextLookup;
40404suppressedHoverWord;
40405suppressedHoverLookupKey = "";
40406activePopoverMode;
40407subtitleMiningPausedVideo;
40408activePlainSubtitleHoverSurface;
40409miningPauseReassert;
40410subtitleHoverMiningResumeTimer;
40411activePopoverAnchor;
40412activePopoverAnchorRect;
40413keyboardActiveWord;
40414activePopoverPositionLocked = false;
40415activePopoverLockedPosition;
40416activePopoverResizeObserver;
40417nativeTitleGuard = new NativeTitleGuard();
40418lastPointerPosition;
40419hoverWordOwnership = new HoverWordOwnership({
40420wordFromPointStack: (x, y) => this.hoverReaderWordFromPointStack(x, y),
40421ocrLineWordForPointer: (target, x, y) => this.ocrLineWordForPointer(target, x, y),
40422wordFromRenderedGeometry: (target, x, y) => this.readerWordFromRenderedGeometry(
40423target,
40424x,
40425y,
40426(word) => this.canHoverLookupReaderWord(word)
40427)
40428});
40429hoverPopoverPointerPosition;
40430hoverPopoverPointerLatched = false;
40431hoverPointerMoveFrame;
40432pendingHoverPointerMove;
40433popoverRepositionFrame;
40434popoverViewportChangePending = false;
40435settingsPreviewOriginalAccent;
40436settingsPreviewOriginalLanguage;
40437settingsPreviewOriginalTheme;
40438pendingPreferredJapaneseSiteLanguage;
40439lastAutoAudioKey = "";
40440lastAutoAudioAt = 0;
40441lastAutoAudioHoverGeneration;
40442cardRenderRequest = 0;
40443cardLookup = new ReaderCardLookupSession({
40444getSettings: () => this.settings,
40445parser: () => this.parser,
40446jpdbVocabulary: () => this.jpdbVocabulary,
40447jiten: () => this.jiten,
40448jitenPublicVocabulary: () => this.jitenPublicVocabulary,
40449isJitenApiActive: () => this.isJitenApiActive(),
40450lookupLocalEntries: (selected) => this.localLookupEntries(selected),
40451textLookupDisplayState: () => ({
40452activePopoverAnchor: this.activePopoverAnchor,
40453defaultTrigger: this.activeTextLookupTrigger(),
40454hasActivePopover: Boolean(this.activePopover),
40455previousNavigationEntry: (trigger, navigation) => this.textLookupPreviousNavigationEntry(trigger, navigation)
40456}),
40457showCard: (card, sentence, anchor, options) => void this.showCard(card, sentence, anchor, options),
40458showTokenList: (tokens, selected, anchor, options) => this.showTokenList(tokens, selected, anchor, options),
40459toast: (message) => this.toast(message),
40460onTargetChange: () => {
40461this.cardRenderRequest += 1;
40462this.deferredPublicJitenReadings.clear();
40463this.cancelPendingHoverLookup();
40464if (this.activePopover && !this.activePopover.classList.contains("jpdb-reader-settings")) {
40465this.dismiss({ suppressHoverTarget: false });
40466}
40467},
40468log
40469});
40470dictionaryRescanPending = false;
40471visiblePageReparseTimer;
40472jpdbPageEnhanceTimer;
40473jpdbPageEnhanceDeadline = 0;
40474jpdbPageEnhancementGeneration = 0;
40475lastEnhancedHref = "";
40476lastJitenStudyHeadword = "";
40477lastJitenImmersionPrefetchHeadword = "";
40478pendingReviewTargetSignature = "";
40479pendingReviewTargetReadyAt = 0;
40480nearbyReaderAudioPreloadTimer;
40481preloadedTermAudioKeys = new Set();
40482preloadedPreparedTermAudioKeys = new Set();
40483nestedParseContentCache = new Map();
40484pageAddonParseStates = new WeakMap();
40485pitchEnrichmentLocalCache = new Map();
40486localPitchDictionaryAvailability;
40487resolvedFallbackVocabularyCache = new Map();
40488unresolvedFallbackVocabularyCache = new Map();
40489publicVocabularyMissRetries = new Map();
40490misalignedPublicFuriganaRecoveries = new Set();
40491fallbackVocabularyResolutionCache = new Map();
40492renderedWords = new RenderedWordIndex({
40493isDestroyed: () => this.isDestroyed,
40494annotationRoots: (roots) => this.renderedAnnotationRoots(roots)
40495});
40496renderedWordIndex = this.renderedWords.entries;
40497lateCardReconciliation = new LateCardReconciliation({
40498isDestroyed: () => this.isDestroyed,
40499getSettings: () => this.settings,
40500getLocalSrs: () => this.yomuLocalSrs,
40501renderedWordsForCardStateRepaint: (card) => this.renderedWords.wordsForCardStateRepaint(card),
40502resetRenderedWordRepaintCycle: () => this.renderedWords.resetRepaintCycle(),
40503pauseMutationObserver: (callback) => this.pauseAutoScanObserver(callback),
40504applyVocabulary: (word, card, pitchClass) => this.applyPublicVocabularyToRenderedWord(word, card, pitchClass),
40505reconcileInteractiveVocabulary: (word, card, pitchClass) => this.ocr.reconcileRenderedWordVocabulary(word, card, pitchClass),
40506annotationRoot: (word) => this.lateAnnotationRootForRenderedWord(word),
40507scheduleAnnotationRefresh: (roots, geometryRoots) => this.pageScanner.scheduleLateAnnotationRefresh(roots, geometryRoots),
40508registerRenderedRoot: (root) => this.registerRenderedWordsInRoot(root),
40509preloadAudio: (tokens) => this.preloadTermAudioForTokens(tokens),
40510queueAnki: (tokens, roots) => this.queueAnkiWordEnrichment(tokens, roots)
40511});
40512knownStateBackfillTimer;
40513knownStateBackfillRunning = false;
40514knownStateBackfillBackoffUntil = 0;
40515knownStateBackfillRequestedForUrl = "";
40516knownStateBackfillRequestedSurfaces = new Set();
40517knownStateBackfillResolvedCards = new Map();
40518pitchEnrichmentQueue = [];
40519pitchEnrichmentQueuedKeys = new Set();
40520pitchEnrichmentUrgentKeys = new Set();
40521pitchEnrichmentQueuedOptions = new Map();
40522pitchEnrichmentDrain;
40523deferredPublicPitchQueue = [];
40524deferredPublicPitchQueuedKeys = new Set();
40525deferredPublicPitchEnqueuedForUrl = 0;
40526deferredPublicPitchDrain;
40527deferredPublicJitenReadings = new DeferredPublicJitenReadingCoordinator({
40528isDestroyed: () => this.isDestroyed,
40529shouldEnrich: () => this.shouldRunCanonicalCardEnrichment(),
40530captureTarget: () => this.cardLookup.captureTarget(),
40531lookupCards: (cards, scope) => this.cardLookup.publicLookupHydratableJitenCards(cards, scope),
40532applyResolvedCard: (token, fallback, card, pitchClass) => this.applyResolvedPitchCardToToken(token, fallback, card, pitchClass),
40533queueSubtitleRefresh: (sentence) => this.queueSubtitleParsedHtmlRefresh(sentence),
40534cacheCards: (cards) => this.parser.cacheCards?.(cards),
40535scheduleDeferredPitch: (tokens) => this.scheduleDeferredPublicPitchEnrichment(tokens),
40536showPitchAccent: () => this.settings.showPitchAccent,
40537hasUnresolvedFallback: (key) => this.hasUnresolvedFallbackVocabulary(key),
40538rememberUnresolvedFallback: (key) => this.rememberUnresolvedFallbackVocabulary(key),
40539forgetUnresolvedFallback: (key) => this.unresolvedFallbackVocabularyCache.delete(key),
40540shouldPauseBackground: () => this.shouldPauseBackgroundPublicPitchLookup({}),
40541waitForIdle: (timeoutMs) => this.waitForIdle(timeoutMs),
40542renderedAnnotationRoots: () => this.renderedAnnotationRoots(),
40543renderedWordToken: (word) => this.renderedWordTokenForRecolor(word)
40544});
40545backgroundPublicPitchLookupBudgetHref = location.href;
40546backgroundPublicPitchLookupBudgetUsed = 0;
40547pendingSubtitleRebakeTexts = new Set();
40548subtitleRebakeTimer;
40549cachedPublicVocabularyHydrationTimer;
40550pressedKeys = new Set();
40551hoverAnchorIds = new WeakMap();
40552nextHoverAnchorId = 1;
40553suppressWordClickUntil = 0;
40554suppressPenHoverUntil = 0;
40555pageHasJapaneseText = false;
40556embeddedFrame = false;
40557pressLookup;
40558tapLookup;
40559linkPressLookup;
40560suppressLinkContextMenuUntil = 0;
40561suppressMiddleAuxClickUntil = 0;
40562createOnboardingController() {
40563const Controller = yomuOnboardingController();
40564if (!Controller) return void 0;
40565return new Controller({
40566getSettings: () => this.settings,
40567saveSettings: (settings2, options) => this.persistSettings(settings2, options),
40568setSettings: (settings2) => {
40569const previous = this.settings;
40570this.settings = settings2;
40571if (settings2.learningTargetChosen) this.syncCardLookupTarget(settings2);
40572this.applyTheme();
40573if (settings2.learningTargetChosen) this.stagePreferredJapaneseSiteLanguage(previous, settings2);
40574},
40575showSettings: (panel) => this.showSettings(panel),
40576parseJapanese: (panel) => void this.parseOnboardingJapanese(panel),
40577lookupText: (text2, sentence, anchor) => this.lookupText(text2, sentence || text2, { anchor, stackOverSettings: true }),
40578installOfflineDictionaries: () => void this.offlineDictionaries.run(),
40579onComplete: (settings2) => this.completePreferredJapaneseSiteLanguageSave(settings2),
40580onPersistenceFailed: (settings2) => this.rollbackOnboardingSettings(settings2)
40581});
40582}
40583createSubtitlePlayer() {
40584const Controller = yomuSubtitlePlayerController();
40585if (!Controller) return {
40586...this.missingCompanionSurface("Video companion", "subtitles"),
40587hasDiscoverableVideoCandidate: () => false
40588};
40589return new Controller({
40590getSettings: () => this.settings,
40591...this.targetTextParserDependencies(),
40592beforeRenderTokens: (tokens) => this.enrichSubtitleTokensBeforeRender(tokens),
40593afterParseTokens: (tokens, roots) => this.afterSubtitleJapaneseParsed(tokens, roots),
40594showBatchMiningCard: (candidate) => this.showCard(candidate.card, candidate.sentence, void 0, {
40595autoPlay: false,
40596trigger: "modal",
40597navigation: "push-current"
40598}),
40599prepareBatchMiningCandidates: (candidates) => this.cardActions.batchMining.prepare(candidates),
40600beginBatchMiningGeneration: () => this.cardActions.batchMining.beginGeneration(),
40601executeBatchMiningCandidates: (plans, action, grade) => this.cardActions.batchMining.execute(plans, action, grade),
40602toast: (message) => this.toast(message),
40603onTranscriptPanelClosed: () => this.scheduleVisiblePageRescan(),
40604onSettingsChange: (explicitUserChoiceKeys, clearExplicitUserChoiceKeys) => void this.persistSettings(this.settings, {
40605explicitUserChoiceKeys,
40606clearExplicitUserChoiceKeys
40607})
40608});
40609}
40610targetTextParserDependencies() {
40611return {
40612parseJapanese: async (text2, options) => (await this.parseJapanese([text2], options))[0] ?? [],
40613parseJapaneseBatch: (texts, options) => this.parseJapanese(texts, options)
40614};
40615}
40616createYoutubeFilter() {
40617const Controller = yomuYoutubeImmersionFilter();
40618if (!Controller) return this.missingCompanionSurface("Video companion", "youtube");
40619return new Controller({
40620getSettings: () => this.settings,
40621setShowFilterNotice: (visible) => void this.setYoutubeFilterNoticeVisible(visible),
40622setShowChannelRecommendations: (visible) => void this.setYoutubeChannelRecommendationsVisible(visible),
40623parseShelfJapanese: (root) => void this.parseYoutubeShelfJapanese(root),
40624scheduleAnnotationLayoutRefresh: () => scheduleProjectedAnnotationLayoutRefresh()
40625});
40626}
40627async parseYoutubeShelfJapanese(root) {
40628if (!root.isConnected) return;
40629const plan = nestedTextParsePlan(root, 160);
40630if (!plan || nestedParseAlreadyScheduled(root, plan.parseKey)) return;
40631await this.parseNestedJapaneseContent(root, plan, () => root.isConnected);
40632}
40633createImageOcrController() {
40634const Controller = yomuImageOcrController();
40635if (!Controller) {
40636log.warnOnce("ocr-companion-missing", "OCR companion missing.");
40637return createNoopImageOcrController();
40638}
40639return new Controller({
40640getSettings: () => this.settings,
40641...this.targetTextParserDependencies(),
40642onToast: (message) => this.toast(message),
40643shouldAutoScan: () => shouldAutoScanImageOcr(this.pageHasJapaneseText),
40644shouldScanInlineImages: () => true,
40645enrichTokensBeforeRender: (tokens) => this.enrichOcrTokensBeforeRender(tokens),
40646enrichRenderedTokens: (tokens, root) => this.enrichOcrRenderedTokens(tokens, root),
40647fallbackCardFromText: (text2) => this.parser.fallbackCardFromText(text2)
40648});
40649}
40650missingCompanionSurface(label, key) {
40651return {
40652init: () => log.warnOnce(`${key}-companion-missing`, `${label} is missing; related features are disabled.`),
40653refresh: () => void 0,
40654destroy: () => void 0
40655};
40656}
40657async init(options = {}) {
40658const done = log.time("init", { href: location.href, devMode: Logger.isDevMode() });
40659try {
40660this.settingsSurface = options.settingsSurface;
40661this.embeddedFrame = options.embeddedFrame === true;
40662const shouldShowWelcome = await this.loadInitialSettings(options);
40663if (!this.canContinueStartup(shouldShowWelcome)) return;
40664const surfacesReady = await this.initializeReaderSurfaces(shouldShowWelcome);
40665if (!surfacesReady) return;
40666publishSettingsChange({ settings: this.settings });
40667} finally {
40668done();
40669}
40670}
40671canContinueStartup(startupResult = false) {
40672return startupResult !== null && !this.isDestroyed;
40673}
40674async initializeReaderSurfaces(shouldShowWelcome) {
40675await this.installCoreSurfaces();
40676if (!this.canContinueStartup()) return false;
40677await this.initReaderPage(shouldShowWelcome);
40678return this.canContinueStartup();
40679}
40680async waitForDocumentBody() {
40681if (document.body || this.isDestroyed) return;
40682await new Promise((resolve) => {
40683let timer;
40684const abortSignal = this.abortController.signal;
40685let check = () => void 0;
40686const cleanup = () => {
40687if (timer !== void 0) window.clearTimeout(timer);
40688document.removeEventListener("DOMContentLoaded", check);
40689abortSignal.removeEventListener("abort", check);
40690};
40691check = () => {
40692if (document.body || this.isDestroyed) {
40693cleanup();
40694resolve();
40695return;
40696}
40697timer = window.setTimeout(check, 25);
40698};
40699document.addEventListener("DOMContentLoaded", check, { once: true });
40700abortSignal.addEventListener("abort", check, { once: true });
40701timer = window.setTimeout(check, 0);
40702});
40703}
40704async loadInitialSettings(options) {
40705const startup = await loadReaderStartupSettings(options).catch(rejectAsReaderSettingsUnavailable);
40706if (this.isDestroyed) return null;
40707this.factoryReset.bind();
40708this.settings = startup.settings;
40709this.pageOwnedLearningTargetActive = startup.pageOwnedLearningTarget !== null;
40710if (startup.settings.learningTargetChosen) {
40711this.syncCardLookupTarget(startup.settings);
40712this.applyPreferredJapaneseSiteLanguage();
40713}
40714configureLogger({ forceEnabled: this.settings.enableLogging });
40715this.pageHasJapaneseText = this.hasLearningTargetRuntimePolicy() ? detectReaderStartupJapaneseText() : false;
40716log.info("Settings loaded", startup.settingsSummary);
40717return startup.shouldShowWelcome;
40718}
40719async installCoreSurfaces() {
40720this.installStyles();
40721this.applyTheme();
40722await honorDictionaryReplicaPurge().catch((error) => log.debug("Dictionary replica purge check failed", error));
40723if (this.hasLearningTargetRuntimePolicy()) this.installTargetOwnedCoreSurfaces();
40724}
40725installTargetOwnedCoreSurfaces() {
40726if (this.targetOwnedCoreInstalled) return;
40727this.targetOwnedCoreInstalled = true;
40728this.installTargetOwnedStorageSurfaces();
40729if (!this.embeddedFrame) this.installTopLevelCoreSurfaces();
40730}
40731installTargetOwnedStorageSurfaces() {
40732void this.refreshDictionaryStyles();
40733if (this.observesSettingsStorage) this.installSettingsStorageSubscription();
40734}
40735installTopLevelCoreSurfaces() {
40736this.registerMenuCommands();
40737this.bindEvents();
40738this.disposeJpdbReviewBridge?.();
40739this.disposeJpdbReviewBridge = installReaderStartupBridge();
40740}
40741async initReaderPage(shouldShowWelcome) {
40742await this.waitForDocumentBody();
40743if (!this.canInitializeReaderPage()) return;
40744if (this.embeddedFrame) {
40745this.initEmbeddedReaderPage();
40746return;
40747}
40748await this.initTopLevelReaderPage(shouldShowWelcome);
40749}
40750canInitializeReaderPage() {
40751return !this.isDestroyed && Boolean(document.body);
40752}
40753initEmbeddedReaderPage() {
40754if (!this.hasLearningTargetRuntimePolicy()) return;
40755this.captureStartupTargetProbe();
40756this.subtitles.init();
40757this.ocr.init();
40758this.setupAutoScan();
40759if (this.shouldScanEmbeddedFrame() || this.pageHasJapaneseText) this.scheduleVisiblePageRescan();
40760}
40761async initTopLevelReaderPage(shouldShowWelcome) {
40762if (!await this.ensureTopLevelLearningTarget(shouldShowWelcome)) {
40763this.installDormantLearningTargetSubscription();
40764return;
40765}
40766if (!this.topLevelTargetLifecycle.beginTargetOwnedSurfaces(this.isDestroyed, this.embeddedFrame, this.hasLearningTargetRuntimePolicy())) return;
40767this.installTargetOwnedCoreSurfaces();
40768const startupTargetProbe = this.captureStartupTargetProbe();
40769this.installFab();
40770void this.installBunproTokenImporter();
40771this.subtitles.init();
40772this.ocr.init();
40773this.disposeMokuroOcrToggleWatch?.();
40774this.disposeMokuroOcrToggleWatch = watchMokuroOcrToggle(() => this.ocr.reassessAutoScan());
40775this.youtube.init();
40776this.setupAutoScan();
40777this.initJpdbPageEnhancements();
40778this.installCardStateSignalSubscription();
40779installAcademyReaderSrsSync();
40780this.scheduleInitialReaderWork(startupTargetProbe.shadowDiscoveryExhausted);
40781}
40782async ensureTopLevelLearningTarget(shouldShowWelcome) {
40783if (this.hasLearningTargetRuntimePolicy()) return true;
40784if (shouldShowReaderOnboarding(shouldShowWelcome)) await this.runOnboardingIfAvailable();
40785return !this.isDestroyed && this.hasLearningTargetRuntimePolicy();
40786}
40787async runOnboardingIfAvailable() {
40788const onboarding = this.onboarding;
40789if (!onboarding) return;
40790await onboarding.showIfNeeded();
40791await onboarding.waitForCompletion();
40792}
40793captureStartupTargetProbe() {
40794const probe = documentJapaneseTextProbe(2e5, scanScopeRoots());
40795if (!this.pageHasJapaneseText) this.pageHasJapaneseText = probe.hasJapanese;
40796return probe;
40797}
40798scheduleInitialReaderWork(shadowDiscoveryUncertain) {
40799if (this.shouldScanInitialPage(shadowDiscoveryUncertain)) {
40800void this.pageScanner.scanVisiblePage({ silent: true }).finally(() => this.scheduleStatusWarmups());
40801} else {
40802this.scheduleStatusWarmups();
40803}
40804}
40805async installBunproTokenImporter() {
40806await this.bunproCompanion?.installBunproFrontendTokenImporter({
40807getSettings: () => this.settings,
40808setSettings: (settings2) => {
40809this.settings = settings2;
40810},
40811saveSettings: (settings2, options) => this.persistSettings(settings2, options),
40812toast: (message) => this.toast(message),
40813language: () => this.settings.interfaceLanguage
40814});
40815}
40816installCardStateSignalSubscription() {
40817this.unsubscribeCardStateSignals?.();
40818this.unsubscribeCardStateSignals = subscribeToCardStateSignals((card) => {
40819if (this.isDestroyed) return;
40820this.applyPublicVocabularyToRenderedWords(card, card);
40821repaintYomuLocalSrsRenderedWords(card, this.renderedAnnotationRoots());
40822});
40823}
40824installSettingsStorageSubscription() {
40825this.unsubscribeSettingsStorageChanges?.();
40826this.unsubscribeSettingsStorageChanges = subscribeToReaderSettingsChanges((settings2) => {
40827if (!this.isDestroyed) void this.applyRemoteSettings(settings2);
40828});
40829}
40830installDormantLearningTargetSubscription() {
40831if (!this.topLevelTargetLifecycle.canWaitForTarget(this.isDestroyed, this.embeddedFrame, this.hasLearningTargetRuntimePolicy())) return;
40832this.unsubscribeSettingsStorageChanges?.();
40833this.unsubscribeSettingsStorageChanges = subscribeToFirstPersistedLearningTarget(
40834() => this.settings,
40835(settings2) => {
40836if (!this.isDestroyed) void this.applyRemoteSettings(settings2);
40837}
40838);
40839}
40840async applyRemoteSettings(settings2) {
40841const wakesTopLevelTarget = shouldWakeTopLevelTarget(this.embeddedFrame, this.hasLearningTargetRuntimePolicy(), settings2);
40842const pauseChanged = settings2.annotationsPaused !== this.settings.annotationsPaused;
40843const japaneseSiteOptOut = japaneseSiteLanguageDisabled(this.settings, settings2);
40844this.pendingPreferredJapaneseSiteLanguage = void 0;
40845this.settings = settings2;
40846if (settings2.learningTargetChosen) this.syncCardLookupTarget(settings2);
40847configureLogger({ forceEnabled: settings2.enableLogging });
40848this.applyPreferredJapaneseSiteLanguage(settings2, japaneseSiteOptOut);
40849this.applyTheme(settings2);
40850this.applyWordColors(settings2);
40851const initializedTarget = await applyTargetSurfaceSettingsChange(
40852wakesTopLevelTarget,
40853this.embeddedFrame,
40854() => this.initTopLevelReaderPage(false),
40855() => this.installFab(),
40856() => {
40857this.subtitles.refresh();
40858this.ocr.refresh();
40859this.youtube.refresh();
40860}
40861);
40862if (pauseChanged) this.applyAnnotationsPausedState();
40863this.clearBridgeBackedCaches();
40864if (!initializedTarget) {
40865this.scheduleDictionaryRescan();
40866await this.refreshDictionaryStyles();
40867}
40868publishSettingsChange({ settings: settings2, remote: true });
40869}
40870syncCardLookupTarget(settings2) {
40871adoptLearningTargetFromSettings(settings2);
40872this.cardLookup.syncTarget(settings2);
40873}
40874hasLearningTargetRuntimePolicy() {
40875return this.settings.learningTargetChosen || this.pageOwnedLearningTargetActive;
40876}
40877scheduleAnkiStatusWarmup() {
40878if (!this.shouldRunAnkiBackgroundWork()) return;
40879scheduleReaderAnkiStatusWarmup({
40880getSettings: () => this.settings,
40881isDestroyed: () => this.isDestroyed,
40882warmStatusIndex: () => this.anki.warmStatusIndex(),
40883recolorRenderedAnkiWordsFromCache: () => this.recolorRenderedAnkiWordsFromCache(),
40884onRecolorError: (error) => {
40885log.warnOnce("anki-cache-recolor-failed", "Anki cache recolor failed", error);
40886}
40887});
40888}
40889scheduleStatusWarmups() {
40890this.scheduleAnkiStatusWarmup();
40891this.scheduleBunproWordStateWarmup();
40892this.scheduleReaderKnownStateBackfill();
40893}
40894scheduleBunproWordStateWarmup() {
40895if (!this.shouldRunBunproWordStateWork()) return;
40896window.setTimeout(() => this.queueBunproWordStateEnrichment([document]), BUNPRO_WORD_STATE_WARMUP_DELAY_MS);
40897}
40898shouldRunBunproWordStateWork() {
40899return !this.isDestroyed && usesJapaneseProviders() && shouldLookupBunproWordStates(this.settings);
40900}
40901queueBunproWordStateEnrichment(roots = [document]) {
40902this.scheduleReaderKnownStateBackfill();
40903if (!this.shouldRunBunproWordStateWork()) return;
40904void this.applyBunproWordStatesToRoots(roots).catch((error) => {
40905log.warnOnce("bunpro-word-state-coloring-failed", "Bunpro word-state coloring failed", error);
40906});
40907}
40908async applyBunproWordStatesToRoots(roots) {
40909const bunproCompanion = this.bunproCompanion;
40910if (!bunproCompanion || !this.bunproWordStates) return;
40911const states = await this.bunproWordStates.load();
40912if (!states?.size || !this.shouldRunBunproWordStateWork()) return;
40913const now = Date.now();
40914const changedWords = [];
40915this.pauseAutoScanObserver(() => {
40916uniqueParentNodes(roots).forEach((root) => {
40917renderedWordsInRoot(root).forEach((word) => {
40918const entry = word.dataset.expression ? states.get(word.dataset.expression) : void 0;
40919const state = entry ? bunproCompanion.effectiveBunproWordState(entry, now) : null;
40920if (applyBunproStateToRenderedWord(word, state)) changedWords.push(word);
40921});
40922});
40923});
40924if (!changedWords.length) return;
40925const changedRoots = uniqueParentNodes(changedWords.map((word) => this.lateAnnotationRootForRenderedWord(word)));
40926this.pageScanner.scheduleLateAnnotationRefresh(changedRoots, []);
40927}
40928scheduleRenderedAnkiStatusRefresh(card) {
40929if (!this.shouldRunAnkiBackgroundWork()) return;
40930scheduleReaderAnkiStatusRefresh(this.settings, () => this.refreshRenderedAnkiStatusAfterMutation(card));
40931}
40932shouldRunAnkiBackgroundWork() {
40933return !this.isDestroyed && shouldLookupAnkiStatus(this.settings);
40934}
40935handleAnkiStatusChanged(card) {
40936this.cardRenderData.clear();
40937this.scheduleRenderedAnkiStatusRefresh(card);
40938}
40939async refreshRenderedAnkiStatusAfterMutation(card) {
40940if (!this.shouldRunAnkiBackgroundWork()) return;
40941try {
40942const lookup = await this.anki.findExistingCards(card);
40943if (!this.shouldRunAnkiBackgroundWork()) return;
40944this.applyAnkiLookupToRenderedWords(card, lookup);
40945} catch (error) {
40946if (!this.shouldRunAnkiBackgroundWork()) return;
40947log.warnOnce("anki-mutation-recolor-failed", "Anki status recolor after mutation failed", error);
40948await this.recolorRenderedAnkiWordsFromCache().catch((cacheError) => {
40949log.warnOnce("anki-mutation-cache-recolor-failed", "Cached Anki recolor after mutation failed", cacheError);
40950});
40951}
40952}
40953shouldScanInitialPage(shadowDiscoveryUncertain = false) {
40954if (this.settings.annotationsPaused || this.settings.manualScanEnabled) return false;
40955return this.canParseJapanese() && (this.pageHasJapaneseText || hasVisibleSiteScanTargets() || shadowDiscoveryUncertain);
40956}
40957registerMenuCommands() {
40958registerReaderMenuCommands({
40959cycleOcr: () => this.cycleOcrMode(),
40960getSettings: () => this.settings,
40961saveSettings: (settings2, explicitUserChoiceKeys) => this.persistSettings(settings2, { explicitUserChoiceKeys }),
40962installFloatingButton: () => this.installFab(),
40963showSettings: () => this.showSettings(),
40964toggleAnnotations: () => this.toggleAnnotationsPaused(),
40965toggleAudio: () => this.toggleAutoPlayAudio(),
40966toggleSiteLanguage: () => this.togglePreferredJapaneseSiteLanguage(),
40967toggleYoutube: () => this.toggleYoutubeImmersion(),
40968factoryReset: () => void this.factoryReset.resetAllData(),
40969logInfo: (message, details) => {
40970log.info(message, details);
40971}
40972});
40973}
40974async toggleYoutubeImmersion() {
40975await this.setYoutubeImmersionEnabled(!this.isYoutubeImmersionEnabled());
40976}
40977async setYoutubeImmersionEnabled(enabled) {
40978const previous = this.settings.youtubeImmersionEnabled;
40979const previousChosen = this.settings.youtubeImmersionEnabledChosen;
40980this.settings.youtubeImmersionEnabled = enabled;
40981this.settings.youtubeImmersionEnabledChosen = true;
40982this.youtube.refresh();
40983this.toast(uiText$1(this.settings.interfaceLanguage, enabled ? "youtubeToggleToastOn" : "youtubeToggleToastOff"));
40984try {
40985await this.persistSettings(this.settings, {
40986explicitUserChoiceKeys: ["youtubeImmersionEnabled", "youtubeImmersionEnabledChosen"]
40987});
40988} catch (error) {
40989this.settings.youtubeImmersionEnabled = previous;
40990this.settings.youtubeImmersionEnabledChosen = previousChosen;
40991this.youtube.refresh();
40992this.toast(uiText$1(this.settings.interfaceLanguage, "settingsSaveFailed"));
40993throw error;
40994}
40995}
40996isYoutubeImmersionEnabled() {
40997return jpOnlyOn(
40998this.settings,
40999this.settings.youtubeImmersionEnabled,
41000this.settings.youtubeImmersionEnabledChosen
41001);
41002}
41003async setYoutubeFilterNoticeVisible(visible) {
41004this.settings.youtubeShowFilterNotice = visible;
41005await this.persistSettings(this.settings, { explicitUserChoiceKeys: ["youtubeShowFilterNotice"] });
41006this.youtube.refresh();
41007}
41008async togglePreferredJapaneseSiteLanguage() {
41009await this.setPreferredJapaneseSiteLanguage(!this.settings.preferJapaneseSiteLanguage);
41010}
41011async setPreferredJapaneseSiteLanguage(enabled) {
41012const previous = this.settings.preferJapaneseSiteLanguage;
41013if (previous === enabled) return;
41014this.settings.preferJapaneseSiteLanguage = enabled;
41015const save = this.persistSettings(this.settings, {
41016persistPreferredJapaneseSiteLanguage: true,
41017explicitUserChoiceKeys: ["preferJapaneseSiteLanguage"]
41018});
41019if (!enabled) this.applyPreferredJapaneseSiteLanguage(this.settings, false, true);
41020try {
41021await save;
41022} catch (error) {
41023this.settings.preferJapaneseSiteLanguage = previous;
41024this.applyPreferredJapaneseSiteLanguage(this.settings, false);
41025throw error;
41026}
41027this.applyPreferredJapaneseSiteLanguage(this.settings, !enabled);
41028}
41029async setYoutubeChannelRecommendationsVisible(visible) {
41030this.settings.youtubeShowChannelRecommendations = visible;
41031this.settings.youtubeShowChannelRecommendationsChosen = true;
41032await this.persistSettings(this.settings, {
41033explicitUserChoiceKeys: [
41034"youtubeShowChannelRecommendations",
41035"youtubeShowChannelRecommendationsChosen"
41036]
41037});
41038this.youtube.refresh();
41039}
41040async setInterfaceLanguage(language) {
41041if (this.settings.interfaceLanguage === language) return;
41042this.settings.interfaceLanguage = language;
41043await this.persistSettings(this.settings, { explicitUserChoiceKeys: ["interfaceLanguage"] });
41044this.settingsDialog?.refreshLanguage(language);
41045this.clearHostedPageReaderWords();
41046this.installFab();
41047this.subtitles.refresh();
41048this.ocr.refresh();
41049this.youtube.refresh();
41050this.scheduleLanguageChangeScan();
41051}
41052clearHostedPageReaderWords() {
41053if (!isYomuHostedAppUrl(location.href)) return;
41054const count = unwrapReaderWords(document);
41055releaseRubyRoomGrowth(document);
41056if (count > 0) refreshReaderWordContrast(document);
41057}
41058scheduleLanguageChangeScan() {
41059window.setTimeout(() => {
41060if (this.isDestroyed) return;
41061this.pageHasJapaneseText = documentHasJapaneseText(2e5, scanScopeRoots());
41062if (this.shouldScanInitialPage()) this.scheduleVisiblePageReparse(0);
41063}, 160);
41064}
41065async invalidateRuntimeStoresForFactoryReset() {
41066this.dismiss({ suppressHoverTarget: false });
41067this.jpdb.clear();
41068this.jitenPublicVocabulary.clear();
41069this.parser.clearLocalCache();
41070this.dictionarySourceState.clear();
41071this.cardRenderData.clear();
41072this.preloadedTermAudioKeys.clear();
41073this.preloadedPreparedTermAudioKeys.clear();
41074this.nestedParseContentCache.clear();
41075this.pitchEnrichmentLocalCache.clear();
41076this.localPitchDictionaryAvailability = void 0;
41077this.jitenPublicVocabulary.clear();
41078this.resolvedFallbackVocabularyCache.clear();
41079this.unresolvedFallbackVocabularyCache.clear();
41080this.publicVocabularyMissRetries.clear();
41081this.misalignedPublicFuriganaRecoveries.clear();
41082this.fallbackVocabularyResolutionCache.clear();
41083this.clearPitchEnrichmentQueue();
41084this.pitchEnrichmentUrgentKeys.clear();
41085this.lateCardReconciliation.resetPending();
41086window.clearTimeout(this.cachedPublicVocabularyHydrationTimer);
41087this.cachedPublicVocabularyHydrationTimer = void 0;
41088this.pressedKeys.clear();
41089window.clearTimeout(this.nearbyReaderAudioPreloadTimer);
41090this.nearbyReaderAudioPreloadTimer = void 0;
41091this.cardRenderRequest++;
41092await this.dictionaries.invalidateForFactoryReset();
41093await clearManagedBrowserCaches();
41094await unregisterManagedServiceWorkers();
41095}
41096installStyles() {
41097const hasLinkedReaderCss = Boolean(document.querySelector('link[href$="/yomu.css"], link[href*="/yomu.css?"]'));
41098const style = document.createElement("style");
41099style.textContent = hasLinkedReaderCss ? "" : initialReaderCss(READER_CSS);
41100appendToDocumentHead(style);
41101setShadowReaderCss(initialReaderCss(READER_CSS));
41102if (!shouldLoadReaderCssFallback(hasLinkedReaderCss, READER_CSS) || isJsdomRuntime()) return;
41103void loadReaderCssFallback().then((css) => {
41104if (!css || this.isDestroyed) return;
41105style.textContent = css;
41106setShadowReaderCss(css);
41107this.applyTheme();
41108}).catch((error) => {
41109log.warn("Reader CSS fallback load failed", error);
41110});
41111}
41112applyTheme(settings2 = this.settings) {
41113applyReaderTheme(settings2);
41114this.hostTheme.sync(settings2);
41115refreshReaderWordContrast(document);
41116this.scheduleDeferredThemeContrastRefresh();
41117}
41118scheduleDeferredThemeContrastRefresh() {
41119window.cancelAnimationFrame(this.themeContrastRefreshFrame ?? 0);
41120window.clearTimeout(this.themeContrastRefreshTimer);
41121this.themeContrastRefreshFrame = window.requestAnimationFrame(() => {
41122this.themeContrastRefreshFrame = void 0;
41123if (this.isDestroyed) return;
41124this.hostTheme.refreshAmbient(this.settings);
41125refreshReaderWordContrast(document);
41126this.themeContrastRefreshTimer = window.setTimeout(() => {
41127this.themeContrastRefreshTimer = void 0;
41128if (this.isDestroyed) return;
41129this.hostTheme.refreshAmbient(this.settings);
41130refreshReaderWordContrast(document);
41131}, 80);
41132});
41133}
41134applyPreferredJapaneseSiteLanguage(settings2 = this.settings, options, deferCookieResponseReloadUntilPersisted) {
41135applyPreferredJapaneseSiteLanguage(
41136settings2.preferJapaneseSiteLanguage,
41137options,
41138deferCookieResponseReloadUntilPersisted,
41139targetLanguageOf(settings2)
41140);
41141}
41142stagePreferredJapaneseSiteLanguage(previous, next) {
41143if (previous.preferJapaneseSiteLanguage === next.preferJapaneseSiteLanguage) {
41144if (this.pendingPreferredJapaneseSiteLanguage === void 0) {
41145this.applyPreferredJapaneseSiteLanguage(next, false);
41146}
41147return;
41148}
41149this.pendingPreferredJapaneseSiteLanguage = next.preferJapaneseSiteLanguage;
41150if (!next.preferJapaneseSiteLanguage) {
41151this.applyPreferredJapaneseSiteLanguage(next, false, true);
41152}
41153}
41154completePreferredJapaneseSiteLanguageSave(settings2) {
41155if (this.pendingPreferredJapaneseSiteLanguage !== settings2.preferJapaneseSiteLanguage) return;
41156const enabled = settings2.preferJapaneseSiteLanguage;
41157this.pendingPreferredJapaneseSiteLanguage = void 0;
41158this.applyPreferredJapaneseSiteLanguage(settings2, !enabled);
41159}
41160failPreferredJapaneseSiteLanguageSave(previousSettings) {
41161if (this.pendingPreferredJapaneseSiteLanguage === void 0) return;
41162this.pendingPreferredJapaneseSiteLanguage = void 0;
41163this.settings = {
41164...this.settings,
41165preferJapaneseSiteLanguage: previousSettings.preferJapaneseSiteLanguage
41166};
41167this.applyPreferredJapaneseSiteLanguage(this.settings, false);
41168}
41169rollbackOnboardingSettings(previousSettings) {
41170this.failPreferredJapaneseSiteLanguageSave(previousSettings);
41171this.settings = previousSettings;
41172if (previousSettings.learningTargetChosen) this.syncCardLookupTarget(previousSettings);
41173this.applyTheme(previousSettings);
41174this.applyWordColors(previousSettings);
41175}
41176publishThemeSettingsChange() {
41177this.publishSettingsChange({ theme: this.settings.theme });
41178}
41179publishSettingsChange(settings2) {
41180publishSettingsChange({ settings: settings2 });
41181}
41182async refreshDictionaryStyles() {
41183await this.dictionaryStyles.refresh();
41184}
41185clearBridgeBackedCaches() {
41186this.audio.clearCaches();
41187this.jpdbVocabulary.clear();
41188this.jitenPublicVocabulary.clear();
41189this.cardRenderData.clear();
41190this.bunproWordStates?.clear();
41191}
41192/**
41193* A dictionary rescan is a DESTRUCTIVE pass: reparseVisiblePage tears down the
41194* page enhancements and re-runs the visible-page scan, which unwraps and
41195* re-wraps every `.jpdb-reader-word`. Any word it replaces is a word some open
41196* popover is anchored to — so the lookup the learner is reading gets its anchor
41197* pulled out from under it and the hover watchdog closes the panel a beat later.
41198*
41199* Nothing that reaches here is the learner asking for it right now: a settings
41200* write from another tab, an offline-dictionary install finishing, an
41201* auto-discovery pass. They land seconds after the fact, which is why this read
41202* as "the popup closed on its own after about twenty seconds". Defer until no
41203* popover is open — dismiss() flushes the pending pass — instead of narrowing
41204* the guard to the settings dialog, which was only ever the one surface someone
41205* happened to notice this on.
41206*/
41207scheduleDictionaryRescan() {
41208if (this.activePopover) {
41209this.dictionaryRescanPending = true;
41210return;
41211}
41212this.pitchEnrichmentLocalCache.clear();
41213this.localPitchDictionaryAvailability = void 0;
41214this.jitenPublicVocabulary.clear();
41215this.nestedParseContentCache.clear();
41216this.resolvedFallbackVocabularyCache.clear();
41217this.unresolvedFallbackVocabularyCache.clear();
41218this.publicVocabularyMissRetries.clear();
41219this.misalignedPublicFuriganaRecoveries.clear();
41220this.fallbackVocabularyResolutionCache.clear();
41221this.clearPitchEnrichmentQueue();
41222window.clearTimeout(this.cachedPublicVocabularyHydrationTimer);
41223this.cachedPublicVocabularyHydrationTimer = void 0;
41224this.scheduleJpdbPageEnhancements(80);
41225this.scheduleVisiblePageReparse(120);
41226}
41227scheduleVisiblePageReparse(delay2 = 0) {
41228if (this.isDestroyed) return;
41229if (this.visiblePageReparseTimer !== void 0) return;
41230this.visiblePageReparseTimer = window.setTimeout(() => {
41231this.visiblePageReparseTimer = void 0;
41232void this.reparseVisiblePage();
41233}, Math.max(0, delay2));
41234}
41235async reparseVisiblePage() {
41236if (this.settings.annotationsPaused) return;
41237this.jpdb.clear();
41238this.jitenPublicVocabulary.clear();
41239this.parser.clearLocalCache();
41240this.nestedParseContentCache.clear();
41241this.pauseAutoScanObserver(() => {
41242this.removeJpdbPageEnhancements();
41243});
41244if (!this.canParseJapanese()) {
41245this.scheduleJpdbPageEnhancements(0);
41246return;
41247}
41248await this.pageScanner.scanVisiblePage({ silent: true });
41249this.scheduleJpdbPageEnhancements(0);
41250}
41251initJpdbPageEnhancements() {
41252if (!isPageEnhancementHost()) return;
41253this.lastJitenStudyHeadword = currentJitenStudyHeadwordText();
41254this.prefetchJitenStudyImmersion();
41255this.scheduleJpdbPageEnhancements(0);
41256this.installJpdbReviewExamplesToggleMemory();
41257addWindowEventListener("popstate", () =>
41257 this.scheduleJpdbPageEnhancements(120), { signal: this.abortController.signal });
41258addWindowEventListener("hashchange", () => this.scheduleJpdbPageEnhancements(120), { signal: this.abortController.signal });
41259}
41260scheduleJpdbPageEnhancements(delay2 = 0, options = {}) {
41261if (this.isDestroyed || !isPageEnhancementHost()) return;
41262const normalizedDelay = Math.max(0, delay2);
41263const deadline = Date.now() + normalizedDelay;
41264if (this.jpdbPageEnhanceTimer !== void 0 && options.preserveEarlier && this.jpdbPageEnhanceDeadline <= deadline) return;
41265window.clearTimeout(this.jpdbPageEnhanceTimer);
41266this.jpdbPageEnhanceDeadline = deadline;
41267this.jpdbPageEnhanceTimer = window.setTimeout(() => {
41268this.jpdbPageEnhanceTimer = void 0;
41269this.jpdbPageEnhanceDeadline = 0;
41270void this.refreshJpdbPageEnhancements();
41271}, normalizedDelay);
41272}
41273async refreshJpdbPageEnhancements() {
41274const generation = ++this.jpdbPageEnhancementGeneration;
41275this.lastEnhancedHref = location.href;
41276this.installJpdbReviewExamplesToggleMemory();
41277if (!this.settings.jpdbPageEnhancementsEnabled || !isPageEnhancementReady()) {
41278this.pauseAutoScanObserver(() => this.removeJpdbPageEnhancements());
41279return;
41280}
41281if (isCurrentKanjiSurface()) {
41282if (this.settings.jpdbPageKanjiEnhancementsEnabled) this.installJpdbKanjiPageEnhancement(generation);
41283this.removeStaleJpdbPageEnhancements(generation);
41284return;
41285}
41286if (this.settings.jpdbPageWordEnhancementsEnabled && this.reviewPageWordTargetsStableForMount()) {
41287await this.installJpdbWordPageEnhancements(generation);
41288}
41289this.removeStaleJpdbPageEnhancements(generation);
41290}
41291reviewPageWordTargetsStableForMount() {
41292if (currentPageEnhancementLayoutContext() !== "review") {
41293this.resetPendingReviewTarget();
41294return true;
41295}
41296const expectedKeys = currentPageLocalDictionaryTargets().map((target) => this.jpdbPageWordAddonKey(target));
41297if (!expectedKeys.length) {
41298this.resetPendingReviewTarget();
41299return true;
41300}
41301const mountedKeys = Array.from(document.querySelectorAll("[data-yomu-jpdb-addon]")).map((element) => element.dataset.yomuAddonKey ?? "");
41302if (expectedKeys.some((expected) => mountedKeys.some((mounted) => pageAddonKeysMatch(expected, mounted)))) {
41303this.resetPendingReviewTarget();
41304return true;
41305}
41306const signature = expectedKeys.join("\0");
41307const now = Date.now();
41308if (signature !== this.pendingReviewTargetSignature) {
41309this.pendingReviewTargetSignature = signature;
41310this.pendingReviewTargetReadyAt = now + REVIEW_PAGE_TARGET_SETTLE_MS;
41311}
41312const remaining = this.pendingReviewTargetReadyAt - now;
41313if (remaining > 0) {
41314this.scheduleJpdbPageEnhancements(Math.ceil(remaining), { preserveEarlier: true });
41315return false;
41316}
41317this.resetPendingReviewTarget();
41318return true;
41319}
41320resetPendingReviewTarget() {
41321this.pendingReviewTargetSignature = "";
41322this.pendingReviewTargetReadyAt = 0;
41323}
41324removeJpdbPageEnhancements() {
41325document.querySelectorAll("[data-yomu-jpdb-addon]").forEach((element) => this.removeJpdbPageAddonRoot(element));
41326}
41327removeStaleJpdbPageEnhancements(generation) {
41328const generationKey = String(generation);
41329this.pauseAutoScanObserver(() => {
41330document.querySelectorAll("[data-yomu-jpdb-addon]").forEach((element) => {
41331if (element.dataset.yomuGeneration !== generationKey) this.removeJpdbPageAddonRoot(element);
41332});
41333});
41334}
41335maybeScrollJitenStudyToNewCard() {
41336const headword = currentJitenStudyHeadwordText();
41337if (!headword || headword === this.lastJitenStudyHeadword) return;
41338const hadPreviousCard = this.lastJitenStudyHeadword !== "";
41339this.lastJitenStudyHeadword = headword;
41340if (hadPreviousCard) window.scrollTo({ top: 0 });
41341}
41342prefetchJitenStudyImmersion() {
41343if (!isJitenHost() || !location.pathname.startsWith("/srs/study") || !this.settings.immersionKitEnabled) return;
41344const headword = currentJitenStudyHeadwordText();
41345if (!headword || headword === this.lastJitenImmersionPrefetchHeadword || currentPageLocalDictionaryTargets().length > 0) return;
41346const controller = this.immersionPopover;
41347if (!controller) return;
41348this.lastJitenImmersionPrefetchHeadword = headword;
41349const card = jpdbAudioCard(headword, headword);
41350card.source = "jiten";
41351void controller.searchExamples(card, { exactOnly: true }).then((result) => {
41352if (currentJitenStudyHeadwordText() !== headword) return;
41353const example = controller.preferredExampleFor(card, result.examples);
41354const client = this.immersionKit;
41355if (!example || !client || !this.settings.immersionKitShowImages) return;
41356const imageUrls = client.mediaUrls(example, "image");
41357if (!imageUrls.length) return;
41358void client.fetchBlobUrl(
41359imageUrls[0],
41360this.settings.audioTimeoutMs,
41361this.settings.corsProxyUrl,
41362this.settings.interfaceLanguage
41363).catch(() => void 0);
41364}).catch((error) => {
41365log.debug("Jiten review Immersion prefetch failed", { term: headword, error });
41366});
41367}
41368jitenEnhancementsNeedRefresh() {
41369if (location.href !== this.lastEnhancedHref) return true;
41370if (!isPageEnhancementReady() || !this.settings.jpdbPageEnhancementsEnabled) return false;
41371const hasAddon = Boolean(document.querySelector("[data-yomu-jpdb-addon]"));
41372if (isBunproHost() && isBunproQuizAnswerHidden()) return hasAddon;
41373if (currentPageEnhancementLayoutContext() === "review" && !isCurrentKanjiSurface() && currentPageLocalDictionaryTargets().length === 0) return hasAddon;
41374if (!hasAddon) return true;
41375if (this.jitenAddonWordIdentityChanged()) return true;
41376return this.jitenAddonStrandedOnFallbackAnchor();
41377}
41378jitenAddonWordIdentityChanged() {
41379const expected = this.currentJitenAddonKeys();
41380if (!expected.length) return false;
41381const mounted = Array.from(document.querySelectorAll("[data-yomu-jpdb-addon]")).map((element) => element.dataset.yomuAddonKey ?? "");
41382return !expected.some((expectedKey) => mounted.some((mountedKey) => pageAddonKeysMatch(expectedKey, mountedKey)));
41383}
41384currentJitenAddonKeys() {
41385if (isCurrentKanjiSurface()) {
41386const kanji = currentPageKanji();
41387return isKanjiCharacter(kanji) ? [`kanji:${kanji}`] : [];
41388}
41389return currentPageLocalDictionaryTargets().map((target) => this.jpdbPageWordAddonKey(target));
41390}
41391jitenAddonStrandedOnFallbackAnchor() {
41392if (!document.querySelector('[data-yomu-jpdb-addon][data-yomu-anchor-fallback="true"]')) return false;
41393const anchor = currentPageTermTarget()?.anchor;
41394return Boolean(anchor && anchor !== document.body && anchor.tagName !== "MAIN");
41395}
41396installJpdbReviewExamplesToggleMemory() {
41397if (!this.isJpdbReviewPage()) return;
41398const checkbox = document.querySelector("#show-checkbox-examples");
41399if (!checkbox) return;
41400if (checkbox.dataset.yomuExamplesMemoryInstalled === "true") return;
41401checkbox.dataset.yomuExamplesMemoryInstalled = "true";
41402this.restoreJpdbReviewExamplesVisible(checkbox);
41403checkbox.addEventListener("change", trustedReaderEventHandler(() => {
41404this.storeJpdbReviewExamplesVisible(checkbox.checked);
41405}), { signal: this.abortController.signal });
41406}
41407restoreJpdbReviewExamplesVisible(checkbox) {
41408const storedVisible = this.storedJpdbReviewExamplesVisible();
41409if (storedVisible === null || checkbox.checked === storedVisible) return;
41410checkbox.checked = storedVisible;
41411checkbox.dispatchEvent(new Event("change", { bubbles: true }));
41412}
41413isJpdbReviewPage() {
41414return location.hostname === "jpdb.io" && location.pathname.startsWith("/review");
41415}
41416storedJpdbReviewExamplesVisible() {
41417try {
41418const value = managedLocalStorage.getItem(JPDB_REVIEW_EXAMPLES_VISIBLE_STORAGE_KEY);
41419return value === "true" ? true : value === "false" ? false : null;
41420} catch {
41421return null;
41422}
41423}
41424storeJpdbReviewExamplesVisible(visible) {
41425try {
41426managedLocalStorage.setItem(JPDB_REVIEW_EXAMPLES_VISIBLE_STORAGE_KEY, String(visible));
41427} catch {
41428}
41429}
41430async installJpdbWordPageEnhancements(generation) {
41431const targets = currentPageLocalDictionaryTargets();
41432await Promise.all(targets.map((target) => this.installJpdbWordPageEnhancement(target, generation)));
41433}
41434installJpdbWordPageEnhancement(target, generation) {
41435const card = this.jpdbPageWordCard(target);
41436if (!this.isCurrentJpdbPageEnhancement(generation)) return;
41437const root = this.createJpdbPageAddonRoot("word", this.jpdbPageWordAddonKey(target), target.anchor, generation);
41438if (!root) return;
41439const state = {
41440entries: [],
41441jpdbVocabularyInfo: null,
41442jitenVocabularyInfo: null,
41443bunproDefinitionInfo: null
41444};
41445this.renderJpdbPageWordDefinitionState(root, card, target, generation, state);
41446const load = this.cardRenderData.loadDefinitionSources(card, {
41447includeJpdbDefinition: !isJpdbHost(),
41448includeJitenDefinition: !isJitenHost(),
41449includeBunproDefinition: !isBunproHost()
41450});
41451const mergeEntries = (entries2) => {
41452state.entries = uniqueLocalDictionaryEntries([...state.entries, ...entries2]);
41453this.renderJpdbPageWordDefinitionState(root, card, target, generation, state);
41454};
41455const tasks = [
41456load.localEntries.then(mergeEntries),
41457load.hydrateLocalEntries().then(mergeEntries),
41458this.lookupJpdbPageLocalEntries(target).then(mergeEntries),
41459load.jpdbVocabularyInfo.then((value) => {
41460state.jpdbVocabularyInfo = value;
41461this.renderJpdbPageWordDefinitionState(root, card, target, generation, state);
41462}),
41463load.jitenVocabularyInfo.then((value) => {
41464state.jitenVocabularyInfo = value;
41465this.renderJpdbPageWordDefinitionState(root, card, target, generation, state);
41466}),
41467load.bunproDefinitionInfo.then((value) => {
41468state.bunproDefinitionInfo = value;
41469this.renderJpdbPageWordDefinitionState(root, card, target, generation, state);
41470})
41471];
41472void Promise.allSettled(tasks).then(() => {
41473if (!this.isCurrentJpdbPageEnhancement(generation) || !root.isConnected) return;
41474if (!this.hasJpdbPageWordContent(state.entries, state)) this.removeJpdbPageAddonRoot(root);
41475});
41476}
41477renderJpdbPageWordDefinitionState(root, card, target, generation, state) {
41478if (!this.isCurrentJpdbPageEnhancement(generation) || !root.isConnected || root.dataset.yomuGeneration !== String(generation)) return;
41479const entries2 = uniqueLocalDictionaryEntries(state.entries).sort(
41480(first, second) => dictionaryPreferencePriority(first.dictionary, this.settings) - dictionaryPreferencePriority(second.dictionary, this.settings)
41481).slice(0, this.settings.localDictionaryMaxResults);
41482if (!this.hasJpdbPageWordContent(entries2, state)) return;
41483const html = this.renderDefinitionSources(
41484card,
41485entries2,
41486target.examples[0]?.sentence,
41487state.jpdbVocabularyInfo,
41488state.jitenVocabularyInfo,
41489state.bunproDefinitionInfo
41490);
41491if (!this.updateJpdbPageAddonHtml(root, html)) return;
41492this.wanikaniSources.installDefinitionMounts(root, card);
41493this.installJpdbPageAddonHandlers(root, card);
41494this.dictionarySourceState.installTracking(root);
41495this.studySources.installLoaders(root, target.examples[0]?.sentence);
41496this.installJpdbPageImmersionExamples(root, card, [
41497...target.alternates,
41498...target.compounds.flatMap((compound) => [compound.term, compound.reading]),
41499...target.examples.map((example) => example.sentence)
41500]);
41501void this.parseJpdbPageAddonJapanese(root);
41502}
41503hasJpdbPageWordContent(entries2, data) {
41504return Boolean(
41505entries2.length || data?.jpdbVocabularyInfo && !isJpdbHost() || data?.jitenVocabularyInfo && !isJitenHost() || data?.bunproDefinitionInfo && !isBunproHost() || this.settings.immersionKitEnabled
41506);
41507}
41508jpdbPageWordCard(target) {
41509const card = jpdbAudioCard(target.term, target.reading);
41510card.source = isBunproHost() ? "bunpro" : isJitenHost() ? "jiten" : "jpdb";
41511return card;
41512}
41513jpdbPageWordAddonKey(target) {
41514return `word:${target.term}:${target.reading}`;
41515}
41516updateJpdbPageAddonHtml(root, html) {
41517const preservedImmersion = root.querySelector("[data-immersion-kit]");
41518const htmlChanged = root.dataset.yomuRenderedHtml !== html;
41519if (htmlChanged) {
41520root.dataset.yomuRenderedHtml = html;
41521setInnerHtml(root, html);
41522const nextImmersion = root.querySelector("[data-immersion-kit]");
41523if (preservedImmersion && nextImmersion && preservedImmersion !== nextImmersion) {
41524nextImmersion.replaceWith(preservedImmersion);
41525}
41526}
41527this.applyJpdbReviewImmersionLayout(root);
41528return htmlChanged;
41529}
41530applyJpdbReviewImmersionLayout(root) {
41531if (root.dataset.yomuPageContext !== "review") return;
41532const immersion = root.querySelector("[data-immersion-kit]");
41533if (!immersion) return;
41534const stack = immersion.parentElement;
41535if (stack?.firstElementChild !== immersion) stack?.prepend(immersion);
41536if (immersion.tagName !== "DETAILS" || immersion.dataset.yomuReviewAutoOpened === "true") return;
41537const details = immersion;
41538details.dataset.yomuReviewAutoOpened = "true";
41539details.dataset.sourceInitialOpen = "true";
41540details.open = true;
41541}
41542async lookupJpdbPageLocalEntries(target) {
41543if (!this.settings.localDictionariesEnabled) return [];
41544const variants = localDictionaryLookupVariants(target).filter((variant) => variant.term !== target.term || variant.reading !== target.reading).slice(0, 11);
41545const batches = await Promise.all(variants.map(
41546(variant) => this.dictionaries.lookup(
41547variant.term,
41548variant.reading || variant.term,
41549this.settings.localDictionaryMaxResults,
41550this.settings.dictionaryPreferences
41551).catch(() => [])
41552));
41553return uniqueLocalDictionaryEntries(batches.flat()).sort(
41554(first, second) => dictionaryPreferencePriority(first.dictionary, this.settings) - dictionaryPreferencePriority(second.dictionary, this.settings)
41555).slice(0, this.settings.localDictionaryMaxResults);
41556}
41557removeJpdbPageAddonRoot(root) {
41558this.immersionPopover?.abortPendingRequests(root);
41559abortPendingTargetExampleSources(root);
41560root.remove();
41561}
41562installJpdbKanjiPageEnhancement(generation) {
41563const kanji = currentPageKanji();
41564if (!isKanjiCharacter(kanji) || !this.isCurrentJpdbPageEnhancement(generation)) return;
41565const target = currentPageTermTarget();
41566const root = this.createJpdbPageAddonRoot("kanji", `kanji:${kanji}`, target?.anchor ?? document.body, generation);
41567if (!root) return;
41568const language = this.settings.interfaceLanguage;
41569const mounts = this.renderKanjiSourceMounts(kanji, language);
41570if (!mounts) {
41571root.remove();
41572return;
41573}
41574const card = jpdbAudioCard(kanji, kanji);
41575this.updateJpdbPageAddonHtml(root, `<div class="jpdb-reader-definition-stack jpdb-reader-kanji-section-stack">${mounts}</div>`);
41576this.installJpdbPageAddonHandlers(root, card);
41577this.dictionarySourceState.installTracking(root);
41578this.startKanjiProgressiveRender(root, this.kanjiDetailPromises(kanji), card, kanji, language, target ?? void 0);
41579void this.parseJpdbPageAddonJapanese(root);
41580}
41581createJpdbPageAddonRoot(kind, key, anchor, generation) {
41582if (!anchor.isConnected || anchor === document.body) return null;
41583const existing = Array.from(document.querySelectorAll(`[data-yomu-jpdb-addon="${kind}"]`)).find((element) => element.dataset.yomuAddonKey === key);
41584if (existing) {
41585existing.dataset.yomuGeneration = String(generation);
41586existing.dataset.yomuAnchorFallback = String(anchor.tagName === "MAIN");
41587existing.dataset.yomuPageContext = currentPageEnhancementLayoutContext();
41588this.syncReaderRootLanguage(existing);
41589this.applyJpdbReviewImmersionLayout(existing);
41590return existing;
41591}
41592const root = document.createElement("div");
41593root.dataset.jpdbReaderRoot = "true";
41594root.dataset.yomuJpdbAddon = kind;
41595root.dataset.yomuAddonKey = key;
41596root.dataset.yomuGeneration = String(generation);
41597root.dataset.yomuPageContext = currentPageEnhancementLayoutContext();
41598root.dataset.yomuAnchorFallback = String(anchor.tagName === "MAIN");
41599root.className = `yomu-jpdb-page-addon yomu-jpdb-${kind}-addon`;
41600this.syncReaderRootLanguage(root);
41601this.pauseAutoScanObserver(() => {
41602anchor.insertAdjacentElement("afterend", root);
41603});
41604queueMicrotask(() => {
41605if (root.isConnected) this.applyJpdbReviewImmersionLayout(root);
41606});
41607return root;
41608}
41609installJpdbPageAddonHandlers(root, fallbackCard) {
41610if (root.dataset.yomuHandlersInstalled === "true") return;
41611root.dataset.yomuHandlersInstalled = "true";
41612this.installReaderControlPointerActivation(root);
41613root.addEventListener("click", trustedReaderEventHandler((event) => this.handleJpdbPageAddonClick(event, root, fallbackCard)));
41614}
41615installReaderControlPointerActivation(root) {
41616installLocalTapActivation(root);
41617}
41618handleJpdbPageAddonClick(event, root, fallbackCard) {
41619if (!(event instanceof MouseEvent)) return;
41620if (this.handleDictionaryLookupLink(event, root, "modal")) return;
41621const actionButton = jpdbPageAddonActionButton(event, root);
41622if (!actionButton) return;
41623event.preventDefault();
41624event.stopPropagation();
41625this.dispatchJpdbPageAddonAction(actionButton, fallbackCard);
41626}
41627dispatchJpdbPageAddonAction(actionButton, fallbackCard) {
41628dispatchPrivateCommand(actionButton, {
41629"kanji-lookup": (command) => {
41630void this.showKanjiCard(fallbackCard, command.kanji, fallbackCard.spelling, actionButton, { navigation: "push-current", preservePosition: true });
41631},
41632"kanji-word": (command) => {
41633void this.lookupText(command.expression, command.reading || command.expression, { anchor: actionButton, navigation: "push-current", preservePosition: true, userGesture: true });
41634},
41635"jpdb-kanji-action": (command) => {
41636void this.performJpdbKanjiAction(command.actionId, fallbackCard, fallbackCard.spelling, fallbackCard.spelling, actionButton);
41637},
41638"card-action": (command) => {
41639void this.handleCardAction(actionButton, fallbackCard, command.sentence || fallbackCard.spelling, command);
41640}
41641});
41642}
41643isCurrentJpdbPageEnhancement(generation) {
41644return !this.isDestroyed && isPageEnhancementHost() && this.settings.jpdbPageEnhancementsEnabled && generation === this.jpdbPageEnhancementGeneration;
41645}
41646applyAccentColor(color) {
41647applyReaderAccentColor(color);
41648}
41649applyWordColors(settings2 = this.settings) {
41650applyReaderWordColors(settings2);
41651}
41652installFab() {
41653this.floatingButton.install(
41654this.settings,
41655() => void this.persistSettings(this.settings, { explicitUserChoiceKeys: NO_EXPLICIT_USER_CHOICE }),
41656{
41657openSettings: () => this.showSettings(),
41658openStudyPage: () => this.openStudyPage(),
41659cyclePowerState: () => this.cyclePowerState(),
41660powerState: () => this.puckPowerState(),
41661isPaused: () => this.settings.annotationsPaused,
41662toggleOcrMode: () => void this.cycleOcrMode(),
41663ocrMode: () => ocrInteractionModeFromSettings(this.settings),
41664toggleAutoPlayAudio: () => void this.toggleAutoPlayAudio(),
41665isAutoPlayAudioEnabled: () => this.isAutoPlayAudioEnabled(),
41666toggleJapaneseSiteLanguage: () => void this.togglePreferredJapaneseSiteLanguage(),
41667isYouTube: () => isYouTubeHostname(),
41668toggleYoutubeFilter: () => void this.toggleYoutubeImmersion(),
41669isYoutubeFilterEnabled: () => this.isYoutubeImmersionEnabled(),
41670toggleAutoSubtitles: () => void this.toggleAutoSubtitles(),
41671isAutoSubtitlesEnabled: () => this.settings.subtitleAutoDetect,
41672hasSubtitleVideo: () => this.settings.subtitlePlayerEnabled && this.subtitles.hasDiscoverableVideoCandidate()
41673}
41674);
41675}
41676async toggleAutoSubtitles() {
41677this.settings.subtitleAutoDetect = !this.settings.subtitleAutoDetect;
41678await this.persistSettings(this.settings, { explicitUserChoiceKeys: ["subtitleAutoDetect"] });
41679this.subtitles.destroy();
41680this.subtitles.init();
41681}
41682async toggleAnnotationsPaused() {
41683await this.setAnnotationsPaused(!this.settings.annotationsPaused);
41684}
41685async setAnnotationsPaused(paused) {
41686const changed = this.settings.annotationsPaused !== paused;
41687const previous = this.settings.annotationsPaused;
41688this.settings.annotationsPaused = paused;
41689if (changed) this.applyAnnotationsPausedState();
41690try {
41691await this.persistAnnotationPauseChoice();
41692} catch (error) {
41693this.rollbackAnnotationPauseChoice(previous, changed);
41694throw error;
41695}
41696if (changed) this.toastAnnotationPauseChoice(paused);
41697}
41698persistAnnotationPauseChoice() {
41699return this.persistSettings(this.settings, { explicitUserChoiceKeys: ["annotationsPaused"] });
41700}
41701rollbackAnnotationPauseChoice(previous, changed) {
41702this.settings.annotationsPaused = previous;
41703if (changed) this.applyAnnotationsPausedState();
41704this.toast(uiText$1(this.settings.interfaceLanguage, "settingsSaveFailed"));
41705}
41706toastAnnotationPauseChoice(paused) {
41707this.toast(uiText$1(this.settings.interfaceLanguage, paused ? "annotationsPausedToast" : "annotationsResumedToast"));
41708}
41709applyAnnotationsPausedState() {
41710if (this.settings.annotationsPaused) {
41711this.cancelPendingHoverLookup();
41712window.clearTimeout(this.autoScanTimer);
41713this.autoScanTimer = void 0;
41714this.autoScanDeadline = 0;
41715this.autoScanForced = false;
41716this.autoScanDebounced = false;
41717this.pageScanner.cancelVisiblePageScan();
41718this.clearAllAnnotations();
41719} else this.scheduleVisiblePageRescan();
41720this.subtitles.refresh();
41721this.ocr.refreshForModeChange();
41722}
41723scanPageNow() {
41724if (this.settings.annotationsPaused) return;
41725void this.pageScanner.scanVisiblePage({ silent: false });
41726}
41727isAutoPlayAudioEnabled() {
41728return this.settings.autoPlayAudio && this.settings.audioAutoPlayMode !== "off";
41729}
41730async toggleAutoPlayAudio() {
41731const enabled = this.isAutoPlayAudioEnabled();
41732this.settings.autoPlayAudio = !enabled;
41733if (!enabled && this.settings.audioAutoPlayMode === "off") this.settings.audioAutoPlayMode = "all";
41734await this.persistSettings(this.settings, { explicitUserChoiceKeys: ["autoPlayAudio", "audioAutoPlayMode"] });
41735this.toast(uiText$1(this.settings.interfaceLanguage, enabled ? "autoplayAudioOffToast" : "autoplayAudioOnToast"));
41736}
41737puckPowerState() {
41738return annotationPowerState(this.settings, usesJapaneseProviders());
41739}
41740async cyclePowerState() {
41741await applyAnnotationPowerTransition(
41742planAnnotationPowerTransition(this.settings, usesJapaneseProviders(), DEFAULT_SETTINGS.furiganaMode),
41743{
41744hideFurigana: async (rememberedMode) => {
41745this.settings.puckFuriganaModeBeforeHide = rememberedMode;
41746await this.applyFuriganaMode("off");
41747this.toast(uiText$1(this.settings.interfaceLanguage, "furiganaOffToast"));
41748},
41749pause: () => this.setAnnotationsPaused(true),
41750resume: async (furiganaMode) => {
41751this.settings.puckFuriganaModeBeforeHide = "";
41752if (furiganaMode) await this.applyFuriganaMode(furiganaMode);
41753await this.setAnnotationsPaused(false);
41754}
41755}
41756);
41757}
41758async applyFuriganaMode(mode) {
41759this.settings.showFurigana = this.settings.showFurigana || mode !== "off";
41760this.settings.furiganaMode = mode;
41761await this.persistSettings(this.settings, {
41762explicitUserChoiceKeys: ["showFurigana", "furiganaMode", "puckFuriganaModeBeforeHide"]
41763});
41764this.clearAllAnnotations();
41765this.scheduleVisiblePageRescan();
41766}
41767scheduleVisiblePageRescan() {
41768if (!this.settings.annotationsPaused && !this.settings.manualScanEnabled) this.scheduleAutoScan(0, { force: true });
41769}
41770async cycleOcrMode() {
41771const nextMode = nextOcrInteractionMode(ocrInteractionModeFromSettings(this.settings));
41772applyOcrInteractionMode(this.settings, nextMode);
41773await this.persistSettings(this.settings, { explicitUserChoiceKeys: ["ocrEnabled", "ocrAutoScanImages"] });
41774this.ocr.refreshForModeChange();
41775this.toast(uiText$1(this.settings.interfaceLanguage, ocrModeToastKey(nextMode)));
41776}
41777openStudyPage() {
41778const opened = window.open(NEW_TAB_PAGE_URL, "_blank");
41779if (opened) opened.opener = null;
41780else location.href = NEW_TAB_PAGE_URL;
41781}
41782clearAllAnnotations() {
41783clearProjectedReadingsWithin(document);
41784removeNonDestructiveScanMirrors(document);
41785releaseRubyRoomGrowth(document);
41786const changedParents = new Set();
41787document.querySelectorAll(".jpdb-reader-word, .jpdb-reader-furigana, .jpdb-reader-ruby, .jpdb-reader-number-bind").forEach((el) => {
41788const parent = el.parentNode;
41789if (parent) changedParents.add(parent);
41790if (el.classList.contains("jpdb-reader-word") || el.classList.contains("jpdb-reader-ruby") || el.classList.contains("jpdb-reader-number-bind")) {
41791const text2 = document.createTextNode(el.classList.contains("jpdb-reader-word") ? readerWordSurfaceText(el) : el.textContent || "");
41792el.replaceWith(text2);
41793} else {
41794el.remove();
41795}
41796});
41797changedParents.forEach((parent) => parent.normalize());
41798}
41799destroy(options = {}) {
41800this.isDestroyed = true;
41801this.destroySubscriptions();
41802this.destroyScanningInfrastructure();
41803this.destroyReaderServices();
41804this.clearReaderRuntimeWork();
41805this.destroyReaderSurfaces(options);
41806}
41807destroySubscriptions() {
41808this.destroyIntegrationSubscriptions();
41809this.destroyStateSubscriptions();
41810}
41811destroyIntegrationSubscriptions() {
41812this.disposeMokuroOcrToggleWatch?.();
41813this.disposeMokuroOcrToggleWatch = void 0;
41814this.disposeJpdbReviewBridge?.();
41815this.disposeJpdbReviewBridge = void 0;
41816}
41817destroyStateSubscriptions() {
41818this.unsubscribeCardStateSignals?.();
41819this.unsubscribeCardStateSignals = void 0;
41820this.unsubscribeSettingsStorageChanges?.();
41821this.unsubscribeSettingsStorageChanges = void 0;
41822}
41823destroyScanningInfrastructure() {
41824this.pageScanner.destroy?.();
41825this.factoryReset.destroy();
41826this.abortController.abort();
41827this.hostTheme.destroy();
41828window.cancelAnimationFrame(this.themeContrastRefreshFrame ?? 0);
41829window.clearTimeout(this.themeContrastRefreshTimer);
41830document.removeEventListener(NON_DESTRUCTIVE_SCAN_MIRROR_STALE_EVENT, this.handleNonDestructiveMirrorStale);
41831this.disposeShadowRootDiscovery?.();
41832this.disposeShadowRootDiscovery = void 0;
41833setShadowRootScanHook(null);
41834setCustomElementUpgradeHook(null);
41835setReviewCardFrontPredicate(null);
41836this.autoScanObserver?.disconnect();
41837this.documentBodyObserver?.dispose();
41838this.documentBodyObserver = void 0;
41839this.observedDocumentBody = void 0;
41840this.documentBodyRecoveryPending = false;
41841this.clearMiningPauseReassert();
41842this.clearSubtitleHoverMiningResumeTimer();
41843this.activePlainSubtitleHoverSurface = void 0;
41844}
41845destroyReaderServices() {
41846this.ocr.destroy();
41847this.subtitles.destroy();
41848this.youtube.destroy();
41849this.anki.destroy?.();
41850this.audio.destroy?.();
41851}
41852clearReaderRuntimeWork() {
41853window.clearTimeout(this.autoScanTimer);
41854this.autoScanForced = false;
41855window.clearTimeout(this.asbScanTimer);
41856window.clearTimeout(this.knownStateBackfillTimer);
41857this.knownStateBackfillTimer = void 0;
41858window.clearTimeout(this.visiblePageReparseTimer);
41859window.clearTimeout(this.jpdbPageEnhanceTimer);
41860this.jpdbPageEnhanceDeadline = 0;
41861window.clearTimeout(this.nearbyReaderAudioPreloadTimer);
41862window.clearTimeout(this.hoverLookupTimer);
41863this.hoverClose.stop();
41864this.nestedParseContentCache.clear();
41865this.pitchEnrichmentLocalCache.clear();
41866this.localPitchDictionaryAvailability = void 0;
41867this.resolvedFallbackVocabularyCache.clear();
41868this.unresolvedFallbackVocabularyCache.clear();
41869this.publicVocabularyMissRetries.clear();
41870this.misalignedPublicFuriganaRecoveries.clear();
41871this.fallbackVocabularyResolutionCache.clear();
41872this.clearPitchEnrichmentQueue();
41873this.lateCardReconciliation.resetPending();
41874window.clearTimeout(this.subtitleRebakeTimer);
41875this.subtitleRebakeTimer = void 0;
41876window.clearTimeout(this.cachedPublicVocabularyHydrationTimer);
41877this.cachedPublicVocabularyHydrationTimer = void 0;
41878this.pendingSubtitleRebakeTexts.clear();
41879this.clearRenderedWordIndex();
41880if (this.popoverRepositionFrame !== void 0) {
41881window.cancelAnimationFrame(this.popoverRepositionFrame);
41882this.popoverRepositionFrame = void 0;
41883}
41884this.popoverViewportChangePending = false;
41885if (this.hoverPointerMoveFrame !== void 0) {
41886window.cancelAnimationFrame(this.hoverPointerMoveFrame);
41887this.hoverPointerMoveFrame = void 0;
41888}
41889this.pendingHoverPointerMove = void 0;
41890this.activePopoverResizeObserver?.disconnect();
41891this.nativeTitleGuard.restore();
41892}
41893destroyReaderSurfaces(options) {
41894this.floatingButton.destroy();
41895this.settingsDialog?.releaseModalBackground();
41896this.lookupModal.release();
41897this.activePopover?.remove();
41898this.activeBackdrop?.remove();
41899this.removeJpdbPageEnhancements();
41900if (!options.preservePageWords) {
41901this.clearAllAnnotations();
41902}
41903this.dictionaryStyles.remove();
41904document.querySelectorAll("[data-jpdb-reader-root]").forEach((el) => el.remove());
41905}
41906setupAutoScan() {
41907const abortSignal = this.abortController.signal;
41908addViewportChangeListeners(() => this.scheduleActivePopoverViewportChange(), abortSignal);
41909this.autoScanObserver?.disconnect();
41910this.disposeShadowRootDiscovery?.();
41911this.disposeShadowRootDiscovery = installOpenShadowRootDiscovery();
41912this.autoScanObserver = new MutationObserver((mutations) => {
41913const canScanText = this.canParseJapanese();
41914const scanMutations = [];
41915let renderRejectionDelay = null;
41916for (const mutation of mutations) {
41917if (mutationContainsOnlyReaderPaint(mutation)) continue;
41918const delay2 = readerRenderRejectionRescanDelay(mutation);
41919if (delay2 !== null) {
41920renderRejectionDelay = Math.max(renderRejectionDelay ?? 0, delay2);
41921continue;
41922}
41923scanMutations.push(mutation);
41924}
41925const removedScannedShadowRoot = scanMutations.some(
41926(mutation) => mutation.type === "childList" && mutation.removedNodes.length > 0
41927) && sweepDisconnectedShadowRoots();
41928if (removedScannedShadowRoot) {
41929this.autoScanObserver?.disconnect();
41930this.observeAutoScanMutations();
41931}
41932const mutationsOnlyInsideReaderRoot = scanMutations.length > 0 && scanMutations.every(mutationInsideReaderRoot);
41933const japaneseScanBudget = createMutationJapaneseScanBudget();
41934const mutationScopeRoots = annotationScopeRoots();
41935const mutationHasJapaneseText = mutationScanProbeCanProduceWork(this.settings, canScanText) && !mutationsOnlyInsideReaderRoot ? scanMutations.reduce(
41936(found, mutation) => mutationMayContainJapaneseText(
41937mutation,
41938japaneseScanBudget,
41939mutationScopeRoots
41940) || found,
41941false
41942) : false;
41943if (scanMutations.some(
41944(mutation) => mutationMayExpandAnnotationScope(mutation, mutationScopeRoots)
41945)) {
41946this.observeScopedScannedShadowRoots();
41947}
41948if (canScanText && renderRejectionDelay !== null) this.scheduleAutoScan(renderRejectionDelay, { force: true, debounce: true });
41949if (canScanText && scanMutations.some(mutationTouchesAsbPlayer)) this.scheduleAsbPlayerScan(120);
41950else if (mutationsOnlyInsideReaderRoot) return;
41951else if (mutationHasJapaneseText) {
41952this.pageHasJapaneseText = true;
41953this.noteVisibleAutoScanWorkObserved();
41954this.scheduleAutoScan(visibleAutoScanMutationDelay(), {
41955force: true,
41956debounce: isYouTubeHostname()
41957});
41958}
41959if (isJitenHost()) {
41960this.maybeScrollJitenStudyToNewCard();
41961this.prefetchJitenStudyImmersion();
41962if (this.jitenEnhancementsNeedRefresh()) {
41963this.scheduleJpdbPageEnhancements(0, { preserveEarlier: true });
41964}
41965} else if (isBunproHost()) {
41966if (this.jitenEnhancementsNeedRefresh()) {
41967this.scheduleJpdbPageEnhancements(0, { preserveEarlier: true });
41968}
41969} else if (isPageEnhancementHost() && scanMutations.some(mutationMayAffectJpdbPageEnhancements)) {
41970this.scheduleJpdbPageEnhancements(0, { preserveEarlier: true });
41971}
41972});
41973this.setupDocumentBodyRecovery();
41974setReviewCardFrontPredicate(isReviewCardFrontPromptElement);
41975setShadowRootScanHook((root, cause) => {
41976if (this.isDestroyed || !nodeWithinAnnotationScope(root)) return;
41977this.autoScanObserver?.observe(root, AUTO_SCAN_OBSERVER_OPTIONS);
41978if (cause === "attached" && root.childNodes.length) {
41979this.scheduleAutoScan(0, { force: true, debounce: true });
41980}
41981});
41982setCustomElementUpgradeHook(() => {
41983if (this.isDestroyed || !this.canParseJapanese()) return;
41984this.scheduleAutoScan(visibleAutoScanMutationDelay(), { force: true, debounce: true });
41985});
41986this.observeAutoScanMutations();
41987if (!this.pageHasJapaneseText) {
41988this.pageHasJapaneseText = detectReaderStartupJapaneseText();
41989}
41990document.addEventListener("visibilitychange", () => this.handleAutoScanVisibilityChange(), { signal: abortSignal });
41991window.addEventListener("scroll", (event) => {
41992if (document.hidden) return;
41993if (eventTargetsReaderRoot(event)) return;
41994this.scheduleAutoScan(visibleAutoScanMutationDelay(160), { force: true, debounce: true });
41995}, { passive: true, capture: true, signal: abortSignal });
41996window.addEventListener("resize", () => {
41997if (document.hidden) return;
41998this.scheduleAutoScan(250, { force: true, debounce: isYouTubeHostname() });
41999}, { passive: true, signal: abortSignal });
42000document.addEventListener("click", trustedReaderEventHandler((event) => this.handleAutoScanClick(event)), { capture: true, signal: abortSignal });
42001window.addEventListener("resize", () => this.scheduleJpdbPageEnhancements(700), { passive: true, signal: abortSignal });
42002if (this.hasVisibleAutoScanWork()) this.scheduleAutoScan(visibleAutoScanInitialDelay());
42003document.addEventListener(NON_DESTRUCTIVE_SCAN_MIRROR_STALE_EVENT, this.handleNonDestructiveMirrorStale);
42004}
42005handleAutoScanClick(event) {
42006if (this.clickMayRevealReviewAnswer(event)) this.scheduleJpdbPageEnhancements(0, { preserveEarlier: true });
42007if (!this.clickMayRevealDynamicUiText(event)) return;
42008this.noteVisibleAutoScanWorkObserved();
42009this.scheduleAutoScan(visibleAutoScanMutationDelay(), { force: true, debounce: true });
42010}
42011clickMayRevealReviewAnswer(event) {
42012return !document.hidden && isPageEnhancementHost() && clickMayRevealReviewAnswer(event);
42013}
42014clickMayRevealDynamicUiText(event) {
42015return !document.hidden && this.canParseJapanese() && clickMayRevealDynamicUiText(event);
42016}
42017handleAutoScanVisibilityChange() {
42018if (this.isDestroyed) return;
42019if (document.hidden) {
42020this.autoScanObserver?.disconnect();
42021window.clearTimeout(this.autoScanTimer);
42022this.autoScanTimer = void 0;
42023this.autoScanDeadline = 0;
42024this.autoScanForced = false;
42025this.autoScanDebounced = false;
42026window.clearTimeout(this.asbScanTimer);
42027this.asbScanTimer = void 0;
42028window.clearTimeout(this.knownStateBackfillTimer);
42029this.knownStateBackfillTimer = void 0;
42030this.pageScanner.pauseGeometrySweeps();
42031return;
42032}
42033if (this.documentBodyRecoveryPending) this.recoverAfterDocumentBodyReplacement();
42034else this.observeAutoScanMutations();
42035wakeShadowHostPoll();
42036this.scheduleReaderKnownStateBackfill();
42037if (this.hasVisibleAutoScanWork()) this.scheduleAutoScan(visibleAutoScanInitialDelay());
42038}
42039observeAutoScanMutations() {
42040if (!document.body) {
42041document.addEventListener("DOMContentLoaded", () => {
42042if (!this.isDestroyed) this.observeAutoScanMutations();
42043}, { once: true });
42044return;
42045}
42046this.autoScanObserver?.observe(document.body, AUTO_SCAN_OBSERVER_OPTIONS);
42047this.observeScopedScannedShadowRoots();
42048}
42049setupDocumentBodyRecovery() {
42050this.documentBodyObserver?.dispose();
42051this.observedDocumentBody = document.body ?? void 0;
42052if (!document.documentElement) return;
42053this.documentBodyObserver = parkableMutationObserver(() => this.handleDocumentBodyReplacement(), {
42054reconcile: () => this.handleDocumentBodyReplacement(),
42055signal: this.abortController.signal
42056});
42057this.documentBodyObserver?.observe(document.documentElement, { childList: true });
42058}
42059handleDocumentBodyReplacement() {
42060const body = document.body ?? void 0;
42061if (this.isDestroyed || !body || body === this.observedDocumentBody) return;
42062this.observedDocumentBody = body;
42063this.documentBodyRecoveryPending = true;
42064if (!document.hidden) this.recoverAfterDocumentBodyReplacement();
42065}
42066recoverAfterDocumentBodyReplacement() {
42067if (this.isDestroyed || !document.body || !this.documentBodyRecoveryPending) return;
42068this.documentBodyRecoveryPending = false;
42069this.autoScanObserver?.disconnect();
42070this.observeAutoScanMutations();
42071this.pageScanner.repointGeometrySettleTarget?.();
42072this.disposeJpdbReviewBridge?.();
42073this.disposeJpdbReviewBridge = installReaderStartupBridge();
42074if (!this.embeddedFrame) this.installFab();
42075if (this.canParseJapanese()) {
42076this.noteVisibleAutoScanWorkObserved();
42077this.scheduleAutoScan(0, { force: true, debounce: true });
42078}
42079if (isPageEnhancementHost()) {
42080this.scheduleJpdbPageEnhancements(0, { preserveEarlier: true });
42081}
42082}
42083observeScopedScannedShadowRoots() {
42084forEachScannedShadowRoot((root) => {
42085if (nodeWithinAnnotationScope(root)) this.autoScanObserver?.observe(root, AUTO_SCAN_OBSERVER_OPTIONS);
42086});
42087}
42088shouldScanEmbeddedFrame() {
42089return /(^|\.)youtube\.com$/i.test(location.hostname) && (location.pathname === "/live_chat" || location.pathname === "/live_chat_replay");
42090}
42091pauseAutoScanObserver(callback) {
42092const observer = this.autoScanObserver;
42093if (!observer) return callback();
42094observer.disconnect();
42095try {
42096return callback();
42097} finally {
42098if (!this.isDestroyed && this.autoScanObserver === observer) this.observeAutoScanMutations();
42099}
42100}
42101scheduleAutoScan(delay2, options = {}) {
42102const forced = Boolean(options.force);
42103if (!this.canScheduleAutoScan(forced)) return;
42104delay2 = throttledAutoScanDelay(delay2, options, this.lastAutoScanStartedAt, Date.now());
42105const now = Date.now();
42106const deadline = now + delay2;
42107if (this.autoScanTimer && this.autoScanDeadline <= deadline) {
42108this.autoScanForced = this.autoScanForced || forced;
42109const cappedDeadline = debouncedAutoScanDeadline(deadline, this.autoScanDebounceStartedAt);
42110if (options.debounce && this.autoScanDebounced && this.autoScanDeadline < cappedDeadline) {
42111window.clearTimeout(this.autoScanTimer);
42112this.autoScanDeadline = cappedDeadline;
42113this.autoScanTimer = window.setTimeout(() => {
42114this.runScheduledAutoScan();
42115}, cappedDeadline - now);
42116}
42117return;
42118}
42119window.clearTimeout(this.autoScanTimer);
42120this.autoScanForced = forced;
42121this.autoScanDebounced = Boolean(options.debounce);
42122if (this.autoScanDebounced) this.autoScanDebounceStartedAt = now;
42123this.autoScanDeadline = deadline;
42124this.autoScanTimer = window.setTimeout(() => {
42125this.runScheduledAutoScan();
42126}, delay2);
42127}
42128canScheduleAutoScan(force = false) {
42129if (this.settings.annotationsPaused || this.settings.manualScanEnabled) return false;
42130if (this.isDestroyed || !this.canParseJapanese()) return false;
42131if (typeof document !== "undefined" && document.hidden) return false;
42132return force || this.autoScanTimer !== void 0 || this.hasVisibleAutoScanWorkCached();
42133}
42134runScheduledAutoScan() {
42135if (this.isDestroyed || this.settings.annotationsPaused || this.settings.manualScanEnabled || typeof document !== "undefined" && document.hidden) {
42136this.autoScanTimer = void 0;
42137this.autoScanDeadline = 0;
42138this.autoScanForced = false;
42139this.autoScanDebounced = false;
42140return;
42141}
42142const forced = this.autoScanForced;
42143this.autoScanTimer = void 0;
42144this.autoScanDeadline = 0;
42145this.autoScanForced = false;
42146this.autoScanDebounced = false;
42147this.lastAutoScanStartedAt = Date.now();
42148if (typeof this.pageScanner.scanAsbPlayerSubtitles === "function") void this.pageScanner.scanAsbPlayerSubtitles();
42149if (forced || hasVisibleAutoScanTargets()) void this.pageScanner.scanVisiblePage({ silent: true });
42150}
42151hasVisibleAutoScanWork() {
42152return hasVisibleSiteScanTargets() || allowsGenericVisibleAutoScan() && this.pageHasJapaneseText;
42153}
42154hasVisibleAutoScanWorkCached() {
42155const now = Date.now();
42156if (this.visibleAutoScanWorkVerdict && now - this.visibleAutoScanWorkVerdict.at < VISIBLE_AUTO_SCAN_WORK_VERDICT_TTL_MS) {
42157return this.visibleAutoScanWorkVerdict.verdict;
42158}
42159const verdict = this.hasVisibleAutoScanWork();
42160this.visibleAutoScanWorkVerdict = { at: now, verdict };
42161return verdict;
42162}
42163noteVisibleAutoScanWorkObserved() {
42164this.visibleAutoScanWorkVerdict = { at: Date.now(), verdict: true };
42165}
42166scheduleAsbPlayerScan(delay2) {
42167if (this.isDestroyed) return;
42168if (!this.canParseJapanese()) return;
42169window.clearTimeout(this.asbScanTimer);
42170this.asbScanTimer = window.setTimeout(() => void this.pageScanner.scanAsbPlayerSubtitles(), delay2);
42171}
42172bindEvents() {
42173const abortSignal = this.abortController.signal;
42174installTrustedReaderRootBoundary(document, abortSignal);
42175bindReaderRuntimeEvents({
42176getSettings: () => this.settings,
42177setSettings: (settings2) => {
42178this.settings = settings2;
42179},
42180isDestroyed: () => this.isDestroyed,
42181showSettings: (panel) => this.showSettings(panel),
42182setInterfaceLanguage: (language) => this.setInterfaceLanguage(language),
42183applyTheme: () => this.applyTheme(),
42184saveSettings: (settings2, explicitUserChoiceKeys) => this.persistSettings(settings2, { explicitUserChoiceKeys }),
42185clearBridgeCaches: () => this.clearBridgeBackedCaches()
42186}, this.abortController.signal);
42187subscribeToSettingsChanges(() => {
42188if (!this.isDestroyed) {
42189this.syncReaderRootLanguages();
42190}
42191}, abortSignal);
42192const swallowReaderRootGesture = (event) => {
42193const swallow = event.type === "touchstart" || event.type === "touchend" ? pointOverReaderRoot(event) : readerRootGestureLeaks(event);
42194if (swallow) event.stopPropagation();
42195};
42196for (const gestureType of READER_ROOT_GESTURE_EVENTS) {
42197document.addEventListener(gestureType, swallowReaderRootGesture, { capture: true, passive: true, signal: abortSignal });
42198}
42199let scrollDragBody = null;
42200let scrollDragLastY = 0;
42201const onScrollDragStart = trustedReaderEventHandler((event) => {
42202scrollDragBody = eventTargetsInteractiveControl(event) ? null : readerScrollBodyForEvent(event);
42203scrollDragLastY = event.touches[0]?.clientY ?? 0;
42204});
42205const onScrollDragMove = (event) => {
42206if (!scrollDragBody?.isConnected || event.touches.length > 1) return;
42207const y = event.touches[0]?.clientY;
42208if (typeof y !== "number") return;
42209const consumed = manualScrollReaderBody(scrollDragBody, scrollDragLastY - y);
42210scrollDragLastY = y;
42211if (consumed && event.cancelable) event.preventDefault();
42212};
42213const endScrollDrag = trustedReaderEventHandler((_event) => {
42214scrollDragBody = null;
42215});
42216const onScrollWheel = (event) => {
42217if (eventTargetsInteractiveControl(event)) return;
42218const body = readerScrollBodyForEvent(event);
42219const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaMode === 2 ? event.deltaY * (body?.clientHeight ?? 0) : event.deltaY;
42220if (!body || !manualScrollReaderBody(body, px)) return;
42221if (event.cancelable) event.preventDefault();
42222};
42223const trustedOnScrollDragMove = trustedReaderEventHandler(onScrollDragMove);
42224const trustedOnScrollWheel = trustedReaderEventHandler(onScrollWheel);
42225let scrollDriveAttached = false;
42226const setScrollDrive = (on) => {
42227if (on === scrollDriveAttached) return;
42228scrollDriveAttached = on;
42229if (on) {
42230document.addEventListener("touchstart", onScrollDragStart, { capture: true, passive: true });
42231document.addEventListener("touchmove", trustedOnScrollDragMove, { capture: true, passive: false });
42232document.addEventListener("touchend", endScrollDrag, { capture: true, passive: true });
42233document.addEventListener("touchcancel", endScrollDrag, { capture: true, passive: true });
42234document.addEventListener("wheel", trustedOnScrollWheel, { capture: true, passive: false });
42235return;
42236}
42237document.removeEventListener("touchstart", onScrollDragStart, true);
42238document.removeEventListener("touchmove", trustedOnScrollDragMove, true);
42239document.removeEventListener("touchend", endScrollDrag, true);
42240document.removeEventListener("touchcancel", endScrollDrag, true);
42241document.removeEventListener("wheel", trustedOnScrollWheel, true);
42242};
42243const syncScrollDrive = () => setScrollDrive(Boolean(document.querySelector(READER_ROOT_SCROLL_BODY_SELECTOR)));
42244const scrollDriveObserver = parkableMutationObserver(syncScrollDrive, {
42245reconcile: syncScrollDrive,
42246signal: abortSignal
42247});
42248let scrollDriveObservedRoot = null;
42249const observeScrollDriveRoot = () => {
42250const root = document.body ?? document.documentElement;
42251if (!root) {
42252document.addEventListener("DOMContentLoaded", () => {
42253if (!this.isDestroyed) observeScrollDriveRoot();
42254}, { once: true });
42255return;
42256}
42257if (scrollDriveObservedRoot === root) return;
42258scrollDriveObserver?.disconnect();
42259scrollDriveObservedRoot = root;
42260scrollDriveObserver?.observe(root, { childList: true });
42261syncScrollDrive();
42262};
42263const rebindScrollDriveRoot = () => {
42264syncScrollDrive();
42265observeScrollDriveRoot();
42266};
42267scrollDriveObserver?.disconnect();
42268const rootObserver = parkableMutationObserver(rebindScrollDriveRoot, {
42269reconcile: observeScrollDriveRoot,
42270signal: abortSignal
42271});
42272const htmlRoot = document.documentElement;
42273if (htmlRoot) rootObserver?.observe(htmlRoot, { childList: true });
42274observeScrollDriveRoot();
42275this.abortController.signal.addEventListener("abort", () => {
42276setScrollDrive(false);
42277scrollDriveObserver?.dispose();
42278rootObserver?.dispose();
42279}, { once: true });
42280document.addEventListener("click", (event) => this.handleDocumentClick(event), { capture: true, signal: abortSignal });
42281document.addEventListener("mousedown", trustedReaderEventHandler((event) => {
42282if (this.isDestroyed) return;
42283if (!this.shouldCaptureMiddleMouseLookup(event)) return;
42284event.preventDefault();
42285event.stopPropagation();
42286}), { capture: true, passive: false, signal: abortSignal });
42287document.addEventListener("auxclick", trustedReaderEventHandler((event) => {
42288if (this.isDestroyed) return;
42289if (event.button !== 1 || Date.now() > this.suppressMiddleAuxClickUntil) return;
42290event.preventDefault();
42291event.stopPropagation();
42292}), { capture: true, signal: abortSignal });
42293document.addEventListener("pointerdown", trustedReaderEventHandler((event) => {
42294this.primeLookupAudioFromFirstGesture();
42295this.clearLatchedHoverPopoverPointerForOutsideEvent(event.target);
42296if ([
42297() => miningDrawerHandleForPointerEvent(event),
42298() => this.retireHoverForLookupFreeTarget(event.target)
42299].some((handle) => handle())) return;
42300this.suppressHoverAfterPenContact(event);
42301if (this.handleOcrReaderWordPointerDown(event)) return;
42302this.beginTapLookup(event);
42303this.beginLinkPressLookup(event);
42304this.pinHoverPopoverForInsidePointer(event);
42305this.dismissModalPopoverForOutsidePointer(event);
42306this.dismissHoverPopoverForOutsidePointer(event);
42307this.beginPressLookup(event);
42308}), { capture: true, passive: false, signal: abortSignal });
42309document.addEventListener("pointermove", trustedReaderEventHandler((event) => {
42310this.updateTapLookup(event);
42311this.updateLinkPressLookup(event);
42312this.updatePressLookup(event);
42313}), { capture: true, passive: false, signal: abortSignal });
42314document.addEventListener("pointerup", trustedReaderEventHandler((event) => {
42315this.finishTapLookup(event);
42316this.cancelLinkPressLookup(event);
42317this.endPressLookup(event);
42318}), { capture: true, signal: abortSignal });
42319document.addEventListener("pointercancel", trustedReaderEventHandler((event) => {
42320this.cancelTapLookup(event);
42321this.cancelLinkPressLookup(event);
42322this.endPressLookup(event);
42323}), { capture: true, signal: abortSignal });
42324document.addEventListener("contextmenu", trustedReaderEventHandler((event) => {
42325if (!this.linkPressLookup && Date.now() >= this.suppressLinkContextMenuUntil) return;
42326event.preventDefault();
42327event.stopPropagation();
42328}), { capture: true, signal: abortSignal });
42329document.addEventListener("pointerover", trustedReaderEventHandler((event) => {
42330this.clearLatchedHoverPopoverPointerForOutsideEvent(event.target);
42331this.handleHoverPointer(event);
42332}), { capture: true, signal: abortSignal });
42333document.addEventListener("pointermove", trustedReaderEventHandler((event) => {
42334this.clearLatchedHoverPopoverPointerForOutsideEvent(event.target);
42335this.queueHoverPointerMove(event);
42336}), { capture: true, signal: abortSignal });
42337document.addEventListener("pointerout", trustedReaderEventHandler((event) => {
42338this.handleHoverPointerOut(event);
42339}), { capture: true, signal: abortSignal });
42340if (!window.PointerEvent) {
42341document.addEventListener("mouseover", trustedReaderEventHandler((event) => {
42342this.handleHoverPointer(event);
42343}), { capture: true, signal: abortSignal });
42344document.addEventListener("mousemove", trustedReaderEventHandler((event) => {
42345this.queueHoverPointerMove(event);
42346}), { capture: true, signal: abortSignal });
42347document.addEventListener("mouseout", trustedReaderEventHandler((event) => {
42348this.handleHoverPointerOut(event);
42349}), { capture: true, signal: abortSignal });
42350}
42351document.addEventListener("keydown", this.handleDocumentKeydown, { signal: abortSignal });
42352document.addEventListener("keyup", trustedReaderEventHandler((event) => this.handleDocumentKeyup(event)), { signal: abortSignal });
42353window.addEventListener("blur", () => {
42354this.pressedKeys.clear();
42355this.cancelPendingHoverLookup();
42356if (this.activePopoverMode === "hover") this.scheduleHoverClose(0, { ignoreCssHover: true });
42357}, { signal: abortSignal });
42358}
42359handleDocumentClick(event) {
42360if (!isTrustedReaderInteraction(event)) return;
42361const target = this.documentClickTarget(event);
42362if (!target) return;
42363if (this.handleDocumentReaderWordClick(event, target)) return;
42364this.handleDocumentNonWordClick(event);
42365}
42366documentClickTarget(event) {
42367if (this.isDestroyed) return null;
42368if (miningDrawerHandleForPointerEvent(event)) return null;
42369const target = event.target;
42370return this.documentClickTargetIgnored(target) ? null : target;
42371}
42372documentClickTargetIgnored(target) {
42373return shouldIgnoreDocumentClickTarget(target) || Boolean(target.closest?.(TOKEN_LIST_POPOVER_CONTROL_SELECTOR));
42374}
42375handleDocumentReaderWordClick(event, target) {
42376const word = this.readerWordForPointerEvent(event, { clickLookup: true });
42377if (!word) return Boolean(target.closest?.("[data-jpdb-reader-root] a.gloss-link[data-dictionary-lookup]"));
42378this.handleReaderWordClick(event, word);
42379return true;
42380}
42381handleDocumentNonWordClick(event) {
42382const insideActivePopover = this.activePopoverMode === "modal" && this.isInsideActivePopover(event.target);
42383if (this.pauseForSubtitleSurfaceTap(event)) return;
42384this.handleDocumentLookupCandidateClick(event, insideActivePopover);
42385}
42386handleDocumentKeyup(event) {
42387this.pressedKeys.delete(normalizePressedKey(event.key));
42388if (!this.shouldCloseHoverAfterKeyup(event)) return;
42389this.cancelPendingHoverLookup();
42390if (this.activePopoverMode === "hover") this.scheduleHoverClose(0, { ignoreCssHover: true });
42391}
42392shouldCloseHoverAfterKeyup(event) {
42393return Boolean(this.settings.shortcuts.hoverLookup?.trim()) && !this.shouldLookupOnHover(event);
42394}
42395handleDocumentLookupCandidateClick(event, insideActivePopover) {
42396if (this.settings.popupActivationMode === "off" && !insideActivePopover) return;
42397if (!this.settings.lookupOnClick && !insideActivePopover) return;
42398const candidate = this.lookupCandidateFromPoint(event.clientX, event.clientY, event.target);
42399if (!candidate) return;
42400event.preventDefault();
42401event.stopPropagation();
42402this.prepareModalLookupFromPointer(event);
42403void this.showLookupCandidate(candidate, "modal", {
42404navigation: insideActivePopover ? "push-current" : "reset",
42405preservePosition: insideActivePopover,
42406userGesture: true
42407});
42408}
42409handleReaderWordClick(event, word) {
42410const surfaces = this.readerWordClickSurfaces(event, word);
42411if (!surfaces) return;
42412this.openReaderWordFromPointer(event, word, surfaces);
42413}
42414beginTapLookup(event) {
42415this.tapLookup = void 0;
42416if (this.isDestroyed || event.button !== 0 || event.isPrimary === false || event.pointerType !== "touch" && event.pointerType !== "pen" || this.isInsideActivePopover(event.target)) return;
42417const word = this.readerWordForPointerEvent(event, { clickLookup: true });
42418if (word && this.isNativeWord(word)) return;
42419if (word && !this.readerWordClickSurfaces(event, word)) return;
42420this.tapLookup = { id: event.pointerId, x: event.clientX, y: event.clientY, word: word ?? void 0 };
42421if (word) this.primeLookupAudioFromGesture();
42422}
42423updateTapLookup(event) {
42424const tap = this.tapLookup;
42425if (!tap || tap.id !== event.pointerId) return;
42426if (Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 12) this.tapLookup = void 0;
42427}
42428finishTapLookup(event) {
42429const tap = this.tapLookup;
42430if (!tap || tap.id !== event.pointerId) return;
42431this.tapLookup = void 0;
42432const releaseWord = this.readerWordForPointerEvent(event, { clickLookup: true });
42433const fallbackWord = tap.word?.isConnected && this.readerWordMatchesPointerGeometry(tap.word, event.clientX, event.clientY) ? tap.word : void 0;
42434const word = releaseWord ?? fallbackWord;
42435if (!word?.isConnected) return;
42436const surfaces = this.readerWordClickSurfaces(event, word);
42437if (!surfaces) return;
42438this.suppressWordClickUntil = Date.now() + 700;
42439if (word) this.primeLookupAudioFromGesture();
42440this.openReaderWordFromPointer(event, word, surfaces);
42441}
42442cancelTapLookup(event) {
42443const tap = this.tapLookup;
42444if (tap && tap.id === event.pointerId) this.tapLookup = void 0;
42445}
42446beginLinkPressLookup(event) {
42447this.clearLinkPressLookup();
42448if (this.isDestroyed || event.button !== 0 || event.isPrimary === false || event.pointerType !== "touch" && event.pointerType !== "pen" || this.settings.popupActivationMode === "off" || this.isInsideActivePopover(event.target)) return;
42449const word = this.linkPressLookupWord(event);
42450if (!word) return;
42451const timer = window.setTimeout(() => this.fireLinkPressLookup(), LINK_PRESS_LOOKUP_MS);
42452this.linkPressLookup = { id: event.pointerId, x: event.clientX, y: event.clientY, word, timer };
42453this.primeLookupAudioFromGesture();
42454}
42455linkPressLookupWord(event) {
42456const word = this.readerWordForPointerEvent(event, { clickLookup: true });
42457if (!word || this.isNativeWord(word)) return null;
42458if (word.dataset.jpdbReaderPassive === "true" && !canClickLookupPassiveReaderWordElement(word)) return null;
42459if (word.closest(".jpdb-reader-popover") || word.closest(SUBTITLE_SURFACE_SELECTOR)) return null;
42460return nativeClickableAncestor(documentPortalSourceHostForReaderWord(word) ?? word) instanceof HTMLAnchorElement ? word : null;
42461}
42462updateLinkPressLookup(event) {
42463const press = this.linkPressLookup;
42464if (!press || press.id !== event.pointerId) return;
42465if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 12) this.clearLinkPressLookup();
42466}
42467cancelLinkPressLookup(event) {
42468if (this.linkPressLookup?.id === event.pointerId) this.clearLinkPressLookup();
42469}
42470clearLinkPressLookup() {
42471const press = this.linkPressLookup;
42472if (!press) return;
42473window.clearTimeout(press.timer);
42474this.linkPressLookup = void 0;
42475}
42476fireLinkPressLookup() {
42477const press = this.linkPressLookup;
42478this.linkPressLookup = void 0;
42479if (!press || this.isDestroyed || !press.word.isConnected) return;
42480const now = Date.now();
42481this.suppressWordClickUntil = now + 1200;
42482this.suppressLinkContextMenuUntil = now + 1200;
42483this.lastPointerPosition = { x: press.x, y: press.y };
42484this.cancelPendingHoverLookup();
42485this.cancelHoverClose();
42486this.pinOcrLineForModalLookup(press.word);
42487if (this.shouldPauseForLookupAnchor(press.word)) this.pauseVideoForSubtitleMining();
42488const candidate = this.renderedWordPointerLookupCandidate(press.word, press.x, press.y, press.word);
42489if (candidate) {
42490void this.showLookupCandidate(candidate, "modal", { userGesture: true }).finally(() => this.releaseOrphanedModalOcrPin());
42491return;
42492}
42493this.releaseOrphanedModalOcrPin();
42494}
42495openReaderWordFromPointer(event, word, surfaces) {
42496if (!surfaces.n) {
42497event.preventDefault();
42498event.stopPropagation();
42499}
42500this.prepareModalLookupFromPointer(event);
42501this.pinOcrLineForModalLookup(word);
42502if (this.shouldPauseForLookupAnchor(word)) this.pauseVideoForSubtitleMining();
42503const candidate = this.renderedWordLookupCandidateForActivation(word, event);
42504if (candidate) {
42505void this.showLookupCandidate(candidate, "modal", {
42506navigation: surfaces.r ? "push-current" : "reset",
42507preservePosition: surfaces.r,
42508userGesture: true
42509}).finally(() => this.releaseOrphanedModalOcrPin());
42510return;
42511}
42512void this.showWord(word, {
42513trigger: "click",
42514userGesture: true,
42515navigation: surfaces.r ? "push-current" : "reset"
42516}).finally(() => this.releaseOrphanedModalOcrPin());
42517}
42518handleOcrReaderWordPointerDown(event) {
42519const word = this.ocrPointerDownReaderWord(event);
42520if (!word) return false;
42521const surfaces = this.readerWordClickSurfaces(event, word);
42522if (!surfaces) return false;
42523event.preventDefault();
42524this.prepareModalLookupFromPointer(event);
42525const now = Date.now();
42526this.suppressWordClickUntil = now + 700;
42527this.pinOcrLineForModalLookup(word);
42528const candidate = this.renderedWordLookupCandidateForActivation(word, event);
42529if (candidate) {
42530void this.showLookupCandidate(candidate, "modal", {
42531navigation: surfaces.r ? "push-current" : "reset",
42532preservePosition: surfaces.r,
42533userGesture: true
42534}).finally(() => this.releaseOrphanedModalOcrPin());
42535return true;
42536}
42537void this.showWord(word, {
42538trigger: "click",
42539userGesture: true,
42540navigation: surfaces.r ? "push-current" : "reset"
42541}).finally(() => this.releaseOrphanedModalOcrPin());
42542return true;
42543}
42544ocrPointerDownReaderWord(event) {
42545if (event.button !== 0 || event.pointerType !== "touch" && event.pointerType !== "pen") return null;
42546const target = event.target instanceof Element ? event.target : null;
42547return target?.closest(".jpdb-ocr-line .jpdb-reader-word") ?? this.ocrLineWordForPointer(target, event.clientX, event.clientY);
42548}
42549readerWordClickSurfaces(event, word) {
42550if (!this.canClickLookupReaderWord(word)) return null;
42551const p = canClickLookupPassiveReaderWordElement(word);
42552if (word.dataset.jpdbReaderPassive === "true" && !p) return null;
42553if (this.consumeSuppressedReaderWordClick(event, word)) return null;
42554const r = Boolean(word.closest(".jpdb-reader-popover"));
42555const s = Boolean(word.closest(SUBTITLE_SURFACE_SELECTOR));
42556if (this.settings.popupActivationMode === "off" && !r) return null;
42557const l = nativeClickableAncestor(documentPortalSourceHostForReaderWord(word) ?? word);
42558const n = this.isNativeWord(word) && !r && !s && !this.clickForcesReaderWordLookup(event);
42559if (!r && !s && l && !this.clickForcesReaderWordLookup(event) && !n) {
42560return null;
42561}
42562if (!this.settings.lookupOnClick && !r && !s) return null;
42563return { r, s, n };
42564}
42565isNativeWord(word) {
42566return Boolean(word.closest(".jpdb-reader-native-canvas"));
42567}
42568consumeSuppressedReaderWordClick(event, word) {
42569const pointerType = event.pointerType;
42570if (event.type.startsWith("pointer") && (pointerType === "touch" || pointerType === "pen")) return false;
42571if (Date.now() >= this.suppressWordClickUntil && !this.shouldIgnoreCurrentImmersionExampleTargetClick(word)) return false;
42572event.preventDefault();
42573event.stopPropagation();
42574return true;
42575}
42576boundSubtitleVideo() {
42577if (!("getBoundVideo" in this.subtitles)) return void 0;
42578return this.subtitles.getBoundVideo();
42579}
42580shouldPauseForLookupAnchor(anchor) {
42581if (!this.settings.subtitleMiningPause || !anchor) return false;
42582if (anchor.closest(".jpdb-reader-popover")) return false;
42583if (anchor.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR)) return true;
42584const bound = this.boundSubtitleVideo();
42585return Boolean(bound && !bound.paused);
42586}
42587shouldPauseForMountedLookup(state) {
42588if (state.mode === "modal") return this.shouldPauseForLookupAnchor(state.resolvedAnchor ?? null);
42589if (!this.settings.subtitleMiningPause || !this.settings.subtitleHoverPause) return false;
42590const anchor = state.resolvedAnchor ?? null;
42591if (!anchor || anchor.closest(".jpdb-reader-popover")) return false;
42592return Boolean(anchor.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR));
42593}
42594pauseForSubtitleSurfaceTap(event) {
42595if (!this.settings.subtitleMiningPause) return false;
42596const target = event.target instanceof Element ? event.target : null;
42597if (!target?.closest(".jpdb-subtitle-text")) return false;
42598if (target.closest("button, a[href], input, [data-action], [data-resize-transcript], [data-subtitle-drag-handle], .jpdb-reader-word")) return false;
42599const before = this.subtitleMiningPausedVideo;
42600this.pauseVideoForSubtitleMining();
42601if (this.subtitleMiningPausedVideo === before) return false;
42602event.preventDefault();
42603event.stopPropagation();
42604return true;
42605}
42606pauseVideoForSubtitleMining() {
42607if (!this.settings.subtitleMiningPause) return;
42608this.clearSubtitleHoverMiningResumeTimer();
42609const bound = this.boundSubtitleVideo();
42610let paused;
42611if (bound?.isConnected && !bound.paused) {
42612bound.pause();
42613paused = bound;
42614}
42615paused ??= pauseActiveVideo();
42616if (!paused) return;
42617this.subtitleMiningPausedVideo = paused;
42618this.markMiningPause(paused);
42619this.armMiningPauseReassert(paused);
42620}
42621armMiningPauseReassert(video) {
42622this.clearMiningPauseReassert();
42623const armedAt = Date.now();
42624const reassert = () => {
42625if (this.subtitleMiningPausedVideo !== video || !video.dataset.jpdbReaderMiningPause) {
42626this.clearMiningPauseReassert();
42627return;
42628}
42629if (Date.now() - armedAt > MINING_PAUSE_REASSERT_WINDOW_MS) {
42630this.clearMiningPauseReassert();
42631return;
42632}
42633if (!video.paused) video.pause();
42634};
42635video.addEventListener("play", reassert);
42636video.addEventListener("playing", reassert);
42637this.miningPauseReassert = {
42638video,
42639off: () => {
42640video.removeEventListener("play", reassert);
42641video.removeEventListener("playing", reassert);
42642}
42643};
42644}
42645clearMiningPauseReassert() {
42646this.miningPauseReassert?.off();
42647this.miningPauseReassert = void 0;
42648}
42649scheduleSubtitleMiningVideoResume(delayMs = 0) {
42650this.clearSubtitleHoverMiningResumeTimer();
42651if (delayMs <= 0) {
42652this.resumeSubtitleMiningVideo();
42653return;
42654}
42655this.subtitleHoverMiningResumeTimer = window.setTimeout(() => {
42656this.subtitleHoverMiningResumeTimer = void 0;
42657this.resumeSubtitleMiningVideo();
42658}, delayMs);
42659}
42660clearSubtitleHoverMiningResumeTimer() {
42661window.clearTimeout(this.subtitleHoverMiningResumeTimer);
42662this.subtitleHoverMiningResumeTimer = void 0;
42663}
42664resumeSubtitleMiningVideo() {
42665this.clearSubtitleHoverMiningResumeTimer();
42666this.clearMiningPauseReassert();
42667const stored = this.subtitleMiningPausedVideo;
42668this.subtitleMiningPausedVideo = void 0;
42669if (!stored) return;
42670this.clearMiningPause(stored);
42671if (stored.isConnected && stored.paused) void stored.play().catch(() => void 0);
42672}
42673markMiningPause(video) {
42674video.dataset.jpdbReaderMiningPause = String(Date.now());
42675}
42676clearMiningPause(video) {
42677delete video.dataset.jpdbReaderMiningPause;
42678}
42679handleDocumentKeydown = trustedReaderEventHandler((event) => {
42680if (this.isDestroyed) return;
42681this.pressedKeys.add(normalizePressedKey(event.key));
42682if (isEditableEventContext(event)) return;
42683[
42684() => this.handleClosePopupShortcut(event),
42685() => {
42686if (this.hasHoverLookupShortcut() && this.shouldLookupOnHover(event)) this.scheduleHoverLookupAtPointer(event);
42687return false;
42688},
42689() => this.handleLookupNavigationShortcut(event),
42690() => this.handleReaderUtilityShortcut(event),
42691() => {
42692this.handleReviewShortcut(event);
42693return true;
42694}
42695].some((handle) => handle());
42696});
42697handleClosePopupShortcut(event) {
42698const escapeClose = this.settings.shortcuts.closePopup.trim().toLowerCase() === "escape" && event.key === "Escape";
42699if (!this.hasOpenReaderDialog()) return false;
42700if (!escapeClose && !matchesShortcut$1(event, this.settings.shortcuts.closePopup)) return false;
42701event.preventDefault();
42702this.dismiss({ suppressHoverTarget: true });
42703return true;
42704}
42705handleLookupNavigationShortcut(event) {
42706if (matchesShortcut$1(event, this.settings.shortcuts.previousLookupWord)) {
42707event.preventDefault();
42708void this.navigateLookupWord(-1);
42709return true;
42710}
42711if (matchesShortcut$1(event, this.settings.shortcuts.nextLookupWord)) {
42712event.preventDefault();
42713void this.navigateLookupWord(1);
42714return true;
42715}
42716return false;
42717}
42718handleReaderUtilityShortcut(event) {
42719if (matchesShortcut$1(event, this.settings.shortcuts.scanPage)) {
42720event.preventDefault();
42721this.scanPageNow();
42722return true;
42723}
42724if (matchesShortcut$1(event, this.settings.shortcuts.openSettings)) {
42725event.preventDefault();
42726this.showSettings();
42727return true;
42728}
42729if (matchesShortcut$1(event, this.settings.shortcuts.toggleOcr)) {
42730this.toggleOcrFromShortcut(event);
42731return true;
42732}
42733if (matchesShortcut$1(event, this.settings.shortcuts.toggleSubtitleOverlay)) {
42734this.toggleSubtitleOverlayFromShortcut(event);
42735return true;
42736}
42737if (matchesShortcut$1(event, this.settings.shortcuts.toggleYoutubeImmersion)) {
42738event.preventDefault();
42739void this.toggleYoutubeImmersion();
42740return true;
42741}
42742if (matchesShortcut$1(event, this.settings.shortcuts.scanImages)) {
42743event.preventDefault();
42744void this.ocr.scanVisible();
42745return true;
42746}
42747if (matchesShortcut$1(event, this.settings.shortcuts.massReviewVisible)) {
42748event.preventDefault();
42749void this.massReviewVisibleJitenWords();
42750return true;
42751}
42752return this.handleAudioShortcut(event);
42753}
42754toggleOcrFromShortcut(event) {
42755event.preventDefault();
42756void this.cycleOcrMode();
42757}
42758toggleSubtitleOverlayFromShortcut(event) {
42759event.preventDefault();
42760this.settings.subtitleOverlayVisible = !this.settings.subtitleOverlayVisible;
42761this.settings.subtitleOverlayVisibleChosen = true;
42762void this.persistSettings(this.settings, {
42763explicitUserChoiceKeys: ["subtitleOverlayVisible", "subtitleOverlayVisibleChosen"]
42764});
42765this.subtitles.refresh();
42766this.toast(uiText$1(this.settings.interfaceLanguage, this.settings.subtitleOverlayVisible ? "subtitleOverlayEnabled" : "subtitleOverlayHidden"));
42767}
42768async massReviewVisibleJitenWords() {
42769if (!hasJitenApiCredential(this.settings)) {
42770this.toast(uiText$1(this.settings.interfaceLanguage, "massReviewNoKey"));
42771return;
42772}
42773const words = visibleJitenReviewableWords();
42774if (!words.length) {
42775this.toast(uiText$1(this.settings.interfaceLanguage, "massReviewNoWords"));
42776return;
42777}
42778const cards = words.map((word) => jitenWordCardForMassReview(word));
42779try {
42780const count = await this.jiten.batchReviewCards(cards, "okay");
42781this.toast(uiText$1(this.settings.interfaceLanguage, "massReviewDone").replace("{count}", String(count)));
42782void this.refreshMassReviewedWordStates(cards);
42783} catch (error) {
42784log.warn("Mass review failed", error);
42785this.toast(uiText$1(this.settings.interfaceLanguage, "massReviewFailed"));
42786}
42787}
42788async refreshMassReviewedWordStates(cards) {
42789const batch = cards.slice(0, 60);
42790await this.jiten.refreshCardStates(batch).catch(() => void 0);
42791for (const card of batch) publishCardStateSignal(card);
42792}
42793handleAudioShortcut(event) {
42794if (!this.lastCard || !this.activePopover) return false;
42795if (!matchesShortcut$1(event, this.settings.shortcuts.playAudio)) return false;
42796event.preventDefault();
42797void this.audioActions.playTermAudio(this.lastCard, { userGesture: true });
42798return true;
42799}
42800handleReviewShortcut(event) {
42801const context = this.reviewShortcutContext(event);
42802if (!context) return;
42803event.preventDefault();
42804void this.submitReviewShortcut(context);
42805}
42806reviewShortcutContext(event) {
42807const target = this.reviewShortcutTarget(event);
42808if (!target) return null;
42809const selection = this.reviewShortcutSelection();
42810if (!this.canReviewFromShortcut(selection.reviewTarget, selection.ankiCardId)) return null;
42811return this.createReviewShortcutContext(target, selection);
42812}
42813reviewShortcutTarget(event) {
42814const grade = this.shortcutGrade(event);
42815const card = this.lastCard;
42816if (!grade || !card) return null;
42817return this.isReviewShortcutPopoverOpen() ? { grade, card } : null;
42818}
42819createReviewShortcutContext(target, selection) {
42820return {
42821grade: target.grade,
42822card: target.card,
42823...selection,
42824sentence: this.lastCardSentence,
42825anchor: this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0,
42826trigger: this.activePopoverMode === "hover" ? "hover" : "modal"
42827};
42828}
42829isReviewShortcutPopoverOpen() {
42830return Boolean(this.activePopover?.classList.contains("jpdb-reader-popover"));
42831}
42832reviewShortcutSelection() {
42833const fallbackAnkiId = this.lastAnkiLookup?.primary?.primaryCardId ?? null;
42834return resolvePrivateReviewSelection(this.activePopover, fallbackAnkiId);
42835}
42836canReviewFromShortcut(reviewTarget, ankiCardId) {
42837if (reviewTarget === "anki") return Boolean(ankiCardId);
42838if (reviewTarget === "both") return Boolean(ankiCardId) && isApiMiningEnabled(this.settings);
42839if (reviewTarget) return isApiMiningEnabled(this.settings);
42840return Boolean(ankiCardId) || isApiMiningEnabled(this.settings);
42841}
42842submitReviewShortcut(context) {
42843return this.cardActions.reviewGrade(context.grade, context.card, context.sentence, {
42844target: context.reviewTarget,
42845ankiCardId: this.reviewShortcutAnkiCardId(context.ankiCardId)
42846}).then(() => this.dismissAfterReview()).catch((error) => {
42847log.warn("Shortcut review failed", { grade: context.grade, ankiCardId: this.reviewShortcutAnkiCardId(context.ankiCardId, true) }, error);
42848this.toast(userFacingErrorText(this.settings.interfaceLanguage, "reviewFailed", error));
42849});
42850}
42851reviewShortcutAnkiCardId(ankiCardId, includeZero = false) {
42852if (typeof ankiCardId !== "number" || !Number.isFinite(ankiCardId)) return void 0;
42853if (!includeZero && !ankiCardId) return void 0;
42854return ankiCardId;
42855}
42856shortcutGrade(event) {
42857const button = reviewShortcutButton(this.activePopover, event, this.settings);
42858return readCardCommandCapability(button)?.grade ?? null;
42859}
42860shouldLookupOnHover(event) {
42861return !this.settings.annotationsPaused && this.settings.popupActivationMode !== "off" && !this.hasStickyModalPopover() && this.settings.lookupOnHover && shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys);
42862}
42863clickForcesReaderWordLookup(event) {
42864const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
42865return hasModifier && this.shouldLookupOnHover(event);
42866}
42867hasHoverLookupShortcut() {
42868return Boolean((this.settings.shortcuts.hoverLookup ?? "").trim());
42869}
42870beginPressLookup(event) {
42871const request = this.pressLookupRequest(event);
42872if (!request) return;
42873this.primeLookupAudioFromGesture();
42874if (request.isMiddleScan) this.captureMiddleMouseLookup(event);
42875this.pressLookup = this.createPressLookup(event, request.isMiddleScan);
42876if (request.isMiddleScan) this.updatePressLookup(event);
42877}
42878pressLookupRequest(event) {
42879if (this.isLookupInteractionIgnoredTarget(event.target)) return null;
42880const isMiddleScan = this.shouldCaptureMiddleMouseLookup(event);
42881if (!this.canBeginPressLookup(event, isMiddleScan)) return null;
42882if (!isMiddleScan && !this.wordFromEventTarget(event.target)) return null;
42883return { isMiddleScan };
42884}
42885canBeginPressLookup(event, isMiddleScan) {
42886if (this.isDestroyed) return false;
42887if (this.isInsideActivePopover(event.target)) return false;
42888if (isMiddleScan) return true;
42889return this.canBeginPrimaryPressLookup(event);
42890}
42891canBeginPrimaryPressLookup(event) {
42892return hasPressLookupEnabled(this.settings) && event.pointerType !== "touch" && event.pointerType !== "pen" && (event.pointerType !== "mouse" || event.button === 0);
42893}
42894prepareModalLookupFromPointer(event) {
42895this.lastPointerPosition = { x: event.clientX, y: event.clientY };
42896this.cancelPendingHoverLookup();
42897this.pinActiveHoverPopoverForPendingModalLookup();
42898this.cancelHoverClose();
42899this.primeLookupAudioFromGesture();
42900}
42901pinHoverPopoverForInsidePointer(event) {
42902if (this.activePopoverMode !== "hover" || !this.activePopover) return;
42903if (!this.isInsideActivePopover(event.target)) return;
42904this.cancelPendingHoverLookup();
42905this.cancelHoverClose();
42906this.pinActiveHoverPopoverForPendingModalLookup();
42907this.lookupModal.activate(this.activePopover, this.activePopoverAnchor);
42908this.activePopover.focus({ preventScroll: true });
42909}
42910pinActiveHoverPopoverForPendingModalLookup() {
42911if (this.activePopoverMode !== "hover" || !this.activePopover) return;
42912this.hoverClose.stopWatch();
42913this.clearHoverPopoverResizeSticky();
42914this.hoverPopoverPointerPosition = void 0;
42915this.activePopoverMode = "modal";
42916this.activeHoverWord = void 0;
42917this.activeHoverLookupKey = "";
42918this.activePointerTextLookup = void 0;
42919}
42920primeLookupAudioFromFirstGesture() {
42921if (this.isDestroyed || !this.settings.audioEnabled || !this.settings.autoPlayAudio) return;
42922this.audio.primeUserGestureIfUnprimed();
42923}
42924primeLookupAudioFromGesture() {
42925if (!this.settings.audioEnabled || !this.settings.autoPlayAudio) return;
42926this.audio.primeUserGesture();
42927}
42928createPressLookup(event, isMiddleScan) {
42929return {
42930pointerId: event.pointerId,
42931startX: event.clientX,
42932startY: event.clientY,
42933active: isMiddleScan,
42934source: isMiddleScan ? "middle" : "primary",
42935captureTarget: isMiddleScan && event.target instanceof Element ? event.target : void 0
42936};
42937}
42938updatePressLookup(event) {
42939if (this.isDestroyed) return;
42940const pressLookup = this.pressLookup;
42941if (!pressLookup || pressLookup.pointerId !== event.pointerId) return;
42942this.lastPointerPosition = { x: event.clientX, y: event.clientY };
42943if (!this.activatePressLookupIfNeeded(pressLookup, event)) return;
42944event.preventDefault();
42945event.stopPropagation();
42946this.updateActivePressLookupTarget(pressLookup, event);
42947}
42948updateActivePressLookupTarget(pressLookup, event) {
42949const targetAtPointer = document.elementFromPoint(event.clientX, event.clientY);
42950if (this.isInsideActivePopover(targetAtPointer)) {
42951this.cancelHoverClose();
42952return;
42953}
42954const word = this.wordFromPoint(event.clientX, event.clientY);
42955if (!word) {
42956if (pressLookup.source === "middle") this.scheduleHoverClose();
42957return;
42958}
42959this.showPressLookupWord(pressLookup, word, event);
42960}
42961showPressLookupWord(pressLookup, word, event) {
42962this.rememberHoverPopoverPointer(event);
42963if (this.refreshPressLookupWord(pressLookup, word)) return;
42964pressLookup.lastWord = word;
42965this.cancelHoverClose();
42966window.clearTimeout(this.hoverLookupTimer);
42967this.hoverLookupTimer = void 0;
42968this.hoverPendingWord = void 0;
42969this.hoverPendingLookupKey = "";
42970const hoverLookupGeneration = this.nextHoverLookupGeneration();
42971const candidate = this.renderedWordPointerLookupCandidate(word, event.clientX, event.clientY, event.target);
42972if (candidate) void this.showLookupCandidate(candidate, "hover", { hoverLookupGeneration });
42973}
42974activatePressLookupIfNeeded(pressLookup, event) {
42975if (pressLookup.active) return true;
42976const distance = Math.hypot(event.clientX - pressLookup.startX, event.clientY - pressLookup.startY);
42977if (distance < 8) return false;
42978pressLookup.active = true;
42979this.suppressWordClickUntil = Date.now() + 700;
42980this.suppressedHoverWord = void 0;
42981return true;
42982}
42983refreshPressLookupWord(pressLookup, word) {
42984if (word !== pressLookup.lastWord) return false;
42985if (this.activePopoverMode === "hover" && this.activeHoverWord === word) this.scheduleActivePopoverReposition();
42986return true;
42987}
42988endPressLookup(event) {
42989if (this.isDestroyed) return;
42990const pressLookup = this.matchingPressLookup(event);
42991if (!pressLookup) return;
42992this.finishPressLookupRelease(event, pressLookup);
42993this.pressLookup = void 0;
42994}
42995matchingPressLookup(event) {
42996const pressLookup = this.pressLookup;
42997return pressLookup?.pointerId === event.pointerId ? pressLookup : null;
42998}
42999finishPressLookupRelease(event, pressLookup) {
43000if (pressLookup.active) this.suppressPressLookupAfterRelease();
43001if (pressLookup.source === "middle") this.finishMiddlePressLookup(event, pressLookup);
43002}
43003suppressPressLookupAfterRelease() {
43004this.suppressWordClickUntil = Date.now() + 700;
43005}
43006finishMiddlePressLookup(event, pressLookup) {
43007event.preventDefault();
43008event.stopPropagation();
43009this.finishMiddleMouseLookup(pressLookup);
43010if (this.activePopoverMode === "hover" && !this.isHoverContextActive()) this.scheduleHoverClose();
43011}
43012shouldCaptureMiddleMouseLookup(event) {
43013if (this.settings.popupActivationMode === "off" || !this.settings.lookupOnMiddleMouse || event.button !== 1) return false;
43014if (this.isInsideActivePopover(event.target)) return false;
43015return isMousePointerEvent(event) && !this.isNativeMiddleClickTarget(eventElement(event));
43016}
43017captureMiddleMouseLookup(event) {
43018event.preventDefault();
43019event.stopPropagation();
43020this.suppressMiddleAuxClickUntil = Date.now() + 1200;
43021document.documentElement.classList.add("jpdb-reader-middle-scan-active");
43022try {
43023if (event.target instanceof Element) event.target.setPointerCapture?.(event.pointerId);
43024} catch {
43025}
43026}
43027finishMiddleMouseLookup(pressLookup) {
43028this.suppressMiddleAuxClickUntil = Date.now() + 700;
43029document.documentElement.classList.remove("jpdb-reader-middle-scan-active");
43030try {
43031pressLookup.captureTarget?.releasePointerCapture?.(pressLookup.pointerId);
43032} catch {
43033}
43034}
43035isNativeMiddleClickTarget(target) {
43036return Boolean(target?.closest([
43037"a[href]",
43038"button",
43039"input",
43040"textarea",
43041"select",
43042"summary",
43043"[role=button]",
43044"[contenteditable=true]",
43045"[data-jpdb-reader-root]"
43046].join(",")));
43047}
43048wordFromEventTarget(target) {
43049const element = target instanceof Element ? target : null;
43050const word = element?.closest?.(".jpdb-reader-word");
43051return word && this.canLookupReaderWord(word) ? word : null;
43052}
43053wordFromPoint(x, y, surface = null, canUseWord = (word) => this.canLookupReaderWord(word)) {
43054if (typeof document.elementsFromPoint !== "function") return null;
43055for (const element of document.elementsFromPoint(x, y)) {
43056const word = element.closest?.(".jpdb-reader-word");
43057if (word && this.readerWordBelongsToPointerSurface(word, surface) && canUseWord(word) && this.readerWordMatchesPointerGeometry(word, x, y)) return word;
43058}
43059return null;
43060}
43061hoverReaderWordFromPointStack(x, y, surface = null) {
43062if (typeof document.elementsFromPoint !== "function") return null;
43063for (const element of document.elementsFromPoint(x, y)) {
43064const word = element.closest?.(".jpdb-reader-word");
43065if (word && this.readerWordBelongsToPointerSurface(word, surface) && this.canHoverLookupReaderWord(word) && this.readerWordMatchesPointerGeometry(word, x, y)) return word;
43066}
43067return null;
43068}
43069preloadHoverWordAudio(word) {
43070const card = this.preloadableReaderWordCard(word);
43071if (!card) return;
43072this.preloadReaderCardAudio(card, {
43073sourceLimit: 1,
43074candidateLimit: 1,
43075prepareAudio: this.shouldPrepareHoverWordAudio(word)
43076});
43077if (this.canPreloadBackgroundReaderAudio()) this.scheduleNearbyReaderWordAudioPreloads(word);
43078}
43079preloadReaderWordAudio(word, options = {}) {
43080if (!this.canPreloadReaderAudio()) return false;
43081const card = this.preloadableReaderWordCard(word);
43082if (!card) return false;
43083return this.preloadReaderCardAudio(card, options);
43084}
43085preloadReaderCardAudio(card, options = {}) {
43086const limits = audioPreloadLimits(options);
43087const key = cardKey(card);
43088const prepareAudio = Boolean(limits.prepareAudio);
43089if (prepareAudio && this.preloadedPreparedTermAudioKeys.has(key)) return false;
43090if (!prepareAudio && this.preloadedTermAudioKeys.has(key)) return false;
43091if (!this.audio.preload(card, limits)) return false;
43092this.rememberPreloadedTermAudioKey(key);
43093if (prepareAudio) this.rememberPreloadedPreparedTermAudioKey(key);
43094return true;
43095}
43096shouldPrepareHoverWordAudio(word) {
43097return canAttemptReaderAutoAudio({
43098settings: this.settings,
43099trigger: "hover",
43100userGesture: false
43101});
43102}
43103canPreloadReaderAudio() {
43104return this.settings.audioEnabled;
43105}
43106canPreloadBackgroundReaderAudio() {
43107return this.settings.audioEnabled && this.settings.autoPlayAudio && !isYouTubeRuntimeHost();
43108}
43109backgroundPitchEnrichmentOptions() {
43110const options = backgroundPitchEnrichmentOptionsForHost(location.hostname, isCompactPitchEnrichmentViewport());
43111if (hasJpdbApiCredential(this.settings) || hasJitenApiCredential(this.settings)) return options;
43112const pageBudget = Math.max(0, Math.floor(options.publicLookupPageBudget ?? PITCH_ENRICHMENT_LIMIT));
43113const keylessVisibleLimit = pageBudget || PITCH_ENRICHMENT_LIMIT;
43114return {
43115...options,
43116publicLookupLimit: Math.max(Math.floor(options.publicLookupLimit ?? 0), keylessVisibleLimit),
43117publicLookupTotalLimit: Math.max(Math.floor(options.publicLookupTotalLimit ?? 0), keylessVisibleLimit)
43118};
43119}
43120nestedPitchEnrichmentOptions() {
43121return nestedPitchEnrichmentOptionsForHost();
43122}
43123preloadableReaderWordCard(word) {
43124if (word.dataset.jpdbReaderPassive === "true") return null;
43125const card = this.getCachedCard(Number(renderedWordPrivateValue(word, "vid")), Number(renderedWordPrivateValue(word, "sid")));
43126return card && isUsefulImmersionPreloadQuery(card.spelling) ? card : null;
43127}
43128scheduleNearbyReaderWordAudioPreloads(word) {
43129window.clearTimeout(this.nearbyReaderAudioPreloadTimer);
43130this.nearbyReaderAudioPreloadTimer = window.setTimeout(() => {
43131this.nearbyReaderAudioPreloadTimer = void 0;
43132if (!word.isConnected || !this.canPreloadBackgroundReaderAudio()) return;
43133this.queueNearbyReaderWordAudioPreloads(word);
43134}, NEARBY_TERM_AUDIO_PRELOAD_DELAY_MS);
43135}
43136queueNearbyReaderWordAudioPreloads(word) {
43137const words = this.lookupableReaderWords();
43138const index = words.indexOf(word);
43139return index < 0 ? 0 : this.queueReaderWordAudioPreloads(words.slice(index + 1), { prepareAudio: false });
43140}
43141lookupableReaderWords() {
43142return Array.from(document.querySelectorAll(".jpdb-reader-word")).filter((candidate) => candidate.isConnected && this.canLookupReaderWord(candidate));
43143}
43144async navigateLookupWord(direction) {
43145const words = this.lookupWordNavigationCandidates();
43146if (!words.length) return;
43147const current = currentLookupNavigationWord(words, this.activePopoverAnchor, this.keyboardActiveWord);
43148const currentIndex = current ? words.indexOf(current) : -1;
43149const nextIndex = currentIndex >= 0 ? Math.max(0, Math.min(words.length - 1, currentIndex + direction)) : direction > 0 ? 0 : words.length - 1;
43150const word = words[nextIndex];
43151if (!word) return;
43152this.setKeyboardActiveWord(word);
43153word.focus({ preventScroll: true });
43154word.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
43155await this.showWord(word, { trigger: "click", navigation: "reset", userGesture: true });
43156if (word.isConnected) this.setKeyboardActiveWord(word);
43157}
43158lookupWordNavigationCandidates() {
43159const selected = this.selectedLookupNavigationWords();
43160if (selected.length) return selected;
43161const scoped = this.scopedLookupNavigationWords();
43162return scoped.length ? scoped : this.lookupableReaderWords().filter((word) => this.isKeyboardNavigableWord(word));
43163}
43164selectedLookupNavigationWords() {
43165const selection = window.getSelection();
43166if (!selection || selection.isCollapsed || !selection.rangeCount) return [];
43167return this.lookupableReaderWords().filter((word) => this.isKeyboardNavigableWord(word) && selectionIntersectsElement(selection, word));
43168}
43169scopedLookupNavigationWords() {
43170const anchor = this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : this.keyboardActiveWord?.isConnected ? this.keyboardActiveWord : void 0;
43171const scope = anchor?.closest(".jpdb-subtitle-primary, .jpdb-subtitle-list-row, p, li, blockquote, article, main");
43172if (!scope) return [];
43173return Array.from(scope.querySelectorAll(".jpdb-reader-word")).filter((word) => this.isKeyboardNavigableWord(word));
43174}
43175isKeyboardNavigableWord(word) {
43176if (!word.isConnected || word.closest(".jpdb-reader-settings")) return false;
43177if (!this.canLookupReaderWord(word)) return false;
43178return Array.from(word.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);
43179}
43180setKeyboardActiveWord(word) {
43181if (this.keyboardActiveWord && this.keyboardActiveWord !== word) {
43182this.keyboardActiveWord.classList.remove("jpdb-reader-keyboard-active");
43183}
43184this.keyboardActiveWord = word;
43185word.classList.add("jpdb-reader-keyboard-active");
43186}
43187clearKeyboardActiveWord() {
43188this.keyboardActiveWord?.classList.remove("jpdb-reader-keyboard-active");
43189this.keyboardActiveWord = void 0;
43190}
43191queueReaderWordAudioPreloads(words, options = {}) {
43192let queued = 0;
43193for (const candidate of words) {
43194if (this.preloadReaderWordAudio(candidate, options)) queued++;
43195if (queued >= NEARBY_TERM_AUDIO_PRELOAD_LIMIT) break;
43196}
43197return queued;
43198}
43199canLookupReaderWord(word) {
43200return canLookupReaderWordElement(word);
43201}
43202canClickLookupReaderWord(word) {
43203return this.canLookupReaderWord(word) || canClickLookupPassiveReaderWordElement(word);
43204}
43205canHoverLookupReaderWord(word) {
43206return canHoverLookupReaderWordElement(word, this.hasHoverLookupShortcut());
43207}
43208queueHoverPointerMove(event) {
43209this.rememberQueuedHoverPointerPosition(event);
43210if (event.buttons) {
43211if (this.hoverPointerMoveFrame !== void 0) {
43212window.cancelAnimationFrame(this.hoverPointerMoveFrame);
43213this.hoverPointerMoveFrame = void 0;
43214}
43215this.pendingHoverPointerMove = void 0;
43216this.cancelHoverLookupForDrag();
43217return;
43218}
43219this.pendingHoverPointerMove = event;
43220if (this.hoverPointerMoveFrame !== void 0) return;
43221this.hoverPointerMoveFrame = requestAnimationFrame(() => {
43222this.hoverPointerMoveFrame = void 0;
43223const pending = this.pendingHoverPointerMove;
43224this.pendingHoverPointerMove = void 0;
43225if (pending && !this.isDestroyed) this.handleHoverPointer(pending);
43226});
43227}
43228rememberQueuedHoverPointerPosition(event) {
43229if (this.isDestroyed || !this.canUseHoverLookupPointer(event)) return;
43230this.lastPointerPosition = { x: event.clientX, y: event.clientY };
43231}
43232handleHoverPointer(event) {
43233if (this.shouldIgnoreHoverPointer(event)) return;
43234this.lastPointerPosition = { x: event.clientX, y: event.clientY };
43235this.keepPlainSubtitleHoverPause(event.target);
43236const insideActivePopover = this.handleActivePopoverHover(event);
43237if (insideActivePopover) return;
43238const word = this.hoverReaderWordForEvent(event);
43239const candidate = this.renderedWordPointerLookupCandidateAtPointer(word, event);
43240if (isHoverPopoverTransitActive(this.activeHoverPopoverPointerState())) {
43241this.handleHoverPopoverTransit(word, candidate, event);
43242return;
43243}
43244if (!word) {
43245this.handlePointerTextHover(event);
43246return;
43247}
43248if (candidate) {
43249this.keepSubtitleMiningPauseForPendingHover(word);
43250this.preloadHoverWordAudio(word);
43251this.handlePointerTextHoverCandidate(event, candidate);
43252return;
43253}
43254this.handleReaderWordHover(word, event);
43255}
43256renderedWordPointerLookupCandidateAtPointer(word, event) {
43257if (!word) return null;
43258return this.renderedWordPointerLookupCandidate(word, event.clientX, event.clientY, event.target);
43259}
43260handleHoverPopoverTransit(word, candidate, event) {
43261this.cancelHoverClose();
43262this.cancelPendingHoverLookup();
43263if (candidate) {
43264this.handlePointerTextHoverCandidate(event, candidate, { minimumDelayMs: HOVER_POPOVER_TRANSIT_SETTLE_DELAY_MS });
43265return;
43266}
43267if (word) {
43268this.scheduleHoverLookup(word, event, { minimumDelayMs: HOVER_POPOVER_TRANSIT_SETTLE_DELAY_MS });
43269}
43270}
43271shouldIgnoreHoverPointer(event) {
43272if (this.isDestroyed || this.pressLookup?.source === "middle" || !this.canUseHoverLookupPointer(event) || this.shouldSuppressPenHover(event)) return true;
43273if (this.retireHoverForLookupFreeTarget(event.target)) return true;
43274if (event.buttons) {
43275this.cancelHoverLookupForDrag();
43276return true;
43277}
43278if (this.suppressHoverForActivePageSelection()) return true;
43279if (!this.hasStickyModalPopover()) return false;
43280this.cancelPendingHoverLookup();
43281this.cancelHoverClose();
43282return true;
43283}
43284cancelHoverLookupForDrag() {
43285if (!this.pressLookup?.active) this.cancelPendingHoverLookup();
43286}
43287retireHoverForLookupFreeTarget(target) {
43288if (!this.isLookupInteractionIgnoredTarget(target) || this.isInsideActivePopover(target)) return false;
43289this.cancelPendingHoverLookup();
43290if (this.activePopoverMode === "hover") this.dismiss({ suppressHoverTarget: false });
43291return true;
43292}
43293isLookupInteractionIgnoredTarget(target) {
43294return target instanceof Element && Boolean(target.closest("[data-jpdb-reader-surface-ignore], [data-jpdb-reader-interaction-ignore]"));
43295}
43296canUseHoverLookupPointer(event) {
43297const pointerType = event.pointerType;
43298return pointerType !== "touch";
43299}
43300hasStickyModalPopover() {
43301return this.activePopoverMode === "modal" && Boolean(this.activePopover);
43302}
43303suppressHoverAfterPenContact(event) {
43304if (event.pointerType !== "pen") return;
43305this.suppressPenHoverUntil = Date.now() + Math.max(700, this.settings.hoverCloseDelayMs + 450);
43306this.cancelPendingHoverLookup();
43307}
43308shouldSuppressPenHover(event) {
43309return event.pointerType === "pen" && Date.now() < this.suppressPenHoverUntil;
43310}
43311suppressHoverForActivePageSelection() {
43312if (!this.hasActivePageTextSelection()) return false;
43313this.cancelPendingHoverLookup();
43314if (this.activePopoverMode === "hover") this.dismiss({ suppressHoverTarget: false });
43315return true;
43316}
43317hasActivePageTextSelection() {
43318const selection = window.getSelection();
43319if (!selection || selection.isCollapsed || !selection.rangeCount) return false;
43320for (let index = 0; index < selection.rangeCount; index += 1) {
43321try {
43322if (!this.isInsideActivePopover(selection.getRangeAt(index).commonAncestorContainer)) return true;
43323} catch {
43324return true;
43325}
43326}
43327return false;
43328}
43329handleActivePopoverHover(event) {
43330if (!this.isInsideActivePopover(event.target)) return false;
43331this.cancelPendingHoverLookup();
43332this.cancelHoverClose();
43333return !this.canHoverLookupActivePopoverWord(event);
43334}
43335canHoverLookupActivePopoverWord(event) {
43336if (!this.hasHoverLookupShortcut() || !this.shouldLookupOnHover(event)) return false;
43337const word = this.hoverReaderWordForEvent(event);
43338return Boolean(word && this.isInsideActivePopover(word));
43339}
43340hoverReaderWordForEvent(event) {
43341const word = this.readerWordForPointerEvent(event, { hoverLookup: true });
43342return word && this.canHoverLookupReaderWord(word) ? word : null;
43343}
43344readerWordForPointerEvent(event, options = {}) {
43345const target = event.target instanceof Element ? event.target : null;
43346const surface = this.readerPointerSurfaceForTarget(target);
43347const direct = target?.closest?.(".jpdb-reader-word");
43348const canUseWord = (word) => {
43349if (options.hoverLookup) return this.canHoverLookupReaderWord(word);
43350if (options.clickLookup) return this.canClickLookupReaderWord(word);
43351return this.canLookupReaderWord(word);
43352};
43353if (direct && this.readerWordBelongsToPointerSurface(direct, surface) && canUseWord(direct) && this.readerWordMatchesPointerGeometry(direct, event.clientX, event.clientY)) return direct;
43354return this.ocrLineWordForPointer(target, event.clientX, event.clientY) ?? projectedReadingWordAtPoint(document, event.clientX, event.clientY, (word) => this.readerWordBelongsToPointerSurface(word, surface) && canUseWord(word)) ?? (options.hoverLookup ? this.hoverReaderWordFromPointStack(event.clientX, event.clientY, surface) : this.wordFromPoint(event.clientX, event.clientY, surface, canUseWord)) ?? firstComposedEventGeometryMatch(event, (candidate) => this.readerWordFromRenderedGeometry(candidate, event.clientX, event.clientY, canUseWord));
43355}
43356readerPointerSurfaceForTarget(target) {
43357return target?.closest(READER_POINTER_SURFACE_SELECTOR) ?? null;
43358}
43359readerWordBelongsToPointerSurface(word, surface) {
43360return !surface || surface.contains(word);
43361}
43362readerWordMatchesPointerGeometry(word, x, y) {
43363if (!word.closest(".jpdb-reader-additive-text-mirror") || typeof Range.prototype.getClientRects !== "function") return true;
43364return readerWordSourcePointScore(word, x, y) !== null;
43365}
43366ocrLineWordForPointer(target, x, y) {
43367const line = target?.closest?.(".jpdb-ocr-line") ?? document.elementFromPoint(x, y)?.closest?.(".jpdb-ocr-line");
43368return line ? ocrLineWordAtPoint(line, x, y) : null;
43369}
43370readerWordFromRenderedGeometry(target, x, y, canUseWord = (word) => this.canLookupReaderWord(word)) {
43371const scope = this.readerWordGeometryScope(target);
43372if (!scope) return null;
43373return readerWordAtSourcePointInScope(scope, x, y, canUseWord) ?? readerWordAtPointInScope(scope, x, y, (word) => canUseWord(word) && this.readerWordMatchesPointerGeometry(word, x, y));
43374}
43375readerWordGeometryScope(target) {
43376if (!target) return null;
43377const documentPortalScope = documentPortalReaderWordScopeForSource(target);
43378if (documentPortalScope) return documentPortalScope;
43379let current = target;
43380for (let depth = 0; current && depth < 12; depth += 1, current = current.parentElement) {
43381const shadowRoot = current.shadowRoot;
43382if (shadowRoot?.querySelector(".jpdb-reader-additive-text-mirror .jpdb-reader-word")) return shadowRoot;
43383if (current.querySelector(":scope > .jpdb-reader-additive-text-mirror .jpdb-reader-word")) return current;
43384}
43385const scope = target.closest(HOVER_READER_WORD_GEOMETRY_SCOPE_SELECTOR);
43386return scope && scope.querySelector(".jpdb-reader-word") ? scope : null;
43387}
43388handlePointerTextHover(event) {
43389const hoverEnabled = this.shouldLookupOnHover(event);
43390const candidate = hoverEnabled ? this.lookupCandidateFromPoint(event.clientX, event.clientY, event.target, HOVER_POINTER_TEXT_LOOKUP_OPTIONS) : null;
43391this.handlePointerTextHoverCandidate(event, candidate, { hoverEnabled });
43392}
43393handlePointerTextHoverCandidate(event, candidate, options = {}) {
43394const hoverEnabled = this.pointerTextHoverEnabled(event, options.hoverEnabled);
43395const activeCandidate = hoverEnabled ? candidate : null;
43396if (activeCandidate && this.refreshActivePointerTextHover(activeCandidate, event)) return;
43397this.cancelMissingPointerTextCandidate(activeCandidate);
43398this.scheduleInactiveHoverClose();
43399if (!canSchedulePointerTextHoverLookup(hoverEnabled, activeCandidate)) return;
43400this.rememberPointerTextHoverOrigin(event, options.minimumDelayMs);
43401this.schedulePointerTextLookup(activeCandidate, event, { minimumDelayMs: options.minimumDelayMs });
43402}
43403pointerTextHoverEnabled(event, configured) {
43404return configured === void 0 ? this.shouldLookupOnHover(event) : configured;
43405}
43406rememberPointerTextHoverOrigin(event, minimumDelayMs) {
43407if (minimumDelayMs === void 0) this.rememberHoverPopoverPointer(event);
43408}
43409renderedWordPointerLookupCandidate(word, x, y, eventTarget) {
43410return pointerTextLookupFromRenderedWord(word, x, y) ?? this.lookupCandidateFromPoint(x, y, eventTarget, HOVER_POINTER_TEXT_LOOKUP_OPTIONS);
43411}
43412renderedWordLookupCandidateForActivation(word, event) {
43413const candidate = this.renderedWordPointerLookupCandidate(word, event.clientX, event.clientY, event.target);
43414if (candidate || eventHasPointTarget(event)) return candidate;
43415return pointerTextLookupFromRenderedWordStart(word);
43416}
43417refreshActivePointerTextHover(candidate, event) {
43418if (!this.isActivePointerTextLookup(candidate)) return false;
43419this.cancelHoverClose();
43420this.refreshActiveHoverAnchor(candidate.anchor);
43421return true;
43422}
43423cancelMissingPointerTextCandidate(candidate) {
43424if (!candidate) this.cancelPendingHoverLookup();
43425}
43426scheduleInactiveHoverClose() {
43427if (this.activePopoverMode === "hover" && !this.isHoverContextActive({ ignoreCssHover: true })) {
43428this.scheduleHoverClose(void 0, { ignoreCssHover: true });
43429}
43430}
43431handleReaderWordHover(word, event) {
43432const hoverLookupKey = this.hoverLookupKeyForWord(word);
43433if (this.isActiveHoverLookup(hoverLookupKey)) {
43434this.cancelHoverClose();
43435this.refreshActiveHoverAnchor(word);
43436if (this.shouldRetryHoverAudio(word, event)) this.retryActiveHoverAudio(word, hoverLookupKey);
43437return;
43438}
43439if (this.activePopoverMode === "hover" && this.activeHoverWord === word) {
43440this.cancelHoverClose();
43441return;
43442}
43443if (!this.shouldLookupOnHover(event)) return;
43444this.rememberHoverPopoverPointer(event);
43445this.keepSubtitleMiningPauseForPendingHover(word);
43446this.preloadHoverWordAudio(word);
43447this.scheduleHoverLookup(word, event);
43448}
43449keepSubtitleMiningPauseForPendingHover(word) {
43450if (!this.settings.subtitleMiningPause || !this.settings.subtitleHoverPause) return;
43451if (!word.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR)) return;
43452this.pauseVideoForSubtitleMining();
43453}
43454keepPlainSubtitleHoverPause(target) {
43455const surface = this.plainSubtitleHoverPauseSurface(target);
43456if (!surface) return;
43457this.activePlainSubtitleHoverSurface = surface;
43458this.pauseVideoForSubtitleMining();
43459}
43460plainSubtitleHoverPauseSurface(target) {
43461if (!this.settings.annotationsPaused || !this.settings.subtitleMiningPause || !this.settings.subtitleHoverPause || !(target instanceof Element)) return null;
43462if (target.closest(".jpdb-reader-popover")) return null;
43463return target.closest(PLAIN_SUBTITLE_HOVER_PAUSE_SELECTOR);
43464}
43465shouldRetryHoverAudio(word, event) {
43466if (event.type !== "pointerover" || !this.shouldPrepareHoverWordAudio(word)) return false;
43467const related = event.relatedTarget;
43468return !this.isInsideNode(related, word) && !this.isInsideActivePopover(related);
43469}
43470retryActiveHoverAudio(word, hoverLookupKey) {
43471const card = this.cardForRenderedWord(word);
43472if (!card) return;
43473void this.audioActions.playTermAudio(card, {
43474isCurrent: () => this.isActiveHoverLookup(hoverLookupKey) && this.isHoverContextActive(),
43475autoPlay: true,
43476hoverLookupGeneration: this.hoverLookupGeneration
43477});
43478}
43479handleHoverPointerOut(event) {
43480if (this.isDestroyed || this.hasStickyModalPopover() || !this.canUseHoverLookupPointer(event) || this.shouldSuppressPenHover(event)) return;
43481this.lastPointerPosition = { x: event.clientX, y: event.clientY };
43482const related = event.relatedTarget;
43483if (this.handlePlainSubtitleHoverPointerOut(event.target, related)) return;
43484if (this.handleActivePopoverPointerOut(event, related)) return;
43485this.handleReaderWordPointerOut(event, related);
43486}
43487handlePlainSubtitleHoverPointerOut(target, related) {
43488const active = this.activePlainSubtitleHoverSurface;
43489if (!active || !(target instanceof Node) || !active.contains(target)) return false;
43490const next = this.plainSubtitleHoverPauseSurface(related);
43491if (next) {
43492this.activePlainSubtitleHoverSurface = next;
43493this.clearSubtitleHoverMiningResumeTimer();
43494return true;
43495}
43496this.activePlainSubtitleHoverSurface = void 0;
43497if (this.isInsideActivePopover(related)) {
43498this.cancelHoverClose();
43499return false;
43500}
43501if (this.activePopoverMode === "hover") {
43502this.scheduleHoverClose(void 0, { ignoreCssHover: true });
43503} else {
43504this.scheduleSubtitleMiningVideoResume(this.settings.hoverCloseDelayMs);
43505}
43506return false;
43507}
43508handleActivePopoverPointerOut(event, related) {
43509if (this.isInsideActivePopover(event.target)) {
43510if (this.isInsideActivePopover(related) || this.activeHoverWord && this.isInsideNode(related, this.activeHoverWord)) return true;
43511this.scheduleHoverClose(void 0, { ignoreCssHover: true });
43512return true;
43513}
43514return false;
43515}
43516handleReaderWordPointerOut(event, related) {
43517const word = this.pointerOutReaderWord(event, related);
43518if (!word) return;
43519const hoverLookupKey = this.hoverLookupKeyForWord(word);
43520this.cancelPendingHoverLookupForWord(word, hoverLookupKey);
43521this.clearSuppressedHoverForWord(word, hoverLookupKey);
43522this.handleActiveHoverWordPointerOut(word, related);
43523}
43524pointerOutReaderWord(event, related) {
43525const word = event.target.closest?.(".jpdb-reader-word");
43526return word && !(related && word.contains(related)) ? word : null;
43527}
43528cancelPendingHoverLookupForWord(word, hoverLookupKey) {
43529const pointer = this.lastPointerPosition;
43530const pointerWord = pointer && this.isPendingHoverLookup(word, hoverLookupKey) ? this.hoverReaderWordFromElement(document.elementFromPoint(pointer.x, pointer.y)) : null;
43531if (pointerWord && pointerWord !== word) return;
43532if (this.hoverPendingWord === word || this.hoverPendingLookupKey === hoverLookupKey || this.hoverLookupInFlightKey === hoverLookupKey) {
43533this.cancelPendingHoverLookup();
43534}
43535}
43536clearSuppressedHoverForWord(word, hoverLookupKey) {
43537if (this.suppressedHoverWord === word) this.suppressedHoverWord = void 0;
43538if (this.suppressedHoverLookupKey === hoverLookupKey) this.suppressedHoverLookupKey = "";
43539}
43540handleActiveHoverWordPointerOut(word, related) {
43541if (this.activePopoverMode !== "hover" || this.activeHoverWord !== word) return;
43542if (this.isInsideActivePopover(related)) {
43543this.cancelHoverClose();
43544return;
43545}
43546if (this.hoverReaderWordFromElement(related instanceof HTMLElement ? related : related?.parentElement ?? null)) {
43547this.cancelHoverClose();
43548return;
43549}
43550if (this.isWithinHoverWordHostControl(word, related)) {
43551this.cancelHoverClose();
43552return;
43553}
43554this.scheduleHoverClose(void 0, { ignoreCssHover: true });
43555}
43556isWithinHoverWordHostControl(word, related) {
43557if (!related) return false;
43558const control = this.hoverWordHostControl(word);
43559if (!control) return false;
43560const relatedElement = related instanceof HTMLElement ? related : related.parentElement;
43561return Boolean(relatedElement && control.contains(relatedElement));
43562}
43563hoverWordHostControl(word) {
43564return this.hoverWordOwnership.hostControl(word);
43565}
43566scheduleHoverLookupAtPointer(event) {
43567if (this.isDestroyed || !this.lastPointerPosition) return;
43568const pointer = this.lastPointerPosition;
43569this.hoverPopoverPointerPosition = { ...pointer };
43570this.scheduleHoverLookupForPointer(pointer, event);
43571}
43572scheduleHoverLookupForPointer(pointer, event) {
43573const target = document.elementFromPoint(pointer.x, pointer.y);
43574const word = this.hoverReaderWordFromElement(target);
43575if (word) {
43576this.scheduleHoverLookup(word, event);
43577return;
43578}
43579const candidate = this.lookupCandidateFromPoint(pointer.x, pointer.y, target, HOVER_POINTER_TEXT_LOOKUP_OPTIONS);
43580if (candidate) this.schedulePointerTextLookup(candidate, event);
43581}
43582hoverReaderWordFromElement(element) {
43583const word = element?.closest?.(".jpdb-reader-word");
43584return word && this.canHoverLookupReaderWord(word) ? word : null;
43585}
43586dismissModalPopoverForOutsidePointer(event) {
43587if (this.isDestroyed || this.activePopoverMode !== "modal" || !this.activePopover) return;
43588if (this.isInsideActivePopover(event.target)) return;
43589if (this.shouldKeepModalPopoverForOutsidePointer(event.target)) return;
43590if (getSelectionText()) {
43591event.preventDefault();
43592clearDocumentSelection();
43593}
43594this.dismiss({ suppressHoverTarget: true });
43595}
43596shouldKeepModalPopoverForOutsidePointer(target) {
43597const element = target instanceof Element ? target : target?.parentElement;
43598if (this.isPointerOnStackedSettingsDialog(element)) return false;
43599return keepsModalPopoverForOwnedSurface(element);
43600}
43601isPointerOnStackedSettingsDialog(element) {
43602if (!this.shouldDismissStackedLookupOnly()) return false;
43603const form = this.stackedSettingsDialog?.form;
43604return Boolean(form && element && form.contains(element));
43605}
43606dismissHoverPopoverForOutsidePointer(event) {
43607if (this.isDestroyed || this.activePopoverMode !== "hover") return;
43608const target = event.target;
43609if (!this.shouldDismissHoverForPointerTarget(target)) return;
43610this.dismiss({ suppressHoverTarget: false });
43611}
43612shouldDismissHoverForPointerTarget(target) {
43613if (this.isInsideActivePopover(target)) return false;
43614return !(this.activeHoverWord && this.isInsideNode(target, this.activeHoverWord));
43615}
43616rememberHoverPopoverPointer(event) {
43617this.hoverPopoverPointerPosition = { x: event.clientX, y: event.clientY };
43618}
43619nextHoverLookupGeneration() {
43620this.hoverLookupGeneration++;
43621return this.hoverLookupGeneration;
43622}
43623cancelPendingHoverLookup() {
43624window.clearTimeout(this.hoverLookupTimer);
43625this.hoverLookupTimer = void 0;
43626this.hoverPendingWord = void 0;
43627this.hoverPendingLookupKey = "";
43628this.hoverLookupInFlightKey = "";
43629this.nextHoverLookupGeneration();
43630}
43631scheduleActivePopoverReposition() {
43632if (this.activePopoverMode !== "hover" || !this.activePopover || this.activePopover.classList.contains("jpdb-reader-sheet")) return;
43633this.scheduleRepositionActivePopoverFrame();
43634}
43635scheduleRepositionActivePopoverFrame() {
43636if (this.popoverRepositionFrame !== void 0) return;
43637this.popoverRepositionFrame = window.requestAnimationFrame(() => {
43638this.popoverRepositionFrame = void 0;
43639if (this.isDestroyed) return;
43640if (this.popoverViewportChangePending) {
43641this.popoverViewportChangePending = false;
43642this.repositionActivePopoverAfterViewportChange();
43643return;
43644}
43645this.repositionActivePopover();
43646});
43647}
43648scheduleActivePopoverViewportChange() {
43649const popover = this.repositionableActivePopover();
43650if (!popover || overlayViewport().pageScale === 1 && !hasOverlayPageScale(popover)) return;
43651this.popoverViewportChangePending = true;
43652this.scheduleRepositionActivePopoverFrame();
43653}
43654repositionActivePopoverAfterViewportChange() {
43655const popover = this.repositionableActivePopover();
43656if (!popover) return;
43657const relock = this.activePopoverPositionLocked && this.activePopoverMode !== "hover";
43658if (relock) {
43659this.activePopoverPositionLocked = false;
43660this.activePopoverLockedPosition = void 0;
43661this.refreshActivePopoverAnchorRect();
43662}
43663this.repositionActivePopover();
43664if (relock && popover.isConnected) this.lockActivePopoverPosition(this.popoverOverlayRect(popover));
43665}
43666scheduleHoverLookup(word, event, options = {}) {
43667const hoverLookupKey = this.hoverLookupKeyForWord(word);
43668if (this.shouldSkipHoverLookupSchedule(word, hoverLookupKey)) return;
43669this.cancelHoverClose();
43670if (this.retargetPendingHoverLookup(word, hoverLookupKey, options.minimumDelayMs)) return;
43671window.clearTimeout(this.hoverLookupTimer);
43672const hoverLookupGeneration = this.nextHoverLookupGeneration();
43673this.hoverPendingWord = word;
43674this.hoverPendingLookupKey = hoverLookupKey;
43675const runLookup = () => {
43676this.rememberSettledHoverPopoverPointer(options.minimumDelayMs);
43677this.runScheduledHoverLookup(word, event, hoverLookupGeneration);
43678};
43679this.startHoverLookupAfterDelay(runLookup, hoverLookupScheduleDelay({
43680switchesAnchor: this.activePopoverMode === "hover" && Boolean(this.activeHoverWord) && this.activeHoverWord !== word,
43681hoverOpenDelayMs: this.settings.hoverOpenDelayMs,
43682minimumDelayMs: options.minimumDelayMs
43683}));
43684}
43685retargetPendingHoverLookup(word, hoverLookupKey, minimumDelayMs) {
43686if (minimumDelayMs !== void 0) return false;
43687if (!this.hoverLookupTimer || !this.hoverPendingWord) return false;
43688this.hoverPendingWord = word;
43689this.hoverPendingLookupKey = hoverLookupKey;
43690return true;
43691}
43692rememberSettledHoverPopoverPointer(minimumDelayMs) {
43693if (minimumDelayMs === void 0 || !this.lastPointerPosition) return;
43694this.hoverPopoverPointerPosition = { ...this.lastPointerPosition };
43695}
43696startHoverLookupAfterDelay(runLookup, delay2) {
43697if (delay2 === 0) {
43698runLookup();
43699return;
43700}
43701this.hoverLookupTimer = window.setTimeout(runLookup, delay2);
43702}
43703shouldSkipHoverLookupSchedule(word, hoverLookupKey) {
43704if (this.isSuppressedHoverLookup(word, hoverLookupKey)) return true;
43705if (this.isActiveHoverLookup(hoverLookupKey)) {
43706this.refreshActiveHoverAnchor(word);
43707return true;
43708}
43709return this.activePopoverMode === "hover" && this.activeHoverWord === word || this.isPendingHoverLookup(word, hoverLookupKey) || Boolean(hoverLookupKey && this.hoverLookupInFlightKey === hoverLookupKey);
43710}
43711isSuppressedHoverLookup(word, hoverLookupKey) {
43712return this.suppressedHoverWord === word || Boolean(hoverLookupKey && this.suppressedHoverLookupKey === hoverLookupKey);
43713}
43714isPendingHoverLookup(word, hoverLookupKey) {
43715return Boolean((this.hoverPendingWord === word || hoverLookupKey && this.hoverPendingLookupKey === hoverLookupKey) && this.hoverLookupTimer);
43716}
43717runScheduledHoverLookup(word, event, hoverLookupGeneration) {
43718if (this.hoverLookupGeneration !== hoverLookupGeneration) return;
43719this.hoverLookupTimer = void 0;
43720this.hoverPendingWord = void 0;
43721this.hoverPendingLookupKey = "";
43722const activeWord = this.scheduledHoverWord(word);
43723if (!activeWord || !this.canRunScheduledHoverLookup(activeWord, event)) return;
43724this.startScheduledHoverWordLookup(activeWord, hoverLookupGeneration);
43725}
43726scheduledHoverWord(fallbackWord) {
43727const pointer = this.lastPointerPosition;
43728if (pointer) {
43729const liveWord = this.liveReaderWordAtPointer(pointer.x, pointer.y);
43730if (liveWord) return liveWord;
43731}
43732return fallbackWord.isConnected ? fallbackWord : null;
43733}
43734startScheduledHoverWordLookup(activeWord, hoverLookupGeneration) {
43735const activeHoverLookupKey = this.hoverLookupKeyForWord(activeWord);
43736const pointer = this.lastPointerPosition;
43737if (!pointer) return;
43738const target = document.elementFromPoint(pointer.x, pointer.y);
43739const candidate = this.renderedWordPointerLookupCandidate(activeWord, pointer.x, pointer.y, target);
43740if (!candidate) return;
43741if (activeHoverLookupKey) this.hoverLookupInFlightKey = activeHoverLookupKey;
43742void this.showLookupCandidate(candidate, "hover", { hoverLookupGeneration }).finally(() => {
43743if (this.hoverLookupInFlightKey === activeHoverLookupKey) this.hoverLookupInFlightKey = "";
43744});
43745}
43746canRunScheduledHoverLookup(activeWord, event) {
43747const hoverLookupKey = this.hoverLookupKeyForWord(activeWord);
43748if (!this.isRunnableScheduledHoverWord(activeWord, hoverLookupKey)) return false;
43749if (this.isActiveHoverLookup(hoverLookupKey)) {
43750this.refreshActiveHoverAnchor(activeWord);
43751return false;
43752}
43753if (!this.canOpenHoverLookupForWord(activeWord, event)) return false;
43754return true;
43755}
43756isRunnableScheduledHoverWord(activeWord, hoverLookupKey) {
43757return activeWord.isConnected && !this.isSuppressedHoverLookup(activeWord, hoverLookupKey);
43758}
43759canOpenHoverLookupForWord(activeWord, event) {
43760return this.isWordHoverActive(activeWord) && this.canUseHoverLookupPointer(event) && this.settings.lookupOnHover && shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys);
43761}
43762schedulePointerTextLookup(candidate, event, options = {}) {
43763if (this.isActivePointerTextLookup(candidate)) {
43764this.refreshActiveHoverAnchor(candidate.anchor);
43765return;
43766}
43767const hoverLookupKey = this.pendingPointerTextLookupKey(candidate);
43768if (this.isPointerTextLookupAlreadyQueued(hoverLookupKey)) return;
43769this.cancelHoverClose();
43770if (this.retargetPendingPointerTextLookup(candidate, hoverLookupKey, options.minimumDelayMs)) return;
43771window.clearTimeout(this.hoverLookupTimer);
43772const hoverLookupGeneration = this.nextHoverLookupGeneration();
43773const pending = { candidate, generation: hoverLookupGeneration };
43774this.pendingPointerTextLookup = pending;
43775this.hoverPendingWord = void 0;
43776this.hoverPendingLookupKey = hoverLookupKey;
43777const runLookup = () => {
43778if (this.hoverLookupGeneration !== hoverLookupGeneration) return;
43779if (options.minimumDelayMs !== void 0 && this.lastPointerPosition) {
43780this.hoverPopoverPointerPosition = { ...this.lastPointerPosition };
43781}
43782this.hoverLookupTimer = void 0;
43783this.hoverPendingLookupKey = "";
43784const target = pending.candidate;
43785if (!target.anchor.isConnected || !this.settings.lookupOnHover) return;
43786if (!shortcutIsPressed(this.settings.shortcuts.hoverLookup ?? "", event, this.pressedKeys)) return;
43787if (!this.isCurrentPointerTextHoverCandidate(target)) return;
43788const inFlightKey = this.pendingPointerTextLookupKey(target);
43789this.hoverLookupInFlightKey = inFlightKey;
43790void this.showLookupCandidate(target, "hover", { hoverLookupGeneration }).finally(() => {
43791if (this.hoverLookupInFlightKey === inFlightKey) this.hoverLookupInFlightKey = "";
43792});
43793};
43794this.startHoverLookupAfterDelay(runLookup, hoverLookupScheduleDelay({
43795switchesAnchor: this.activePopoverMode === "hover",
43796hoverOpenDelayMs: this.settings.hoverOpenDelayMs,
43797minimumDelayMs: options.minimumDelayMs
43798}));
43799}
43800retargetPendingPointerTextLookup(candidate, hoverLookupKey, minimumDelayMs) {
43801const pending = this.pendingPointerTextLookup;
43802const retargets = retargetsPendingHoverOpen({ timerPending: Boolean(this.hoverLookupTimer), popoverOpen: this.activePopoverMode === "hover", minimumDelayMs });
43803if (!retargets || pending?.generation !== this.hoverLookupGeneration) return false;
43804pending.candidate = candidate;
43805this.hoverPendingLookupKey = hoverLookupKey;
43806return true;
43807}
43808isPointerTextLookupAlreadyQueued(hoverLookupKey) {
43809return Boolean(hoverLookupKey && (this.hoverPendingLookupKey === hoverLookupKey && this.hoverLookupTimer || this.hoverLookupInFlightKey === hoverLookupKey));
43810}
43811cancelHoverClose() {
43812this.hoverClose.cancel();
43813}
43814markHoverPopoverSelfResize() {
43815if (this.activePopoverMode !== "hover" || !this.activePopover || !this.lastPointerPosition) return;
43816this.hoverResizeStickyPointer = { ...this.lastPointerPosition };
43817this.hoverResizeStickyExpiry = Date.now() + HOVER_POPOVER_RESIZE_STICKY_MS;
43818this.cancelHoverClose();
43819}
43820isHoverPopoverResizeStickyActive() {
43821const sticky = this.hoverResizeStickyPointer;
43822if (!sticky || !this.activePopover || this.activePopoverMode !== "hover") return false;
43823if (Date.now() > this.hoverResizeStickyExpiry) {
43824this.clearHoverPopoverResizeSticky();
43825return false;
43826}
43827const pointer = this.lastPointerPosition;
43828if (pointer && pointer.x === sticky.x && pointer.y === sticky.y) return true;
43829this.clearHoverPopoverResizeSticky();
43830return false;
43831}
43832clearHoverPopoverResizeSticky() {
43833this.hoverResizeStickyPointer = void 0;
43834this.hoverResizeStickyExpiry = 0;
43835}
43836scheduleHoverClose(delay2 = this.settings.hoverCloseDelayMs, options = {}) {
43837if (this.activePopoverMode !== "hover") return;
43838this.hoverClose.arm(delay2, options);
43839}
43840shouldDeferSubtitleMiningResumeForHoverClose() {
43841if (this.activePopoverMode !== "hover" || !this.subtitleMiningPausedVideo) return false;
43842if (this.activeHoverWord?.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR)) return true;
43843const pointer = this.lastPointerPosition;
43844if (!pointer) return false;
43845const target = document.elementFromPoint(pointer.x, pointer.y);
43846if (target instanceof Element && target.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR)) return true;
43847const liveWord = this.hoverReaderWordFromPointStack(pointer.x, pointer.y) ?? (target instanceof Element ? this.readerWordFromRenderedGeometry(target, pointer.x, pointer.y, (item) => this.canHoverLookupReaderWord(item)) : null);
43848return Boolean(liveWord?.closest(VIDEO_LOOKUP_ANCHOR_SELECTOR));
43849}
43850isHoverContextActive(options = {}) {
43851if (this.hasDirectHoverContext()) return true;
43852if (this.activePointerTextLookup) return this.isPointerTextHoverContextActive(options);
43853if (this.hasActiveLookupHoverContext(options)) return true;
43854return this.hasPopoverHoverContext(options);
43855}
43856hasActiveLookupHoverContext(options) {
43857return Boolean(this.activeHoverWord && this.isWordHoverActive(this.activeHoverWord, options));
43858}
43859hasPopoverHoverContext(options) {
43860if (this.isPopoverCssHoverActive(options)) return true;
43861const target = this.currentHoverPointerTarget(options);
43862return Boolean(target && this.isInsideActiveHoverContext(target));
43863}
43864hasDirectHoverContext() {
43865return this.hasLatchedHoverPopoverPointer() || this.isHoverPopoverResizeStickyActive() || this.isMiddlePressHoverContextActive() || isActiveHoverPopoverPointerContext(this.activeHoverPopoverPointerState());
43866}
43867activeHoverPopoverPointerState() {
43868return {
43869mode: this.activePopoverMode,
43870popover: this.activePopover,
43871point: this.lastPointerPosition,
43872origin: this.hoverPopoverPointerPosition
43873};
43874}
43875isMiddlePressHoverContextActive() {
43876const pressLookup = this.pressLookup;
43877const word = pressLookup?.lastWord;
43878return Boolean(
43879pressLookup?.source === "middle" && pressLookup.active && word?.isConnected && this.activeHoverWord === word
43880);
43881}
43882isPointerTextHoverContextActive(options) {
43883if (this.isPopoverCssHoverActive(options)) return true;
43884if (!this.lastPointerPosition) return false;
43885const target = document.elementFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y);
43886const current = this.currentPointerTextHoverCandidateAtPoint(
43887this.lastPointerPosition.x,
43888this.lastPointerPosition.y,
43889target
43890);
43891const active = this.activePointerTextLookup;
43892if (!active || !current) return false;
43893if (!active.anchor.isConnected) return this.reanchorDisconnectedPointerText(active, current);
43894return samePointerTextLookupTarget(active, current) && pointerOffsetInsideLiveLookup(active, current.offset);
43895}
43896reanchorDisconnectedPointerText(active, current) {
43897if (active.text !== current.text || !pointerOffsetInsideLiveLookup(active, current.offset)) return false;
43898this.activePointerTextLookup = { ...active, anchor: current.anchor };
43899this.refreshActiveHoverAnchor(current.anchor);
43900return true;
43901}
43902isPopoverCssHoverActive(options) {
43903return !options.ignoreCssHover && Boolean(this.activePopover?.matches(":hover"));
43904}
43905currentHoverPointerTarget(options) {
43906if (options.ignorePointerPosition || !this.lastPointerPosition) return null;
43907return document.elementFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y);
43908}
43909isInsideActiveHoverContext(target) {
43910const anchor = this.activeHoverWord ?? (this.activePopoverMode === "hover" ? this.activePopoverAnchor : void 0);
43911return this.isInsideActivePopover(target) || Boolean(anchor && (this.isInsideNode(target, anchor) || this.isWithinHoverWordHostControl(anchor, target)));
43912}
43913isWordHoverActive(word, options = {}) {
43914if (!word.isConnected) return this.reanchorDisconnectedHoverWord(word, options);
43915return this.hoverWordOwnership.isActive(word, this.lastPointerPosition, options);
43916}
43917reanchorDisconnectedHoverWord(word, options) {
43918if (!this.lastPointerPosition) return false;
43919const replacement = this.liveReaderWordAtPointer(this.lastPointerPosition.x, this.lastPointerPosition.y);
43920if (!replacement || replacement === word || renderedWordElementKey(replacement) !== renderedWordElementKey(word)) return false;
43921this.refreshActiveHoverAnchor(replacement);
43922return true;
43923}
43924liveReaderWordAtPointer(x, y) {
43925const target = document.elementFromPoint(x, y);
43926return this.hoverReaderWordFromPointStack(x, y) ?? this.ocrLineWordForPointer(target, x, y) ?? (target instanceof Element ? this.readerWordFromRenderedGeometry(target, x, y, (item) => this.canHoverLookupReaderWord(item)) : null);
43927}
43928hoverLookupKeyForWord(word) {
43929const vid = Number(renderedWordPrivateValue(word, "vid"));
43930const sid = Number(renderedWordPrivateValue(word, "sid"));
43931if (!Number.isFinite(vid) || !Number.isFinite(sid)) return "";
43932return `word:${vid}:${sid}:${word.dataset.sentence ?? ""}`;
43933}
43934pendingPointerTextLookupKey(candidate) {
43935return `text-pending:${this.hoverAnchorId(candidate.anchor)}:${candidate.start}:${candidate.end}:${candidate.text.length}`;
43936}
43937activePointerTextLookupKey(candidate, start, end, card) {
43938return `text:${this.hoverAnchorId(candidate.anchor)}:${start}:${end}:${cardKey(card)}`;
43939}
43940hoverAnchorId(anchor) {
43941const existing = this.hoverAnchorIds.get(anchor);
43942if (existing) return existing;
43943const next = this.nextHoverAnchorId++;
43944this.hoverAnchorIds.set(anchor, next);
43945return next;
43946}
43947isActiveHoverLookup(hoverLookupKey) {
43948return Boolean(hoverLookupKey && this.activePopover && this.activePopoverMode === "hover" && this.activeHoverLookupKey === hoverLookupKey);
43949}
43950isActivePointerTextLookup(candidate) {
43951const active = this.activePointerTextLookup;
43952if (!active || !this.hasActiveHoverPopover()) return false;
43953if (!samePointerTextLookupTarget(active, candidate)) return false;
43954return pointerOffsetInsideLiveLookup(active, candidate.offset);
43955}
43956isCurrentPointerTextHoverCandidate(candidate) {
43957if (!this.lastPointerPosition) return false;
43958const current = this.currentPointerTextHoverCandidateAtPoint(
43959this.lastPointerPosition.x,
43960this.lastPointerPosition.y,
43961document.elementFromPoint(this.lastPointerPosition.x, this.lastPointerPosition.y)
43962);
43963return Boolean(current && samePointerTextLookupTarget({ anchor: candidate.anchor, text: candidate.text, start: candidate.start, end: candidate.end }, current) && pointerOffsetInsideLiveLookup({ anchor: candidate.anchor, text: candidate.text, start: candidate.start, end: candidate.end }, current.offset));
43964}
43965currentPointerTextHoverCandidateAtPoint(x, y, target) {
43966const word = this.readerWordOwnedByPointerTarget(target, x, y) ?? this.liveReaderWordAtPointer(x, y);
43967const candidate = this.pointerTextCandidateForRenderedWord(word, x, y, target);
43968return candidate ?? this.lookupCandidateFromPoint(x, y, target, HOVER_POINTER_TEXT_LOOKUP_OPTIONS);
43969}
43970readerWordOwnedByPointerTarget(target, x, y) {
43971if (!(target instanceof Element)) return null;
43972return this.readerWordFromRenderedGeometry(target, x, y, (item) => this.canHoverLookupReaderWord(item));
43973}
43974pointerTextCandidateForRenderedWord(word, x, y, target) {
43975if (!word) return null;
43976return this.renderedWordPointerLookupCandidate(word, x, y, target);
43977}
43978hasActiveHoverPopover() {
43979return this.activePopoverMode === "hover" && Boolean(this.activePopover);
43980}
43981refreshActiveHoverAnchor(anchor) {
43982if (!this.canRefreshActiveHoverAnchor(anchor)) return;
43983if (this.activePopoverAnchor === anchor && (this.activePointerTextLookup || this.activeHoverWord === anchor)) return;
43984this.activePopoverAnchor = anchor;
43985if (!this.activePointerTextLookup) this.activeHoverWord = anchor;
43986this.retainOcrLookupLineForAnchor(anchor);
43987this.captureActiveHoverAnchorRect(anchor);
43988this.repositionActivePopover();
43989}
43990canRefreshActiveHoverAnchor(anchor) {
43991return Boolean(this.activePopover && this.activePopoverMode === "hover" && anchor.isConnected);
43992}
43993captureActiveHoverAnchorRect(anchor) {
43994this.activePopoverAnchorRect = popoverAnchorRect(anchor, this.activePopoverAnchorRect);
43995}
43996isInsideActivePopover(node) {
43997return Boolean(this.activePopover && this.isInsideNode(node, this.activePopover));
43998}
43999isInsideNode(node, root) {
44000return Boolean(node && (node === root || root.contains(node)));
44001}
44002hasOpenReaderDialog() {
44003return Boolean(this.activePopover || this.activeBackdrop || document.querySelector("[data-j
44003pdb-reader-root].jpdb-reader-popover, [data-jpdb-reader-root].jpdb-reader-settings, [data-jpdb-reader-root].jpdb-reader-backdrop"));
44004}
44005async parseJapanese(paragraphs, options) {
44006return this.parser.parse(paragraphs, options);
44007}
44008canParseJapanese() {
44009return this.parser.canParse();
44010}
44011getCachedCard(vid, sid) {
44012return this.parser.getCachedCard(vid, sid);
44013}
44014isJpdbBackedCard(card) {
44015return this.parser.isJpdbBackedCard(card);
44016}
44017lookupText(text2, sentence = text2, options = {}, scope = this.cardLookup.captureTarget()) {
44018return this.cardLookup.lookupText(text2, sentence, options, scope);
44019}
44020resolveLookupCard(card, scope = this.cardLookup.captureTarget()) {
44021return this.cardLookup.resolveLookupCard(card, scope);
44022}
44023resolveLookupCardForInitialRender(card, scope = this.cardLookup.captureTarget()) {
44024return this.cardLookup.resolveLookupCardForInitialRender(card, scope);
44025}
44026publicLookupCard(term, exact = false, readingOrOptions = "", options = {}) {
44027return this.cardLookup.publicLookupCard(term, exact, readingOrOptions, options);
44028}
44029publicLookupFallbackCards(cards, options = {}, scope = this.cardLookup.captureTarget()) {
44030return this.cardLookup.publicLookupFallbackCards(cards, options, scope);
44031}
44032lookupFallbackApiCard(card, options = {}, scope = this.cardLookup.captureTarget()) {
44033return this.cardLookup.lookupFallbackApiCard(card, options, scope);
44034}
44035localLookupEntries(selected) {
44036return this.settings.localDictionariesEnabled ? this.dictionaries.lookup(selected, selected, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
44037}
44038activeTextLookupTrigger() {
44039return this.activePopoverMode === "hover" ? "hover" : "modal";
44040}
44041textLookupPreviousNavigationEntry(trigger, navigation) {
44042return trigger === "modal" && navigation === "push-current" ? this.activePopoverNavigationEntry() : void 0;
44043}
44044handleDictionaryLookupLink(event, anchor, trigger) {
44045const link = dictionaryLookupLink(event.target);
44046if (!link) return false;
44047const query = dictionaryLookupQuery(link);
44048const nestedWord = dictionaryLookupNestedWord(event.target, link);
44049if (this.handleNestedDictionaryLookupWord(event, nestedWord, query, trigger)) return true;
44050if (!query) return false;
44051return this.handleDictionaryReferenceLookup(event, link, query, anchor, trigger);
44052}
44053handleNestedDictionaryLookupWord(event, nestedWord, query, trigger) {
44054if (!nestedWord || !this.shouldLookupNestedDictionaryWord(nestedWord, query)) return false;
44055event.preventDefault();
44056event.stopPropagation();
44057this.primeLookupAudioFromGesture();
44058const navigation = trigger === "modal" ? "push-current" : "reset";
44059const candidate = this.renderedWordLookupCandidateForActivation(nestedWord, event);
44060if (candidate) {
44061void this.showLookupCandidate(candidate, trigger, {
44062navigation,
44063preservePosition: trigger === "modal",
44064userGesture: true
44065});
44066} else {
44067void this.showWord(nestedWord, { trigger: "click", navigation, userGesture: true });
44068}
44069return true;
44070}
44071shouldLookupNestedDictionaryWord(nestedWord, query) {
44072if (isOcrLineFrameWord(nestedWord) || isNativePageLookupBlocked(nestedWord)) return false;
44073if (!dictionaryLookupWordMatchesLink(nestedWord, query)) return true;
44074return this.isInsideActivePopover(nestedWord) && nestedWord.hasAttribute("data-vid") && nestedWord.hasAttribute("data-sid") && nestedWord.dataset.jpdbReaderRelatedWord !== "true";
44075}
44076handleDictionaryReferenceLookup(event, link, query, anchor, trigger) {
44077event.preventDefault();
44078event.stopPropagation();
44079void this.lookupDictionaryReference(query, link.dataset.dictionaryReading ?? "", link.dataset.dictionary ?? "", anchor, trigger, this.isInsideActivePopover(event.target));
44080return true;
44081}
44082async lookupDictionaryReference(query, reading, sourceDictionary, anchor, trigger, preservePosition = false) {
44083if (!isTargetLanguageText(query)) return;
44084const scope = this.cardLookup.captureTarget();
44085const normalizedReading = reading.replace(/\s+/g, " ").trim();
44086const navigation = trigger === "modal" ? "push-current" : "reset";
44087const done = log.time("dictionaryReferenceLookup", { query, hasReading: Boolean(normalizedReading), sourceDictionary, trigger });
44088try {
44089const previousNavigationEntry = this.textLookupPreviousNavigationEntry(trigger, navigation);
44090const jpdbCard = sourceDictionary === "JPDB" ? await this.publicLookupCard(query, true, normalizedReading) : void 0;
44091if (!scope.isCurrent()) return;
44092if (jpdbCard) {
44093this.parser.cacheCards?.([jpdbCard]);
44094await this.showCard(jpdbCard, query, anchor, { autoPlay: false, trigger, navigation, preservePosition, previousNavigationEntry });
44095return;
44096}
44097const localEntries = await this.dictionaryReferenceLocalEntries(query, normalizedReading, sourceDictionary);
44098if (!scope.isCurrent()) return;
44099const preferredEntry = localEntries.find((entry) => entry.dictionary === sourceDictionary) ?? localEntries[0];
44100if (preferredEntry) {
44101await this.showCard(
44102this.parser.localCardFromEntry(preferredEntry, scope.target),
44103query,
44104anchor,
44105{ autoPlay: false, trigger, navigation, preservePosition, previousNavigationEntry }
44106);
44107return;
44108}
44109await this.lookupText(query, query, { navigation, preservePosition, previousNavigationEntry });
44110} finally {
44111done();
44112}
44113}
44114async dictionaryReferenceLocalEntries(query, reading, sourceDictionary) {
44115if (!this.settings.localDictionariesEnabled) return [];
44116return await this.dictionaries.lookup(query, reading || query, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch((error) => {
44117log.warn("Dictionary reference failed", { query, reading, sourceDictionary }, error);
44118return [];
44119});
44120}
44121lookupCandidateFromPoint(x, y, eventTarget, options = {}) {
44122const element = this.pointerLookupElement(x, y, eventTarget, options);
44123if (!element) return null;
44124const position = this.usablePointerTextPosition(element, x, y);
44125if (!position) return null;
44126const characterOffset = pointerTextCharacterOffset(position.node, position.offset, x, y);
44127if (characterOffset === null) return null;
44128return this.lookupCandidateFromTextPosition(position.node, characterOffset, options);
44129}
44130pointerLookupElement(x, y, eventTarget, options = {}) {
44131const element = eventTarget instanceof Element ? eventTarget : document.elementFromPoint(x, y);
44132return element && !this.isNativeTextLookupTarget(element, options) && !isNativePageLookupBlocked(element) ? element : null;
44133}
44134usablePointerTextPosition(element, x, y) {
44135const position = caretTextPositionFromPoint(x, y);
44136return this.isUsablePointerTextPosition(element, position) ? position : null;
44137}
44138lookupCandidateFromTextPosition(node, characterOffset, options = {}) {
44139return pointerTextLookupFromTextNode(node, characterOffset, { allowInteractiveText: options.allowPassiveInteractionText });
44140}
44141isUsablePointerTextPosition(element, position) {
44142return Boolean(position && position.node.parentElement && (element.contains(position.node) || position.node.parentElement.contains(element)) && !position.node.parentElement.closest(".jpdb-reader-word"));
44143}
44144isNativeTextLookupTarget(target, options = {}) {
44145return !options.allowPassiveInteractionText && isPassiveInteractionElement(target) || this.isReaderImmersionExampleSentenceText(target) || !!target.closest("input,textarea,select,[contenteditable],.jpdb-reader-word") || this.isSettingsNativeControlText(target);
44146}
44147isReaderImmersionExampleSentenceText(target) {
44148return Boolean(target.closest("[data-jpdb-reader-root] [data-immersion-kit] .jpdb-reader-example-sentence"));
44149}
44150isSettingsNativeControlText(target) {
44151return Boolean(target.closest(".jpdb-reader-settings") && target.closest("a[href],button,input,label,select,textarea,[role=button],[role=checkbox],[role=link],[role=menuitem],[role=option],[role=radio],[role=switch],[role=tab],[data-action]"));
44152}
44153async showLookupCandidate(candidate, trigger, options = {}) {
44154const sentence = lookupCandidateSentence(candidate.text, candidate.start, candidate.end);
44155if (!sentence) return;
44156const done = log.time("lookupTextAtPointer", { length: sentence.length, offset: candidate.offset, trigger });
44157try {
44158await this.showFirstPointerTextCandidate(candidate, sentence, trigger, options);
44159} catch {
44160} finally {
44161done();
44162}
44163}
44164async showFirstPointerTextCandidate(candidate, sentence, trigger, options) {
44165const scope = this.cardLookup.captureTarget();
44166try {
44167const token = await this.parser.lookupTokenAt(
44168candidate.text,
44169candidate.offset,
44170{ start: candidate.start, end: candidate.end },
44171this.pointerTextJpdbParseOptions()
44172);
44173if (!scope.isCurrent() || !token) return;
44174await this.showPointerTextCard(token.card, sentence, candidate, token, trigger, options);
44175} catch (error) {
44176if (scope.isCurrent()) log.warn("Authoritative pointer lookup failed", { offset: candidate.offset }, error);
44177}
44178}
44179pointerTextJpdbParseOptions() {
44180if (!hasJpdbApiCredential(this.settings)) return jpdbFirstParseOptions();
44181return jpdbFirstParseOptions({
44182requireJpdb: false,
44183jpdbTimeoutMs: POINTER_TEXT_JPDB_TIMEOUT_MS,
44184allowJpdbTimeoutFallback: true
44185});
44186}
44187async showPointerTextCard(card, sentence, candidate, range, trigger, options) {
44188if (trigger === "hover" && !this.isCurrentPointerTextHoverResult(candidate, range, options.hoverLookupGeneration)) return;
44189const navigation = options.navigation ?? "reset";
44190const pointerTextLookup = { anchor: candidate.anchor, text: candidate.text, start: range.start, end: range.end };
44191const cardSentence = lookupCandidateSentence(candidate.text, range.start, range.end) || sentence;
44192await this.showCard(card, cardSentence, candidate.anchor, {
44193trigger,
44194navigation,
44195preservePosition: options.preservePosition,
44196previousNavigationEntry: this.textLookupPreviousNavigationEntry(trigger, navigation),
44197hoverLookupKey: trigger === "hover" ? this.activePointerTextLookupKey(candidate, range.start, range.end, card) : void 0,
44198hoverLookupGeneration: options.hoverLookupGeneration,
44199pointerTextLookup,
44200userGesture: options.userGesture
44201});
44202}
44203isCurrentRenderedWordHover(word, hoverLookupKey, hoverLookupGeneration) {
44204if (!this.isCurrentHoverGeneration(hoverLookupGeneration, hoverLookupKey)) return false;
44205const activeWord = this.currentRenderedHoverWord(word);
44206if (!activeWord) return false;
44207const activeMiddlePressLookup = this.pressLookup?.source === "middle" && this.pressLookup.lastWord === activeWord;
44208return this.isRunnableScheduledHoverWord(activeWord, hoverLookupKey) && (activeMiddlePressLookup || this.isWordHoverActive(activeWord) && this.settings.lookupOnHover);
44209}
44210currentRenderedHoverWord(word) {
44211if (word.isConnected) return word;
44212if (!this.reanchorDisconnectedHoverWord(word, {})) return null;
44213return this.activeHoverWord?.isConnected ? this.activeHoverWord : null;
44214}
44215isCurrentPointerTextHoverResult(candidate, range, hoverLookupGeneration) {
44216const hoverLookupKey = `text-result:${this.hoverAnchorId(candidate.anchor)}:${range.start}:${range.end}`;
44217if (!this.isCurrentHoverGeneration(hoverLookupGeneration, hoverLookupKey)) return false;
44218return this.isCurrentPointerTextHoverCandidate({ ...candidate, start: range.start, end: range.end });
44219}
44220isCurrentHoverGeneration(hoverLookupGeneration, hoverLookupKey) {
44221if (hoverLookupGeneration === void 0) return true;
44222if (this.hoverLookupGeneration === hoverLookupGeneration) return true;
44223return this.isActiveHoverLookup(hoverLookupKey) && !this.hasPendingDifferentHoverLookup(hoverLookupKey) && this.isHoverContextActive();
44224}
44225hasPendingDifferentHoverLookup(hoverLookupKey) {
44226return Boolean(this.hoverPendingLookupKey && this.hoverPendingLookupKey !== hoverLookupKey || this.hoverLookupInFlightKey && this.hoverLookupInFlightKey !== hoverLookupKey);
44227}
44228async showWord(word, options = {}) {
44229const scope = this.cardLookup.captureTarget();
44230if (this.shouldIgnoreRenderedWordLookup(word, options)) return;
44231const insideReaderPopup = Boolean(word.closest(".jpdb-reader-popover"));
44232const stackOverSettings = options.stackOverSettings || Boolean(word.closest(".jpdb-reader-settings"));
44233const card = this.cardForRenderedWord(word);
44234if (!card) {
44235await this.handleMissingRenderedWordCard(word, { ...options, stackOverSettings }, insideReaderPopup, scope);
44236return;
44237}
44238if (stackOverSettings && !insideReaderPopup && options.trigger !== "hover" && !card.meanings.length && await this.lookupUncachedPageWord(word, { ...options, stackOverSettings }, scope)) return;
44239if (!scope.isCurrent()) return;
44240this.rememberRenderedWordMiningContext(word, card, insideReaderPopup);
44241const context = this.renderedWordDisplayContext(word, options, insideReaderPopup);
44242if (this.refreshActiveRenderedWordHover(word, context)) return;
44243if (this.isStaleRenderedWordHover(word, context, options.hoverLookupGeneration)) return;
44244this.preloadHoverWordAudio(word);
44245if (await this.showAuthoritativeSpanForRenderedWord(word, card, context, options, stackOverSettings, scope)) return;
44246if (await this.showOcrKanjiRenderedWord(word, card, context, scope)) return;
44247await this.showRenderedWordCard(card, context, options, stackOverSettings, scope);
44248}
44249async showOcrKanjiRenderedWord(word, card, context, scope) {
44250if (!scope.isCurrent()) return true;
44251const ocrKanji = singleKanjiOcrLookupCharacter(word);
44252if (!ocrKanji || context.trigger !== "modal") return false;
44253await this.showKanjiCard(card, ocrKanji, ocrKanji, context.anchor, {
44254navigation: context.navigation,
44255preservePosition: context.insideReaderPopup
44256});
44257return true;
44258}
44259shouldIgnoreRenderedWordLookup(word, options) {
44260return options.trigger === "click" && this.shouldIgnoreCurrentImmersionExampleTargetClick(word);
44261}
44262refreshActiveRenderedWordHover(word, context) {
44263if (!context.hoverLookupKey || !this.isActiveHoverLookup(context.hoverLookupKey)) return false;
44264this.refreshActiveHoverAnchor(word);
44265return true;
44266}
44267isStaleRenderedWordHover(word, context, hoverLookupGeneration) {
44268return context.trigger === "hover" && !this.isCurrentRenderedWordHover(word, context.hoverLookupKey ?? "", hoverLookupGeneration);
44269}
44270showRenderedWordCard(card, context, options, stackOverSettings, scope) {
44271if (!scope.isCurrent()) return Promise.resolve();
44272return this.showCard(card, context.sentence, context.anchor, {
44273trigger: context.trigger,
44274navigation: context.navigation,
44275preservePosition: context.insideReaderPopup && context.trigger === "modal",
44276previousNavigationEntry: context.previousNavigationEntry,
44277hoverLookupKey: context.hoverLookupKey,
44278hoverLookupGeneration: options.hoverLookupGeneration,
44279insideReaderPopup: context.insideReaderPopup,
44280userGesture: options.userGesture,
44281stackOverSettings,
44282skipInitialCardResolution: true
44283});
44284}
44285/** Re-resolve only unconfirmed fallback spans so stale fragments can widen;
44286* provider-confirmed cards skip this authority pass. */
44287async showAuthoritativeSpanForRenderedWord(word, card, context, options, stackOverSettings, scope) {
44288const span = unconfirmedRenderedWordSpan(word, card, context);
44289if (!span) return false;
44290try {
44291const token = await this.parser.lookupTokenAt(
44292span.sentence,
44293span.start,
44294{ start: 0, end: span.sentence.length },
44295this.pointerTextJpdbParseOptions()
44296);
44297if (!scope.isCurrent()) return true;
44298if (!token) return false;
44299if (card && token.end - token.start <= span.end - span.start) return false;
44300if (!card && token.card.source === "fallback" && token.end - token.start <= span.end - span.start) return false;
44301this.parser.cacheCards?.([token.card]);
44302await this.showRenderedWordCard(
44303token.card,
44304{ ...context, sentence: span.sentence },
44305options,
44306stackOverSettings,
44307scope
44308);
44309return true;
44310} catch (error) {
44311if (!scope.isCurrent()) return true;
44312log.warn("Authoritative rendered-word span lookup failed", { expression: card?.spelling ?? word.dataset.expression }, error);
44313return false;
44314}
44315}
44316cardForRenderedWord(word) {
44317const vid = Number(renderedWordPrivateValue(word, "vid"));
44318const sid = Number(renderedWordPrivateValue(word, "sid"));
44319const card = this.getCachedCard(vid, sid);
44320return renderedWordCardForLookup(word, card);
44321}
44322async handleMissingRenderedWordCard(word, options, insideReaderPopup, scope) {
44323if (!scope.isCurrent()) return;
44324const { vid, sid } = renderedWordNumericIdentity(word);
44325const context = this.renderedWordDisplayContext(word, options, insideReaderPopup);
44326const uncachedLookup = selectRenderedWordAttempt(
44327insideReaderPopup,
44328() => this.lookupUncachedPopupWord(word, options, scope),
44329() => this.lookupUncachedPageWord(word, options, scope)
44330);
44331if (await resolveRenderedWordAttempts([
44332() => this.showAuthoritativeSpanForRenderedWord(word, void 0, context, options, options.stackOverSettings ?? false, scope),
44333uncachedLookup
44334], () => scope.isCurrent())) return;
44335if (options.stackOverSettings) return;
44336log.warn("Clicked word cache miss; reparsing", { vid, sid });
44337this.scheduleVisiblePageReparse();
44338}
44339async lookupUncachedPageWord(word, options, scope) {
44340if (!scope.isCurrent()) return true;
44341const expression = renderedWordLookupText(word);
44342if (!isLookupableTargetLanguageText(expression)) return false;
44343const trigger = this.renderedWordTrigger(options.trigger, false);
44344const navigation = options.navigation ?? renderedWordNavigationMode(false, trigger);
44345if (!scope.isCurrent()) return true;
44346await this.lookupText(expression, this.renderedWordSentence(word) ?? expression, {
44347anchor: renderedWordAnchor(word, false, this.activePopoverAnchor),
44348navigation,
44349preservePosition: trigger === "hover",
44350previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, false, trigger, navigation),
44351userGesture: options.userGesture,
44352trigger,
44353hoverLookupGeneration: options.hoverLookupGeneration,
44354stackOverSettings: options.stackOverSettings
44355}, scope);
44356return true;
44357}
44358async lookupUncachedPopupWord(word, options, scope) {
44359if (!scope.isCurrent()) return true;
44360const expression = renderedWordLookupText(word);
44361if (!isLookupableTargetLanguageText(expression)) return false;
44362const trigger = this.renderedWordTrigger(options.trigger, true);
44363const navigation = options.navigation ?? renderedWordNavigationMode(true, trigger);
44364const sentence = this.renderedWordSentence(word) ?? expression;
44365if (word.closest(".jpdb-reader-example-card")) this.immersionPopover?.rememberTermMiningContext(expression, sentence, word);
44366await this.lookupText(expression, sentence, {
44367anchor: renderedWordAnchor(word, true, this.activePopoverAnchor),
44368navigation,
44369preservePosition: true,
44370previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, true, trigger, navigation),
44371insideReaderPopup: true,
44372userGesture: options.userGesture,
44373trigger,
44374hoverLookupGeneration: options.hoverLookupGeneration
44375}, scope);
44376return true;
44377}
44378rememberRenderedWordMiningContext(word, card, insideReaderPopup) {
44379if (!insideReaderPopup || !word.closest(".jpdb-reader-example-card")) return;
44380this.immersionPopover?.rememberPageMiningContext(card, this.renderedWordSentence(word), word);
44381}
44382renderedWordDisplayContext(word, options, insideReaderPopup) {
44383const trigger = this.renderedWordTrigger(options.trigger, insideReaderPopup);
44384const navigation = options.navigation ?? renderedWordNavigationMode(insideReaderPopup, trigger);
44385return {
44386sentence: trigger === "hover" ? this.renderedWordFastHoverSentence(word) : this.renderedWordSentence(word),
44387anchor: renderedWordAnchor(word, insideReaderPopup, this.activePopoverAnchor),
44388trigger,
44389navigation,
44390hoverLookupKey: this.renderedWordHoverLookupKey(word, trigger),
44391previousNavigationEntry: this.renderedWordPreviousNavigationEntryForOptions(options, insideReaderPopup, trigger, navigation),
44392insideReaderPopup
44393};
44394}
44395renderedWordFastHoverSentence(word) {
44396const immersionSentence = this.renderedImmersionExampleSentence(word);
44397if (immersionSentence) return immersionSentence;
44398const tokenSentence = normalizedLookupText(word.dataset.sentence || "");
44399if (tokenSentence) return tokenSentence;
44400return normalizedLookupText(renderedWordLookupText(word)) || void 0;
44401}
44402renderedWordSentence(word) {
44403const immersionSentence = this.renderedImmersionExampleSentence(word);
44404if (immersionSentence) return immersionSentence;
44405const tokenSentence = word.dataset.sentence || "";
44406if (tokenSentence && word.closest(".jpdb-reader-example-sentence")) return normalizedLookupText(tokenSentence);
44407return preferredRenderedWordSentence(
44408nearestReadableSentenceForElement(word, tokenSentence),
44409tokenSentence
44410);
44411}
44412renderedImmersionExampleSentence(word) {
44413if (!word.closest(".jpdb-reader-example-sentence")) return "";
44414const sentence = word.closest("[data-immersion-sentence]")?.dataset.immersionSentence ?? "";
44415return normalizedLookupText(sentence);
44416}
44417shouldIgnoreCurrentImmersionExampleTargetClick(word) {
44418if (!this.lastCard || !this.isInsideActivePopover(word)) return false;
44419const exampleSentence = word.closest(".jpdb-reader-example-sentence");
44420if (!exampleSentence || !this.isInsideImmersionKitContainer(exampleSentence)) return false;
44421if (!word.closest(".jpdb-reader-example-target")) return false;
44422return cardMatchesRenderedLookupValue(this.lastCard, renderedWordLookupText(word));
44423}
44424isInsideImmersionKitContainer(element) {
44425for (let current = element.parentElement; current; current = current.parentElement) {
44426if (current.hasAttribute("data-immersion-kit")) return true;
44427}
44428return false;
44429}
44430renderedWordHoverLookupKey(word, trigger) {
44431return trigger === "hover" ? this.hoverLookupKeyForWord(word) : void 0;
44432}
44433renderedWordPreviousNavigationEntryForOptions(options, insideReaderPopup, trigger, navigation) {
44434return options.previousNavigationEntry ?? this.renderedWordPreviousNavigationEntry(insideReaderPopup, trigger, navigation);
44435}
44436renderedWordTrigger(trigger, insideReaderPopup) {
44437if (insideReaderPopup && trigger === "click") return "modal";
44438if (insideReaderPopup && this.activePopoverMode) return this.activePopoverMode;
44439return trigger === "hover" ? "hover" : "modal";
44440}
44441renderedWordPreviousNavigationEntry(insideReaderPopup, trigger, navigation) {
44442return insideReaderPopup && trigger === "modal" && navigation === "push-current" ? this.activePopoverNavigationEntry() : void 0;
44443}
44444activePopoverNavigationEntry() {
44445const activeKanji = this.navigation.activeKanjiEntry();
44446if (activeKanji) return activeKanji;
44447if (!this.activePopover?.isConnected || !this.lastCard) return void 0;
44448return { kind: "word", card: this.lastCard, sentence: this.lastCardSentence };
44449}
44450showTokenList(tokens, selected, anchor, options = {}) {
44451if (!tokens.length) return;
44452const trigger = options.trigger === "hover" ? "hover" : "modal";
44453const navigation = options.navigation ?? "reset";
44454const previousNavigationEntry = trigger === "modal" ? options.previousNavigationEntry : void 0;
44455this.prepareTokenListNavigation(trigger, navigation);
44456const popover = this.createPopover(trigger);
44457setInnerHtml(popover, this.renderTokenListHtml(tokens, selected, previousNavigationEntry));
44458this.installTokenListHandlers(popover, tokens, anchor, { trigger, navigation, previousNavigationEntry, stackOverSettings: options.stackOverSettings });
44459this.mountPopover(popover, anchor, {
44460mode: trigger,
44461preservePosition: options.preservePosition,
44462focusOnMount: options.focusOnMount,
44463stackOverSettings: options.stackOverSettings
44464});
44465void this.parsePopoverJapanese(popover);
44466}
44467prepareTokenListNavigation(trigger, navigation) {
44468if (trigger === "modal" && navigation === "reset") this.navigation.clearWord();
44469}
44470renderTokenListHtml(tokens, selected, previousNavigationEntry) {
44471return renderTokenListHtml(tokens, selected, previousNavigationEntry, this.settings);
44472}
44473installTokenListHandlers(popover, tokens, anchor, context) {
44474installTokenListHandlers(popover, tokens, anchor, context, {
44475showPrevious: (previousAnchor, previousContext) => void this.showTokenListPrevious(previousAnchor, previousContext),
44476showCard: (button, nextTokens, cardAnchor, cardContext) => this.showTokenListCard(button, nextTokens, cardAnchor, cardContext),
44477copySelected: (selected) => void this.copyTokenListSelection(selected)
44478});
44479}
44480async copyTokenListSelection(selected) {
44481await copyText(selected);
44482this.toast(uiText$1(this.settings.interfaceLanguage, "copiedWord"));
44483}
44484async showTokenListPrevious(anchor, context) {
44485const previous = context.previousNavigationEntry;
44486if (!previous) return;
44487await this.showPreviousNavigationEntry(previous, anchor, context.trigger);
44488}
44489showTokenListCard(button, tokens, anchor, context) {
44490const choice = readTokenChoiceCommandCapability(button);
44491if (!choice) return;
44492const card = this.getCachedCard(choice.vid, choice.sid);
44493if (!card) return;
44494void this.showCard(card, tokens.find((token) => token.card === card)?.sentence, anchor, {
44495trigger: context.trigger,
44496navigation: context.navigation,
44497preservePosition: true,
44498previousNavigationEntry: context.previousNavigationEntry,
44499stackOverSettings: context.stackOverSettings
44500});
44501}
44502async showCard(card, sentence, anchor, options = {}) {
44503const scope = this.cardLookup.captureTarget();
44504if (!scope.isCurrent()) return;
44505const requestedCard = card;
44506const trigger = cardDisplayTrigger(options);
44507const immediatePitch = trigger === "modal";
44508this.prioritizeQueuedPitchEnrichment(requestedCard, { immediate: immediatePitch });
44509if (!options.skipInitialCardResolution) card = await this.resolveLookupCardForInitialRender(card, scope);
44510if (this.isDestroyed || typeof document === "undefined" || !scope.isCurrent()) return;
44511sentence = this.preferredCardSentence(sentence, anchor);
44512if (card !== requestedCard) this.prioritizeQueuedPitchEnrichment(card, { immediate: immediatePitch });
44513this.lastCard = card;
44514this.lastCardSentence = sentence;
44515const popover = this.createPopover(trigger);
44516const navigation = options.navigation ?? "reset";
44517const hoverLookup = this.cardHoverLookupContext(trigger, options);
44518const isCurrentHoverCard = () => scope.isCurrent() && this.isCurrentCardHoverLookup(trigger, hoverLookup);
44519this.navigation.updateWord(card, sentence, trigger, navigation, options.previousNavigationEntry);
44520this.navigation.clearKanji();
44521const done = log.time("showCard", { term: card.spelling, source: cardSourceLabel(card), trigger });
44522this.rememberCardMiningContext(card, sentence, anchor, options);
44523const fallbackAnkiLookup = this.fallbackCardAnkiLookup();
44524this.lastAnkiLookup = fallbackAnkiLookup;
44525this.maybePreloadLookupCardAudio(card, options, anchor);
44526let renderData;
44527const loadRenderData = () => {
44528renderData ??= this.noteInteractiveCardLoad(this.cardRenderData.load(card));
44529return renderData;
44530};
44531if (trigger !== "hover") loadRenderData();
44532const requestId = ++this.cardRenderRequest;
44533const mounted = await this.mountInitialCardShell(popover, card, sentence, anchor, {
44534trigger,
44535navigation,
44536options,
44537fallbackAnkiLookup,
44538anchor,
44539requestId,
44540isCurrentHoverCard,
44541hoverLookupGeneration: hoverLookup.generation
44542});
44543if (!mounted) {
44544done();
44545return;
44546}
44547if (options.skipInitialCardResolution) {
44548void this.refreshSkippedInitialCardResolution(
44549popover,
44550card,
44551sentence,
44552anchor,
44553options,
44554mounted.requestId,
44555isCurrentHoverCard,
44556scope
44557);
44558}
44559try {
44560await this.completeMountedCardRender({
44561popover,
44562card,
44563sentence,
44564trigger,
44565isCurrentHoverCard,
44566anchor,
44567mounted,
44568fallbackAnkiLookup,
44569loadRenderData
44570});
44571}
44571 finally {
44572done();
44573}
44574}
44575async completeMountedCardRender(context) {
44576const { popover, card, sentence, trigger, mounted, fallbackAnkiLookup, isCurrentHoverCard, anchor } = context;
44577if (trigger === "hover") {
44578await waitForHoverCardInitialPaint();
44579if (!this.isCurrentCardRender(popover, mounted.requestId, isCurrentHoverCard)) return;
44580}
44581const renderData = context.loadRenderData();
44582const renderState = { fullRenderCompleted: false };
44583this.renderDeferredCardLocalEntries(popover, card, sentence, trigger, renderData, fallbackAnkiLookup, mounted, renderState, isCurrentHoverCard, anchor);
44584const fullData = await this.cardRenderDataOrFallback(card, renderData.all, fallbackAnkiLookup);
44585renderState.fullRenderCompleted = true;
44586if (!this.isCurrentCardRender(popover, mounted.requestId, isCurrentHoverCard)) return;
44587this.renderCompletedCardPopover(popover, card, sentence, trigger, fullData, anchor);
44588const hydrationContext = {
44589popover,
44590card,
44591sentence,
44592trigger,
44593state: { data: fullData },
44594requestId: mounted.requestId,
44595isCurrentHoverCard,
44596anchor
44597};
44598this.renderHydratedCardAnkiLookup(hydrationContext, renderData);
44599this.renderHydratedCardLocalEntries(hydrationContext, renderData);
44600this.renderHydratedCardJitenVocabulary(hydrationContext, renderData);
44601this.renderHydratedCardPitchAccent(hydrationContext, renderData);
44602this.renderHydratedCardFrequencyRanks(hydrationContext, renderData);
44603this.renderHydratedCardBunproDefinition(hydrationContext, renderData);
44604}
44605async refreshSkippedInitialCardResolution(popover, card, sentence, anchor, options, requestId, isCurrentHoverCard, scope = this.cardLookup.captureTarget()) {
44606if (!scope.isCurrent() || !usesJapaneseProviders()) return;
44607if (!this.shouldResolveAfterSkippedInitialCardResolution(card)) return;
44608const resolved = await this.resolveLookupCard(card, scope).catch((error) => {
44609log.warn("Skipped initial card resolution failed", { term: card.spelling }, error);
44610return null;
44611});
44612if (!resolved || !scope.isCurrent() || !usesJapaneseProviders() || !this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44613if (!this.isResolvedCardRefresh(card, resolved)) return;
44614const changedRoots = this.applyPublicVocabularyToRenderedWords(card, resolved);
44615this.queueResolvedWordEffects([this.resolvedWordEffectsToken(resolved)], changedRoots);
44616await this.showCard(resolved, sentence, anchor, {
44617...options,
44618autoPlay: false,
44619navigation: "preserve",
44620preservePosition: true,
44621previousNavigationEntry: void 0,
44622skipInitialCardResolution: false
44623});
44624}
44625shouldResolveAfterSkippedInitialCardResolution(card) {
44626return card.source === "fallback" || card.source === "jpdb" && Boolean(card.sourceCardKey);
44627}
44628isResolvedCardRefresh(card, resolved) {
44629return resolved !== card && (cardKey(resolved) !== cardKey(card) || (resolved.source ?? "jpdb") !== (card.source ?? "jpdb"));
44630}
44631cardHoverLookupContext(trigger, options) {
44632return trigger === "hover" ? { generation: options.hoverLookupGeneration, key: options.hoverLookupKey ?? "" } : { generation: void 0, key: "" };
44633}
44634isCurrentCardHoverLookup(trigger, hoverLookup) {
44635return trigger !== "hover" || this.isCurrentHoverGeneration(hoverLookup.generation, hoverLookup.key);
44636}
44637fallbackCardAnkiLookup() {
44638return { state: "not-in-deck", notes: [], primary: null };
44639}
44640async cardRenderDataOrFallback(card, renderData, fallbackAnkiLookup) {
44641try {
44642return await renderData;
44643} catch (error) {
44644log.warn("Popup details failed", { term: card.spelling }, error);
44645return {
44646localEntries: [],
44647kanjiEntries: [],
44648metaEntries: [],
44649ankiLookup: this.lastAnkiLookup ?? fallbackAnkiLookup,
44650jpdbDecks: [],
44651ankiDecks: [],
44652jpdbVocabularyInfo: null
44653};
44654}
44655}
44656preferredCardSentence(sentence, anchor) {
44657const cleanSentence = normalizedLookupText(sentence ?? "");
44658const anchorSentence = this.renderedAnchorSentence(anchor);
44659if (!anchorSentence) return cleanSentence || void 0;
44660if (!cleanSentence) return anchorSentence;
44661if (anchorSentence.length > cleanSentence.length + 2) return anchorSentence;
44662return cleanSentence;
44663}
44664renderedAnchorSentence(anchor) {
44665const word = anchor?.closest(".jpdb-reader-word");
44666const sentence = normalizedLookupText(word?.dataset.sentence ?? anchor?.dataset.sentence ?? "");
44667return isTargetLanguageText(sentence) ? sentence : "";
44668}
44669rememberCardMiningContext(card, sentence, anchor, options) {
44670const hasNestedImmersionContext = options.insideReaderPopup && Boolean(this.immersionPopover?.activeContextFor(card));
44671if (hasNestedImmersionContext || this.immersionPopover?.hasActiveContext(card, sentence)) return;
44672this.immersionPopover?.rememberPageMiningContext(card, sentence, anchor);
44673}
44674async mountInitialCardShell(popover, card, sentence, anchor, context) {
44675if (!context.isCurrentHoverCard()) {
44676return null;
44677}
44678setInnerHtml(popover, this.cardPopoverRenderer.render(
44679card,
44680sentence,
44681context.trigger,
44682loadingCardRenderData([], context.fallbackAnkiLookup)
44683));
44684this.installCardPopoverHandlers(popover, card, sentence, anchor, context.trigger);
44685this.mountPopover(popover, anchor, {
44686mode: context.trigger,
44687preservePosition: this.initialCardPreservePosition(context),
44688focusOnMount: context.options.focusOnMount,
44689hoverLookupKey: context.options.hoverLookupKey,
44690pointerTextLookup: context.options.pointerTextLookup,
44691stackOverSettings: context.options.stackOverSettings
44692});
44693this.installInitialCardBehaviors(popover, card, sentence, context, null);
44694return { instantLocalEntries: null, requestId: context.requestId };
44695}
44696initialCardPreservePosition(context) {
44697return context.options.preservePosition ?? (context.trigger === "modal" && context.navigation !== "reset");
44698}
44699installInitialCardBehaviors(popover, card, sentence, context, instantLocalEntries) {
44700this.maybeAutoPlayInitialCard(card, context);
44701this.maybeParseInstantLocalEntries(popover, instantLocalEntries);
44702if (context.trigger === "hover") {
44703this.installInitialHoverCardBackgroundBehaviors(popover, card, sentence, context);
44704return;
44705}
44706this.studySources.installLoaders(popover, sentence);
44707this.installLazyImmersionExamples(popover, card);
44708}
44709installInitialHoverCardBackgroundBehaviors(popover, card, sentence, context) {
44710requestAnimationFrame(() => {
44711if (!this.isCurrentCardRender(popover, 0, context.isCurrentHoverCard)) return;
44712this.studySources.installLoaders(popover, sentence);
44713this.installLazyImmersionExamples(popover, card);
44714});
44715}
44716prioritizeQueuedPitchEnrichment(card, options = {}) {
44717if (!this.settings.showPitchAccent || cardHasContextPitch(card)) return;
44718const key = cardKey(card);
44719const queuedToken = this.takeQueuedPitchEnrichmentToken(key);
44720if (!queuedToken && !options.immediate) return;
44721const token = queuedToken ?? pitchEnrichmentTokenForCard(card);
44722void this.enrichPitchWords([token], { urgent: true });
44723}
44724maybeAutoPlayInitialCard(card, context) {
44725if (context.options.autoPlay === false || !this.isCurrentCardForAutoPlay(context)) return;
44726void this.playInitialCardAudio(card, context);
44727}
44728async playInitialCardAudio(card, context) {
44729const audioCard = await this.resolveInitialCardForAutoAudio(card, context);
44730if (!this.shouldAutoPlayInitialCard(audioCard, context)) return;
44731void this.audioActions.playTermAudio(audioCard, {
44732hoverLookupGeneration: context.hoverLookupGeneration,
44733userGesture: context.options.userGesture,
44734isCurrent: context.trigger === "hover" ? context.isCurrentHoverCard : void 0,
44735autoPlay: true
44736});
44737}
44738async resolveInitialCardForAutoAudio(card, context) {
44739return card;
44740}
44741maybePreloadLookupCardAudio(card, options, anchor) {
44742if (!this.canPreloadReaderAudio()) return;
44743this.audio.preload(card, {
44744sourceLimit: 1,
44745candidateLimit: 1,
44746prepareAudio: this.shouldPrepareLookupCardAudio(options, anchor)
44747});
44748}
44749shouldPrepareLookupCardAudio(options, anchor) {
44750const trigger = cardDisplayTrigger(options);
44751if (trigger !== "hover") return true;
44752if (options.autoPlay === false) return false;
44753return canAttemptReaderAutoAudio({
44754settings: this.settings,
44755trigger,
44756userGesture: Boolean(options.userGesture)
44757});
44758}
44759shouldAutoPlayInitialCard(card, context) {
44760return context.options.autoPlay !== false && this.isCurrentCardForAutoPlay(context) && this.shouldAutoPlay(card, context.trigger, Boolean(context.options.userGesture), context.anchor, context.hoverLookupGeneration);
44761}
44762isCurrentCardForAutoPlay(context) {
44763return context.trigger === "modal" || context.isCurrentHoverCard();
44764}
44765maybeParseInstantLocalEntries(popover, instantLocalEntries) {
44766if (instantLocalEntries?.length) void this.parsePopoverJapanese(popover);
44767}
44768installLazyImmersionExamples(popover, card, options = {}) {
44769if (!immersionKitCapabilitiesFor(targetLanguageOf(this.settings)).supported) {
44770installTargetExampleSources(popover, {
44771settings: this.settings,
44772term: card.spelling,
44773sourceAttributes: (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
44774isCurrentRoot: (root) => this.isCurrentPopoverRoot(root)
44775});
44776return;
44777}
44778if (!this.settings.immersionKitEnabled) return;
44779this.immersionPopover?.installLazyLoad(popover, card, options);
44780}
44781renderDeferredCardLocalEntries(popover, card, sentence, trigger, renderData, fallbackAnkiLookup, mounted, renderState, isCurrentHoverCard, anchor) {
44782if (mounted.instantLocalEntries !== null) return;
44783let localEntriesValue = null;
44784let metaEntriesValue = [];
44785let jpdbVocabularyInfoValue = null;
44786let jitenVocabularyInfoValue = null;
44787let frequencyRanksValue = {};
44788let ankiLookupValue;
44789let renderedPitchKey = card.pitchAccent.join("|");
44790let loadingRenderFrame;
44791const isCurrentRender = () => this.isCurrentCardRender(popover, mounted.requestId, isCurrentHoverCard);
44792const canRenderLoading = () => !renderState.fullRenderCompleted && isCurrentRender();
44793const runLoadingRender = () => {
44794loadingRenderFrame = void 0;
44795if (!canRenderLoading()) return;
44796renderedPitchKey = card.pitchAccent.join("|");
44797const preservedImmersion = this.preserveImmersionMountForRerender(popover);
44798const scrollOffset = capturePopoverScrollOffset(popover);
44799clearNestedParseState(popover);
44800setInnerHtml(popover, this.cardPopoverRenderer.render(
44801card,
44802sentence,
44803trigger,
44804loadingCardRenderData(
44805localEntriesValue ?? [],
44806ankiLookupValue ?? this.lastAnkiLookup ?? fallbackAnkiLookup,
44807metaEntriesValue,
44808jpdbVocabularyInfoValue,
44809jitenVocabularyInfoValue,
44810null,
44811frequencyRanksValue
44812)
44813));
44814this.restorePreservedImmersionMount(popover, preservedImmersion);
44815restorePopoverScrollOffsetSoon(scrollOffset);
44816refreshForcedReaderPopoverSurface(popover, this.settings);
44817this.updateCardPopoverPosition(trigger);
44818this.installDeferredCardPostRenderBehaviors(popover, card, sentence, trigger);
44819};
44820const renderLoading = () => {
44821if (!canRenderLoading()) return;
44822if (loadingRenderFrame !== void 0) return;
44823loadingRenderFrame = window.requestAnimationFrame(runLoadingRender);
44824};
44825void renderData.localEntries.then((localEntries) => {
44826localEntriesValue = localEntries;
44827renderLoading();
44828});
44829if (renderData.localMetaEntries) {
44830void renderData.localMetaEntries.then((metaEntries) => {
44831metaEntriesValue = metaEntries;
44832if (!canRenderLoading()) return;
44833this.updateDeferredCardHeader(popover, card, metaEntriesValue, trigger, anchor, ankiLookupValue, frequencyRanksValue);
44834});
44835}
44836if (renderData.jpdbVocabularyInfo) {
44837void renderData.jpdbVocabularyInfo.then((jpdbVocabularyInfo) => {
44838jpdbVocabularyInfoValue = jpdbVocabularyInfo;
44839renderLoading();
44840});
44841}
44842if (renderData.jitenVocabularyInfo) {
44843void renderData.jitenVocabularyInfo.then((jitenVocabularyInfo) => {
44844jitenVocabularyInfoValue = jitenVocabularyInfo;
44845renderLoading();
44846});
44847}
44848if (renderData.frequencyRanks) {
44849void renderData.frequencyRanks.then((frequencyRanks) => {
44850frequencyRanksValue = frequencyRanks;
44851if (canRenderLoading()) this.updateDeferredCardHeader(popover, card, metaEntriesValue, trigger, anchor, ankiLookupValue, frequencyRanksValue);
44852});
44853}
44854if (renderData.ankiLookup) {
44855void renderData.ankiLookup.then((ankiLookup) => {
44856if (!isCurrentRender()) return;
44857ankiLookupValue = ankiLookup;
44858this.lastAnkiLookup = ankiLookup;
44859this.applyAnkiLookupToRenderedWords(card, ankiLookup, {
44860preserveExistingEmpty: trigger === "hover",
44861roots: this.renderedWordUpdateRootsForCardRender(trigger, anchor)
44862});
44863renderLoading();
44864});
44865}
44866if (!renderData.pitchAccent) return;
44867void renderData.pitchAccent.then((pitchAccent) => {
44868if (!pitchAccent.length || !isCurrentRender()) return;
44869if (!card.pitchAccent.length) card.pitchAccent = pitchAccent;
44870if (renderedPitchKey === card.pitchAccent.join("|")) return;
44871renderedPitchKey = card.pitchAccent.join("|");
44872this.updateDeferredCardHeader(popover, card, metaEntriesValue, trigger, anchor, ankiLookupValue, frequencyRanksValue);
44873});
44874}
44875installDeferredCardPostRenderBehaviors(popover, card, sentence, trigger) {
44876this.installCardPostRenderBehaviors(popover, card, sentence, trigger, {});
44877}
44878updateDeferredCardHeader(popover, card, metaEntries, trigger = "modal", anchor, ankiLookup, frequencyRanks) {
44879this.applyPitchAccentToRenderedWords(card, void 0, this.renderedWordUpdateRootsForCardRender(trigger, anchor));
44880this.updatePopoverWordPills(popover, card, metaEntries, ankiLookup, frequencyRanks);
44881this.updatePopoverPitch(popover, card, metaEntries);
44882this.updateCardPopoverPosition(trigger);
44883}
44884updatePopoverWordPills(popover, card, metaEntries, ankiLookup, frequencyRanks) {
44885updateHeadingWordPills(popover, {
44886card,
44887jpdbUrl: jpdbVocabularyUrl(card),
44888settings: this.settings,
44889metaEntries,
44890ankiLookup,
44891frequencyRanks,
44892isJpdbBackedCard: (value) => this.isJpdbBackedCard(value),
44893dictionaryLabel: (name) => this.dictionaryLabel(name)
44894});
44895}
44896updatePopoverPitch(popover, card, metaEntries) {
44897updateRenderedPitch(popover, card, metaEntries, this.settings, (name) => this.dictionaryLabel(name));
44898}
44899updateCardPopoverPosition(trigger) {
44900if (trigger === "hover") {
44901this.scheduleRepositionActivePopoverFrame();
44902return;
44903}
44904this.repositionActivePopover();
44905}
44906renderCompletedCardPopover(popover, card, sentence, trigger, data, anchor) {
44907this.lastAnkiLookup = data.ankiLookup;
44908this.applyCardEnrichmentToRenderedAnchor(card, anchor);
44909const renderedRoots = this.renderedWordUpdateRootsForCardRender(trigger, anchor);
44910this.applyAnkiLookupToRenderedWords(card, data.ankiLookup, {
44911preserveExistingEmpty: trigger === "hover",
44912roots: renderedRoots
44913});
44914this.applyPitchAccentToRenderedWords(card, void 0, renderedRoots);
44915const preservedImmersion = this.preserveImmersionMountForRerender(popover);
44916const scrollOffset = capturePopoverScrollOffset(popover);
44917clearNestedParseState(popover);
44918setInnerHtml(popover, this.cardPopoverRenderer.render(card, sentence, trigger, { ...data, loading: false }));
44919this.wanikaniSources.installDefinitionMounts(popover, card);
44920this.restorePreservedImmersionMount(popover, preservedImmersion);
44921restorePopoverScrollOffsetSoon(scrollOffset);
44922refreshForcedReaderPopoverSurface(popover, this.settings);
44923this.updateCardPopoverPosition(trigger);
44924this.installCardPostRenderBehaviors(popover, card, sentence, trigger, {
44925relatedQueries: this.immersionRelatedQueries(data.jpdbVocabularyInfo)
44926});
44927}
44928applyCardEnrichmentToRenderedAnchor(card, anchor) {
44929const word = anchor?.closest(".jpdb-reader-word");
44930if (!word?.isConnected) return;
44931const surface = normalizedLookupText(word.dataset.expression || readerWordSurfaceText(word));
44932if (!surface || !cardMatchesRenderedLookupValue(card, surface)) return;
44933const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown";
44934this.pauseAutoScanObserver(() => {
44935this.applyPublicVocabularyToRenderedWord(word, card, pitchClass);
44936refreshReaderWordContrast(word.parentElement ?? word);
44937});
44938}
44939installCardPostRenderBehaviors(popover, card, sentence, trigger, immersionOptions) {
44940const install = () => {
44941if (!this.isActivePopoverRender(popover)) return;
44942void this.parsePopoverJapanese(popover);
44943this.studySources.installLoaders(popover, sentence);
44944this.installLazyImmersionExamples(popover, card, immersionOptions);
44945};
44946if (trigger === "hover") {
44947window.requestAnimationFrame(install);
44948return;
44949}
44950install();
44951}
44952isActivePopoverRender(popover) {
44953return !this.isDestroyed && popover.isConnected && this.activePopover === popover;
44954}
44955renderHydratedCardAnkiLookup(context, renderData) {
44956const { popover, card, trigger, state, requestId, isCurrentHoverCard } = context;
44957if (!this.shouldRunAnkiBackgroundWork()) return;
44958const hydrateAnkiLookup = renderData.hydrateAnkiLookup;
44959if (!hydrateAnkiLookup) return;
44960const hydrate = () => {
44961if (!this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44962void hydrateAnkiLookup().then((ankiLookup) => {
44963const current = state.data;
44964const resolvesPendingMiss = current.ankiLookup.trusted === false && ankiLookup.trusted !== false;
44965if (!ankiLookupHasDisplayableNotes(ankiLookup) && !ankiLookupHasDisplayableNotes(current.ankiLookup) && !resolvesPendingMiss) return;
44966if (!this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44967state.data = { ...current, ankiLookup };
44968this.scheduleHydratedCardPopoverRender(context);
44969}).catch((error) => {
44970log.warn("Popup Anki detail failed", { term: card.spelling }, error);
44971if (!this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44972const ankiLookup = ankiLookupWithUnavailableDetails(state.data.ankiLookup);
44973if (!ankiLookup.primary) return;
44974state.data = { ...state.data, ankiLookup };
44975this.scheduleHydratedCardPopoverRender(context);
44976});
44977};
44978if (trigger === "hover") {
44979window.setTimeout(hydrate, HOVER_ANKI_HYDRATION_DELAY_MS);
44980return;
44981}
44982hydrate();
44983}
44984renderHydratedCardLocalEntries(context, renderData) {
44985const { popover, card, state, requestId, isCurrentHoverCard } = context;
44986if (state.data.localEntries.length || !renderData.hydrateLocalEntries) return;
44987void renderData.hydrateLocalEntries().then((entries2) => {
44988if (!entries2.length || state.data.localEntries.length || !this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44989state.data = { ...state.data, localEntries: entries2 };
44990this.scheduleHydratedCardPopoverRender(context);
44991}).catch((error) => log.debug("Popup local dictionary hydration failed", { term: card.spelling, error }));
44992}
44993renderHydratedCardJitenVocabulary(context, renderData) {
44994const { popover, card, state, requestId, isCurrentHoverCard } = context;
44995if (!usesJapaneseProviders() || state.data.jitenVocabularyInfo || !renderData.hydrateJitenVocabularyInfo) return;
44996void renderData.hydrateJitenVocabularyInfo().then((info) => {
44997if (!usesJapaneseProviders() || !info || state.data.jitenVocabularyInfo || !this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
44998state.data = { ...state.data, jitenVocabularyInfo: info };
44999this.scheduleHydratedCardPopoverRender(context);
45000}).catch((error) => log.debug("Popup Jiten vocabulary hydration failed", { term: card.spelling, error }));
45001}
45002renderHydratedCardPitchAccent(context, renderData) {
45003const { popover, card, requestId, isCurrentHoverCard } = context;
45004if (!renderData.hydratePitchAccent) return;
45005const renderedPitchKey = card.pitchAccent.join("|");
45006void renderData.hydratePitchAccent().then((pitchAccent) => {
45007if (!this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
45008if (!card.pitchAccent.length && pitchAccent.length) card.pitchAccent = [...pitchAccent];
45009if (renderedPitchKey === card.pitchAccent.join("|")) return;
45010this.scheduleHydratedCardPopoverRender(context);
45011}).catch((error) => log.debug("Popup pitch hydration failed", { term: card.spelling, error }));
45012}
45013renderHydratedCardFrequencyRanks(context, renderData) {
45014const { popover, card, state, requestId, isCurrentHoverCard } = context;
45015if (!usesJapaneseProviders() || !renderData.hydrateFrequencyRanks) return;
45016void renderData.hydrateFrequencyRanks().then((frequencyRanks) => {
45017if (!usesJapaneseProviders() || !this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
45018if (JSON.stringify(state.data.frequencyRanks ?? {}) === JSON.stringify(frequencyRanks)) return;
45019state.data = { ...state.data, frequencyRanks };
45020this.scheduleHydratedCardPopoverRender(context);
45021}).catch((error) => log.debug("Popup provider frequency hydration failed", { term: card.spelling, error }));
45022}
45023renderHydratedCardBunproDefinition(context, renderData) {
45024const { popover, card, state, requestId, isCurrentHoverCard } = context;
45025if (!usesJapaneseProviders() || !renderData.hydrateBunproDefinitionResult) return;
45026void renderData.hydrateBunproDefinitionResult().then((result) => {
45027if (!usesJapaneseProviders() || !this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
45028const unchangedInfo = state.data.bunproDefinitionInfo === result.info;
45029const unchangedStatus = JSON.stringify(state.data.bunproDefinitionStatus) === JSON.stringify(result.status);
45030if (unchangedInfo && unchangedStatus) return;
45031state.data = {
45032...state.data,
45033bunproDefinitionInfo: result.info,
45034bunproDefinitionStatus: result.status
45035};
45036this.scheduleHydratedCardPopoverRender(context);
45037}).catch((error) => log.debug("Popup Bunpro definition hydration failed", { term: card.spelling, error }));
45038}
45039/** Merge same-frame provider completions so independent microtasks do not
45040* repeat layout reads and large dictionary-tree replacements before input. */
45041scheduleHydratedCardPopoverRender(context) {
45042let pass = this.cardHydrationRenderPasses.get(context.state);
45043if (!pass) {
45044pass = createPostPaintPass(() => {
45045const { popover, card, sentence, trigger, state, requestId, isCurrentHoverCard, anchor } = context;
45046if (!this.isCurrentCardRender(popover, requestId, isCurrentHoverCard)) return;
45047this.renderCompletedCardPopover(popover, card, sentence, trigger, state.data, anchor);
45048});
45049this.cardHydrationRenderPasses.set(context.state, pass);
45050}
45051pass.schedule(viewForNode(context.popover));
45052}
45053renderedWordUpdateRootsForCardRender(trigger, anchor) {
45054if (trigger !== "hover") return [document];
45055const word = anchor?.closest(".jpdb-reader-word");
45056if (word?.isConnected) return [word];
45057if (anchor?.isConnected) return [anchor];
45058return [document];
45059}
45060preserveImmersionMountForRerender(popover) {
45061const mount = popover.querySelector("[data-immersion-kit]");
45062return mount && this.shouldPreserveImmersionMount(mount) ? mount : null;
45063}
45064shouldPreserveImmersionMount(mount) {
45065return Boolean(
45066mount.dataset.immersionLoadState || mount.dataset.immersionEmpty === "true" || mount.querySelector(".jpdb-reader-example-card") || mount instanceof HTMLDetailsElement && mount.open
45067);
45068}
45069restorePreservedImmersionMount(popover, preserved) {
45070if (!preserved) return;
45071const next = popover.querySelector("[data-immersion-kit]");
45072if (!next || next === preserved) return;
45073next.replaceWith(preserved);
45074}
45075isCurrentCardRender(popover, _requestId, isCurrentHoverCard) {
45076return isCurrentHoverCard() && popover.isConnected && this.activePopover === popover;
45077}
45078immersionRelatedQueries(info) {
45079if (!info) return [];
45080return info.compounds.flatMap((compound) =>
45080 [compound.term, compound.reading]).filter(Boolean);
45081}
45082installCardPopoverHandlers(popover, card, sentence, anchor, trigger) {
45083installMiningDrawerHandle(popover, (button, expanded) => this.setMiningControlsExpanded(button, expanded));
45084this.installReaderControlPointerActivation(popover);
45085popover.addEventListener("click", trustedReaderEventHandler((event) => this.handleCardPopoverClick(event, card, sentence, anchor, trigger)));
45086popover.addEventListener("keydown", trustedReaderEventHandler((event) => this.handleCardPopoverLookupKeydown(event)));
45087popover.addEventListener("change", trustedReaderEventHandler((event) => this.handlePopoverReviewTargetChange(event, popover)));
45088}
45089handleCardPopoverLookupKeydown(event) {
45090if (event.key !== "Enter" && event.key !== " ") return;
45091const link = dictionaryLookupLink(event.target);
45092if (!link) return;
45093event.preventDefault();
45094event.stopPropagation();
45095dispatchAuthorizedReaderControlClick(link);
45096}
45097handlePopoverReviewTargetChange(event, popover) {
45098const select = event.target?.closest("[data-review-target-select]");
45099if (select && popover.contains(select)) updatePopoverReviewTargetSelection(select);
45100}
45101handleCardPopoverClick(event, card, sentence, anchor, trigger) {
45102if (handleReaderActionPillLink(event)) return;
45103if (this.handleDictionaryLookupLink(event, anchor, trigger)) return;
45104const target = event.target;
45105const kanjiButton = target.closest('[data-action="kanji"]');
45106if (kanjiButton) {
45107this.handleCardPopoverKanjiAction(event, kanjiButton, card, sentence, anchor);
45108return;
45109}
45110const button = target.closest("[data-action]");
45111if (!button) return;
45112event.preventDefault();
45113event.stopPropagation();
45114this.dispatchCardPopoverAction(button, card, sentence, anchor, trigger);
45115}
45116handleCardPopoverKanjiAction(event, button, card, sentence, anchor) {
45117const command = readKanjiCommandCapability(button);
45118if (!command) return;
45119event.preventDefault();
45120event.stopPropagation();
45121void this.showKanjiCard(card, command.kanji, sentence, anchor, { preservePosition: true });
45122}
45123dispatchCardPopoverAction(button, card, sentence, anchor, trigger) {
45124if (this.handleCardPopoverNavigationAction(button, anchor, trigger)) return;
45125if (this.handleCardPopoverMiningAction(button)) return;
45126if (this.handleCardPopoverDeckPickerAction(button, card, sentence)) return;
45127this.performCardPopoverCommand(button, card, sentence);
45128}
45129performCardPopoverCommand(button, card, sentence) {
45130const command = readCardCommandCapability(button);
45131if (command) void this.handleCardAction(button, card, sentence, command);
45132}
45133handleCardPopoverNavigationAction(button, anchor, trigger) {
45134if (button.dataset.action !== "word-history-back") return false;
45135void this.showPreviousWord(anchor, trigger);
45136return true;
45137}
45138handleCardPopoverMiningAction(button) {
45139const command = readCardUiCommandCapability(button);
45140if (!command) return false;
45141return this.performCardPopoverUiCommand(button, command.action);
45142}
45143performCardPopoverUiCommand(button, action) {
45144if (action === "review-target-toggle") {
45145togglePopoverReviewTargetSelection(button);
45146return true;
45147}
45148if (action !== "mining-collapse") return false;
45149this.toggleMiningControls(button);
45150return true;
45151}
45152handleCardPopoverDeckPickerAction(button, card, sentence) {
45153if (cardPopoverDeckPickerCommand(button)) return this.openDeckPickerForAdd(button, card, sentence);
45154if (!cardPopoverAddCommand(button)) return false;
45155return this.openDeckPickerForAdd(button, card, sentence);
45156}
45157toggleMiningControls(button) {
45158toggleMiningControls(button, (expanded) => this.miningControlsToggleLabel(expanded));
45159}
45160setMiningControlsExpanded(button, expanded) {
45161setMiningControlsExpanded(button, expanded, (value) => this.miningControlsToggleLabel(value));
45162}
45163miningControlsToggleLabel(expanded) {
45164return uiText$1(this.settings.interfaceLanguage, expanded ? "hideMiningActions" : "showMiningActions");
45165}
45166openDeckPickerForAdd(button, card, sentence) {
45167return openDeckPickerForCardAdd(button, card, sentence, (actionButton, actionCard, actionSentence, command) => this.handleCardAction(actionButton, actionCard, actionSentence, command));
45168}
45169async showPreviousWord(anchor, trigger = "modal") {
45170const previous = this.navigation.popPreviousWord();
45171if (!previous) return;
45172await this.showPreviousNavigationEntry(previous, anchor, trigger);
45173}
45174async showPreviousNavigationEntry(previous, anchor, trigger) {
45175if (previous.kind === "kanji") {
45176await this.showKanjiCard(previous.card, previous.kanji, previous.sentence, anchor, {
45177navigation: "preserve",
45178preservePosition: true
45179});
45180return;
45181}
45182await this.showCard(previous.card, previous.sentence, anchor, {
45183autoPlay: false,
45184trigger,
45185navigation: "preserve",
45186preservePosition: true
45187});
45188}
45189async showPreviousKanji(anchor) {
45190const previous = this.navigation.popPreviousKanji();
45191if (!previous) return;
45192await this.showKanjiCard(previous.card, previous.kanji, previous.sentence, anchor, {
45193navigation: "preserve",
45194preservePosition: true
45195});
45196}
45197shouldAutoPlay(card, trigger, userGesture = false, anchor, hoverLookupGeneration) {
45198if (!canAttemptReaderAutoAudio({
45199settings: this.settings,
45200trigger,
45201userGesture
45202})) return false;
45203const key = `${card.vid}:${card.sid}`;
45204const now = Date.now();
45205if (!userGesture) {
45206const sameHoverGeneration = trigger === "hover" && hoverLookupGeneration !== void 0 && this.lastAutoAudioHoverGeneration !== void 0 && hoverLookupGeneration === this.lastAutoAudioHoverGeneration;
45207const shouldSuppressDuplicate = key === this.lastAutoAudioKey && now - this.lastAutoAudioAt < 2500 && (trigger !== "hover" || hoverLookupGeneration === void 0 || this.lastAutoAudioHoverGeneration === void 0 || sameHoverGeneration);
45208if (shouldSuppressDuplicate) return false;
45209this.lastAutoAudioKey = key;
45210this.lastAutoAudioAt = now;
45211this.lastAutoAudioHoverGeneration = trigger === "hover" ? hoverLookupGeneration : void 0;
45212}
45213return true;
45214}
45215async showKanjiCard(card, kanji, sentence, anchor, options = {}) {
45216if (!usesJapaneseCharacterStudy() || !targetCanLookupCharacter(kanji)) return;
45217const navigation = options.navigation ?? "reset";
45218this.navigation.updateKanji(card, kanji, sentence, navigation);
45219const popover = this.createPopover();
45220const language = this.settings.interfaceLanguage;
45221const kanjiCharacters = uniqueKanji(card.spelling);
45222const jpdbUrl = `https://jpdb.io/kanji/${encodeURIComponent(kanji)}`;
45223const detailsPromises = this.kanjiDetailPromises(kanji);
45224this.renderKanjiCardShell(popover, card, kanji, kanjiCharacters, jpdbUrl, language);
45225this.installKanjiCardActions(popover, card, kanji, sentence, anchor);
45226this.mountPopover(popover, anchor, { preservePosition: options.preservePosition });
45227this.startKanjiProgressiveRender(popover, detailsPromises, card, kanji, language);
45228}
45229kanjiDetailPromises(kanji) {
45230const needsKanjiVG = this.settings.kanjivgEnabled || this.settings.kanjiOriginsEnabled && this.settings.kanjiOriginGraphEnabled;
45231return {
45232jpdbInfo: this.jpdbKanjiDetailPromise(kanji),
45233jitenInfo: this.jitenKanjiDetailPromise(kanji),
45234kanjiEntries: this.localKanjiEntriesPromise(kanji),
45235rtkInfo: this.rtkDetailPromise(kanji),
45236kanjiVGInfo: needsKanjiVG && this.kanjiVG ? this.kanjiVG.lookup(kanji).catch(() => null) : Promise.resolve(null)
45237};
45238}
45239jpdbKanjiDetailPromise(kanji) {
45240return this.settings.jpdbKanjiEnabled && this.jpdbKanji ? this.jpdbKanji.lookup(kanji).catch(() => null) : Promise.resolve(null);
45241}
45242jitenKanjiDetailPromise(kanji) {
45243return this.settings.jpdbKanjiEnabled ? this.jiten.lookupKanji(kanji).catch(() => null) : Promise.resolve(null);
45244}
45245isJitenApiActive() {
45246return hasJitenApiCredential(this.settings);
45247}
45248localKanjiEntriesPromise(kanji) {
45249return this.settings.localDictionariesEnabled && this.settings.localDictionaryShowKanji ? this.dictionaries.lookupKanji(kanji, this.settings.localDictionaryMaxResults, this.settings.dictionaryPreferences).catch(() => []) : Promise.resolve([]);
45250}
45251rtkDetailPromise(kanji) {
45252return this.settings.rtkEnabled && this.rtk ? this.rtk.lookup(kanji).catch(() => null) : Promise.resolve(null);
45253}
45254renderKanjiCardShell(popover, card, kanji, kanjiCharacters, jpdbUrl, language) {
45255setInnerHtml(popover, `
45256<div class="jpdb-reader-sheet-handle"></div>
45257<div class="jpdb-reader-popover-body">
45258${renderModalNavigation({
45259...this.navigation.kanjiModalBack(card, language),
45260controlsHtml: this.renderKanjiNavigationControls(kanjiCharacters, kanji, language)
45261})}
45262<div class="jpdb-reader-header">
45263<div class="jpdb-reader-heading">
45264<div class="jpdb-reader-title-row jpdb-reader-kanji-title-row">
45265<div class="jpdb-reader-kanji-display">${escapeHtml(kanji)}</div>
45266<div data-kanji-keyword-mount><div class="jpdb-reader-help">${escapeHtml(uiText$1(language, "loadingKanjiDetails"))}</div></div>
45267${renderWordPills({
45268card,
45269jpdbUrl,
45270settings: this.settings,
45271metaEntries: [],
45272overrideQuery: kanji,
45273isJpdbBackedCard: (value) => this.isJpdbBackedCard(value),
45274dictionaryLabel: (name) => this.dictionaryLabel(name)
45275})}
45276</div>
45277</div>
45278</div>
45279<div class="jpdb-reader-definition-stack jpdb-reader-kanji-section-stack">
45280${this.renderKanjiSourceMounts(kanji, language)}
45281</div>
45282</div>
45283${this.renderKanjiActionBar(card)}
45284`);
45285}
45286renderKanjiNavigationControls(kanjiCharacters, kanji, language) {
45287if (kanjiCharacters.length <= 1) return "";
45288const index = Math.max(0, kanjiCharacters.indexOf(kanji));
45289const previous = kanjiCharacters[(index - 1 + kanjiCharacters.length) % kanjiCharacters.length];
45290const next = kanjiCharacters[(index + 1) % kanjiCharacters.length];
45291return `
45292<button class="jpdb-reader-icon-mini" type="button" data-action="kanji-prev" data-kanji="${escapeHtml(previous)}"${privateCommandAttributes({ kind: "kanji-lookup", kanji: previous })} title="${escapeHtml(uiText$1(language, "previousKanji"))}">‹</button>
45293<button class="jpdb-reader-icon-mini" type="button" data-action="kanji-next" data-kanji="${escapeHtml(next)}"${privateCommandAttributes({ kind: "kanji-lookup", kanji: next })} title="${escapeHtml(uiText$1(language, "nextKanji"))}">›</button>
45294`;
45295}
45296installKanjiCardActions(popover, card, kanji, sentence, anchor) {
45297installMiningDrawerHandle(popover, (button, expanded) => this.setMiningControlsExpanded(button, expanded));
45298this.installReaderControlPointerActivation(popover);
45299popover.addEventListener("click", trustedReaderEventHandler((event) => this.handleKanjiCardActionClick(event, card, kanji, sentence, anchor)));
45300popover.addEventListener("change", trustedReaderEventHandler((event) => this.handlePopoverReviewTargetChange(event, popover)));
45301}
45302handleKanjiCardActionClick(event, card, kanji, sentence, anchor) {
45303if (handleReaderActionPillLink(event)) return;
45304if (this.handleDictionaryLookupLink(event, anchor, "modal")) return;
45305const actionButton = event.target.closest("[data-action]");
45306if (!actionButton?.dataset.action) return;
45307event.preventDefault();
45308event.stopPropagation();
45309this.dispatchKanjiCardAction(actionButton, card, kanji, sentence, anchor);
45310}
45311dispatchKanjiCardAction(actionButton, card, kanji, sentence, anchor) {
45312if (dispatchPrivateCommand(actionButton, {
45313"jpdb-kanji-action": (command) => {
45314void this.performJpdbKanjiAction(command.actionId, card, kanji, sentence, anchor);
45315},
45316"kanji-lookup": (command) => {
45317void this.showKanjiCard(card, command.kanji, sentence, anchor, { navigation: "push-current", preservePosition: true });
45318},
45319"card-ui": () => this.toggleMiningControls(actionButton),
45320"card-action": (command) => {
45321if (command.action === "copy-word") void copyText(kanji).then(() => this.toast(uiText$1(this.settings.interfaceLanguage, "copiedWord")));
45322else void this.handleCardAction(actionButton, card, sentence, command);
45323},
45324"kanji-word": (command) => {
45325void this.lookupText(command.expression, command.reading || command.expression, { navigation: "push-current", preservePosition: true });
45326},
45327"jiten-kanji-words": (command) => {
45328void (command.action === "more" ? this.loadMoreJitenKanjiWords(actionButton) : this.filterJitenKanjiWords(actionButton));
45329}
45330})) return;
45331const handlers = {
45332"word-back": () => this.showCard(card, sentence, anchor, { autoPlay: false, navigation: "preserve", preservePosition: true }),
45333"kanji-history-back": () => this.showPreviousKanji(anchor)
45334};
45335void handlers[actionButton.dataset.action ?? ""]?.();
45336}
45337jitenKanjiWordsActionContext() {
45338if (!this.isJitenApiActive()) return null;
45339return {
45340lookupKanjiWords: (character, options) => this.jiten.lookupKanjiWords(character, options),
45341language: () => this.settings.interfaceLanguage,
45342afterRender: () => this.repositionActivePopover(),
45343onError: (details, error) => log.warn("Jiten kanji words lookup failed", details, error)
45344};
45345}
45346async loadMoreJitenKanjiWords(button) {
45347const context = this.jitenKanjiWordsActionContext();
45348if (context) await loadMoreJitenKanjiWords(button, context);
45349}
45350async filterJitenKanjiWords(button) {
45351const context = this.jitenKanjiWordsActionContext();
45352if (context) await filterJitenKanjiWords(button, context);
45353}
45354startKanjiProgressiveRender(popover, detailsPromises, card, kanji, language, pageTarget) {
45355this.installKanjiImmersionExamples(popover, card, pageTarget?.queries ?? []);
45356void this.renderKanjiDetailsInto(popover, detailsPromises, card, kanji, language);
45357if (this.settings.kanjivgEnabled) {
45358void this.renderKanjiVGInto(popover, detailsPromises.kanjiVGInfo, kanji, language);
45359}
45360}
45361async performJpdbKanjiAction(actionId, card, kanji, sentence, anchor) {
45362if (!actionId || !this.jpdbKanji || !usesJapaneseCharacterStudy()) return;
45363try {
45364await this.jpdbKanji?.performAction(actionId);
45365this.toast(uiText$1(this.settings.interfaceLanguage, "jpdbKanjiUpdated"));
45366await this.showKanjiCard(card, kanji, sentence, anchor, { preservePosition: true });
45367} catch (error) {
45368log.warn("JPDB kanji action failed", { kanji }, error);
45369this.toast(uiText$1(this.settings.interfaceLanguage, "jpdbKanjiUpdateFailedRuntime"));
45370}
45371}
45372renderKanjiSourceMounts(kanji, language) {
45373return renderKanjiSourceMounts({
45374settings: this.settings,
45375kanji,
45376language,
45377isSourceOpen: (key) => this.dictionarySourceState.isOpen(key),
45378sourceAttributes: (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
45379sourceTitle: (sourceId) => this.kanjiSourceTitle(sourceId),
45380renderImmersionMount: () => this.renderKanjiImmersionKitMount()
45381});
45382}
45383renderKanjiImmersionKitMount() {
45384return renderKanjiImmersionKitMount(this.settings, (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded));
45385}
45386renderKanjiActionBar(card) {
45387const reviewButtons = this.renderKanjiReviewButtons(card);
45388return `
45389<div class="jpdb-reader-actions" data-kanji-actions data-kanji-has-review="${reviewButtons ? "true" : "false"}"${reviewButtons ? "" : " hidden"}>
45390<div class="jpdb-reader-actions-gutter" hidden>
45391<button class="jpdb-reader-mining-collapse jpdb-reader-mining-drawer-handle" type="button" data-action="mining-collapse"${privateCommandAttributes({ kind: "card-ui", action: "mining-collapse" })} aria-expanded="false" title="${escapeHtml(uiText$1(this.settings.interfaceLanguage, "showMiningActions"))}" aria-label="${escapeHtml(uiText$1(this.settings.interfaceLanguage, "showMiningActions"))}"></button>
45392</div>
45393<div data-kanji-mining-mount hidden></div>
45394${reviewButtons}
45395</div>
45396`;
45397}
45398renderKanjiReviewButtons(card) {
45399if (!this.settings.enableReviews) return "";
45400const states = normalizeCardStates(card.cardState);
45401const ankiNote = this.currentKanjiAnkiReviewNote(card);
45402const canReviewWithAnki = Boolean(ankiNote?.primaryCardId);
45403const canReviewWithJpdb = this.canReviewKanjiWithJpdb(card, states);
45404if (!canReviewWithAnki && !canReviewWithJpdb) return "";
45405return renderReviewButtons(this.settings, ankiNote);
45406}
45407currentKanjiAnkiReviewNote(card) {
45408if (!this.shouldRunAnkiBackgroundWork()) return null;
45409if (!this.lastCard || cardKey(this.lastCard) !== cardKey(card)) return null;
45410return this.lastAnkiLookup?.primary ?? null;
45411}
45412canReviewKanjiWithJpdb(card, states) {
45413return !hasBlockedJpdbReviewState(states) && this.isJpdbBackedCard(card) && Boolean(hasJpdbApiCredential(this.settings)) && isApiMiningEnabled(this.settings);
45414}
45415updateKanjiMiningControls(popover, controls) {
45416updateKanjiMiningControlsMount(popover, controls, (button, expanded) => this.setMiningControlsExpanded(button, expanded));
45417}
45418async renderKanjiDetailsInto(popover, detailsPromises, card, kanji, language) {
45419let jpdbInfo = null;
45420let jitenInfo = null;
45421let kanjiEntries = [];
45422let rtkInfo = null;
45423let kanjiVGInfo = null;
45424let sourceInfo = null;
45425const practiceDoodle = this.kanjiCompanion?.installKanjiPracticeDoodle?.(popover, () => this.settings.interfaceLanguage, () => kanjiVGInfo) ?? noopKanjiPracticeDoodle();
45426const keywordMount = popover.querySelector("[data-kanji-keyword-mount]");
45427const miningMount = popover.querySelector("[data-kanji-mining-mount]");
45428const jpdbMount = popover.querySelector("[data-kanji-jpdb-mount]");
45429const rtkMount = popover.querySelector("[data-kanji-rtk-mount]");
45430const definitionsMounts = Array.from(popover.querySelectorAll("[data-kanji-definitions-mount]"));
45431this.wanikaniSources.installKanjiMount(popover, kanji);
45432const renderKeyword = () => {
45433if (!popover.isConnected || !keywordMount?.isConnected) return;
45434setInnerHtml(keywordMount, jitenInfo ? renderJitenKanjiKeywordLine(jitenInfo, rtkInfo, kanjiEntries, language, sourceInfo) : this.renderKanjiKeywordLine(jpdbInfo, rtkInfo, kanjiEntries, language, sourceInfo));
45435this.repositionActivePopover();
45436};
45437const renderKanjiPillRanks = () => {
45438if (!popover.isConnected) return;
45439const frequencyRanks = kanjiFrequencyRanks(kanji, jitenInfo?.frequencyRank, jpdbInfo?.frequency);
45440if (!frequencyRanks.jiten && !frequencyRanks.jpdb) return;
45441updateHeadingWordPills(popover, {
45442card,
45443jpdbUrl: `https://jpdb.io/kanji/${encodeURIComponent(kanji)}`,
45444settings: this.settings,
45445metaEntries: [],
45446overrideQuery: kanji,
45447frequencyRanks,
45448isJpdbBackedCard: (value) => this.isJpdbBackedCard(value),
45449dictionaryLabel: (name) => this.dictionaryLabel(name)
45450});
45451};
45452const renderRtk = () => {
45453if (!popover.isConnected || !rtkMount?.isConnected) return;
45454const companion = this.kanjiCompanion;
45455if (!companion) {
45456rtkMount.remove();
45457this.repositionActivePopover();
45458return;
45459}
45460const componentSummaries = companion.buildRtkComponentSummaries(rtkInfo, jpdbInfo, kanjiEntries);
45461const sourceStateKey = kanjiSourceStateKey(KANJI_RTK_SOURCE_ID);
45462setInnerHtml(rtkMount, companion.renderRtkInfo(rtkInfo, componentSummaries, language, this.dictionarySourceState.isOpen(sourceStateKey), sourceStateKey));
45463this.repositionActivePopover();
45464};
45465const jpdbInfoPromise = detailsPromises.jpdbInfo.then((info) => {
45466jpdbInfo = info;
45467if (!popover.isConnected) return;
45468renderKeyword();
45469renderKanjiPillRanks();
45470if (miningMount?.isConnected && this.kanjiCompanion) this.updateKanjiMiningControls(popover, this.kanjiCompanion.renderJpdbKanjiMiningControls(jpdbInfo, language));
45471if (jpdbMount?.isConnected) {
45472setInnerHtml(jpdbMount, this.renderKanjiFactSourcesHtml(jpdbInfo, jitenInfo, language));
45473}
45474renderRtk();
45475});
45476const jitenInfoPromise = detailsPromises.jitenInfo.then((info) => {
45477jitenInfo = info;
45478if (!popover.isConnected) return;
45479renderKeyword();
45480renderKanjiPillRanks();
45481if (jpdbMount?.isConnected) {
45482setInnerHtml(jpdbMount, this.renderKanjiFactSourcesHtml(jpdbInfo, jitenInfo, language));
45483}
45484});
45485const kanjiEntriesPromise = detailsPromises.kanjiEntries.then((entries2) => {
45486kanjiEntries = entries2;
45487if (!popover.isConnected) return;
45488renderKeyword();
45489for (const definitionsMount of definitionsMounts.filter((mount) => mount.isConnected)) {
45490const dictionaryName = definitionsMount.dataset.kanjiDictionary;
45491const sourceId = definitionsMount.dataset.kanjiSourceId ?? KANJI_DICTIONARIES_SOURCE_ID;
45492const visibleEntries = dictionaryName ? kanjiEntries.filter((entry) => entry.dictionary === dictionaryName) : kanjiEntries;
45493setInnerHtml(definitionsMount, renderKanjiDefinitions(
45494visibleEntries,
45495(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
45496(name) => this.dictionaryLabel(name),
45497sourceId,
45498dictionaryName ? this.dictionaryLabel(dictionaryName) : this.kanjiSourceTitle(KANJI_DICTIONARIES_SOURCE_ID),
45499language
45500));
45501}
45502renderRtk();
45503});
45504const rtkInfoPromise = detailsPromises.rtkInfo.then((info) => {
45505rtkInfo = info;
45506if (!popover.isConnected) return;
45507renderKeyword();
45508renderRtk();
45509});
45510const kanjiVGInfoPromise = detailsPromises.kanjiVGInfo.then((info) => {
45511kanjiVGInfo = info;
45512if (!popover.isConnected) return;
45513practiceDoodle.reassess();
45514});
45515await Promise.all([jpdbInfoPromise, jitenInfoPromise, kanjiEntriesPromise, rtkInfoPromise, kanjiVGInfoPromise]);
45516sourceInfo = await this.kanjiOrigin?.lookup(kanji, this.settings).catch(() => null) ?? null;
45517renderKeyword();
45518if (!popover.isConnected) return;
45519if (this.settings.kanjiOriginsEnabled) {
45520this.renderKanjiOriginsInto(popover, kanji, jpdbInfo, jitenInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo);
45521}
45522void (this.isJpdbPageAddonRoot(popover) ? this.parseJpdbPageAddonJapanese(popover) : this.parsePopoverJapanese(popover));
45523this.repositionActivePopover();
45524}
45525renderKanjiKeywordLine(jpdbInfo, rtkInfo, entries2, language, sourceInfo) {
45526return this.kanjiCompanion?.renderKanjiKeywordLine(jpdbInfo, rtkInfo, entries2, language, sourceInfo) ?? `<div class="jpdb-reader-help">${escapeHtml(uiText$1(language, "kanjiDetailsUnavailable"))}</div>`;
45527}
45528waitForIdle(timeoutMs = 75) {
45529return waitForIdle(timeoutMs);
45530}
45531async renderKanjiVGInto(popover, kanjiVGPromise, kanji, language) {
45532const info = await kanjiVGPromise;
45533if (!info || !popover.isConnected) return;
45534const elements = this.kanjiVGStageElements(popover, kanji);
45535if (!elements) return;
45536const { stage, ghost, help } = elements;
45537setInnerHtml(ghost, info.svg);
45538help.textContent = `${info.strokeCount} ${uiText$1(language, "strokes")}`;
45539const trace = stage.closest(".jpdb-reader-kanjivg")?.querySelector("[data-doodle-trace]");
45540const traceVisible = !stage.classList.contains("trace-hidden");
45541ghost.hidden = !traceVisible;
45542if (trace) trace.textContent = uiText$1(language, traceVisible ? "hideTrace" : "showTrace");
45543}
45544kanjiVGStageElements(popover, kanji) {
45545const stage = Array.from(popover.querySelectorAll(".jpdb-reader-doodle-stage")).find((candidate) => candidate.dataset.kanji === kanji);
45546if (!stage) return null;
45547const ghost = stage.querySelector(".jpdb-reader-doodle-ghost");
45548if (!ghost) return null;
45549const help = stage.closest(".jpdb-reader-kanjivg")?.querySelector(".jpdb-reader-help");
45550if (!help) return null;
45551return { stage, ghost, help };
45552}
45553renderKanjiOriginsInto(popover, kanji, jpdbInfo, jitenInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo) {
45554const mount = popover.querySelector("[data-kanji-origin-mount]");
45555if (!mount) return;
45556if (!this.canRenderKanjiOriginMount(popover, mount)) return;
45557this.renderKanjiOriginMount(mount, kanji, jpdbInfo, jitenInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo);
45558this.installKanjiOriginImageFallbacks(mount);
45559}
45560canRenderKanjiOriginMount(popover, mount) {
45561return popover.isConnected && mount.isConnected;
45562}
45563renderKanjiOriginMount(mount, kanji, jpdbInfo, jitenInfo, rtkInfo, kanjiVGInfo, kanjiEntries, sourceInfo) {
45564const companion = this.kanjiCompanion;
45565if (!companion) {
45566mount.remove();
45567this.repositionActivePopover();
45568return;
45569}
45570const facts = companion.buildKanjiFacts(kanji, jpdbInfo, rtkInfo, this.settings.kanjivgEnabled ? kanjiVGInfo : null, kanjiEntries, sourceInfo);
45571const graph = this.settings.kanjiOriginGraphEnabled ? companion.buildKanjiOriginGraph(kanji, jpdbInfo, rtkInfo, kanjiEntries, sourceInfo, kanjiVGInfo) : null;
45572const sourceStateKey = kanjiSourceStateKey(KANJI_ORIGINS_SOURCE_ID);
45573setInnerHtml(mount, companion.renderKanjiOrigins(
45574facts,
45575graph,
45576sourceInfo,
45577this.settings,
45578this.settings.interfaceLanguage,
45579this.dictionarySourceState.isOpen(sourceStateKey),
45580sourceStateKey,
45581this.hiddenKanjiOriginFactLabels(jpdbInfo, jitenInfo),
45582this.kanjiSourceTitle(KANJI_ORIGINS_SOURCE_ID)
45583));
45584companion.installOriginGraphInteractions(mount);
45585}
45586hiddenKanjiOriginFactLabels(jpdbInfo, jitenInfo) {
45587const labels = new Set(jitenKanjiOriginFactLabels(jitenInfo, this.settings.interfaceLanguage));
45588if (!jitenInfo) {
45589if (jpdbInfo?.type) labels.add("Type");
45590if (jpdbInfo?.frequency) labels.add("Frequency");
45591}
45592return labels.size ? labels : void 0;
45593}
45594installKanjiOriginImageFallbacks(mount) {
45595mount.querySelectorAll("[data-radical-frame]").forEach((image) => {
45596image.addEventListener("error", () => image.remove(), { once: true });
45597const url = image.dataset.radicalFrameUrl;
45598if (!url) return;
45599void this.immersionKit?.fetchBlobUrl(url, 9e3, this.settings.corsProxyUrl, this.settings.interfaceLanguage).then((blobUrl) => {
45600image.src = blobUrl;
45601}).catch(() => image.remove());
45602});
45603}
45604renderDefinitionSources(card, entries2, sentence, jpdbVocabularyInfo = null, jitenVocabularyInfo = null, bunproDefinitionInfo = null, extraSectionsOrOptions = {}) {
45605return renderDefinitionSourcesStack({
45606card,
45607entries: entries2,
45608settings: this.settings,
45609sourceAttributes: (key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded),
45610dictionaryLabel: (name) => this.dictionaryLabel(name),
45611noDefinitionsHtml: () => `<div class="jpdb-reader-help jpdb-reader-no-definitions">${uiText$1(this.settings.interfaceLanguage, "noDefinitions")}</div>`,
45612sentence,
45613jpdbVocabularyInfo,
45614jitenVocabularyInfo,
45615bunproDefinitionInfo,
45616extraSectionsOrOptions,
45617jpdbLanguage: this.settings.interfaceLanguage,
45618renderTranslationSource: (renderSentence) => this.studySources.renderTranslationSource(renderSentence),
45619renderGrammarSource: (renderSentence) => this.studySources.renderGrammarSource(renderSentence),
45620renderImmersionSource: () => renderDefinitionSourceImmersionMount(
45621this.settings,
45622(key, initiallyExpanded) => this.dictionarySourceState.attributes(key, initiallyExpanded)
45623)
45624});
45625}
45626shouldRenderKanjiImmersionKit() {
45627return this.settings.immersionKitEnabled && this.settings.kanjiImmersionKitEnabled;
45628}
45629installKanjiImmersionExamples(root, card, relatedQueries = []) {
45630if (!this.shouldRenderKanjiImmersionKit()) return;
45631this.immersionPopover?.installLazyLoad(root, card, relatedQueries.length ? { relatedQueries } : void 0);
45632}
45633installJpdbPageImmersionExamples(root, card, relatedQueries = []) {
45634if (!this.settings.immersionKitEnabled) return;
45635const controller = this.immersionPopover;
45636if (!controller) return;
45637const options = relatedQueries.length ? { relatedQueries } : void 0;
45638if (root.dataset.yomuPageContext !== "review") {
45639controller.installLazyLoad(root, card, options);
45640return;
45641}
45642const container = root.querySelector("[data-immersion-kit]");
45643if (!container || ["loading", "loaded"].includes(container.dataset.immersionLoadState ?? "")) return;
45644container.dataset.immersionLoadState = "loading";
45645void controller.loadExamples(root, card, options).then(() => {
45646if (container.isConnected && container.dataset.immersionLoadState === "loading") {
45647container.dataset.immersionLoadState = "loaded";
45648}
45649}).catch(() => {
45650if (container.isConnected && container.dataset.immersionLoadState === "loading") {
45651delete container.dataset.immersionLoadState;
45652}
45653});
45654}
45655async parsePopoverJapanese(popover) {
45656if (!this.isCurrentPopoverRoot(popover)) return;
45657void yomuSettingsSurfaceCompanion()?.installDefinitionTranslationBehaviors(popover, this.settings);
45658installProviderExampleBehaviors(popover, {
45659interfaceLanguage: this.settings.interfaceLanguage,
45660outputLanguage: outputLanguageOf(this.settings),
45661blurTranslations: this.settings.immersionKitRevealTranslationOnClick,
45662translate: translateJapaneseSentence,
45663isCurrentRoot: (root) => this.isCurrentPopoverRoot(root)
45664});
45665this.enrichJpdbRelatedWords(popover);
45666const plan = nestedTextParsePlan(popover, 120, { excludeProviderExamples: true });
45667if (plan && !nestedParseAlreadyScheduled(popover, plan.parseKey)) {
45668await this.parseNestedJapaneseContent(popover, plan, () => this.isCurrentPopoverRoot(popover));
45669}
45670if (!this.isCurrentPopoverRoot(popover)) return;
45671const providerPlan = providerExampleTextParsePlan(popover, 24);
45672if (providerPlan && !nestedParseAlreadyScheduled(popover, providerPlan.parseKey)) {
45673await this.parseNestedJapaneseContent(popover, providerPlan, () => this.isCurrentPopoverRoot(popover), {
45674publicJitenDetailLimit: 24
45675}, false);
45676}
45677}
45678async parseJpdbPageAddonJapanese(root) {
45679let state = this.pageAddonParseStates.get(root);
45680if (!state) {
45681state = { dirty: false };
45682this.pageAddonParseStates.set(root, state);
45683}
45684state.dirty = true;
45685if (state.running) return state.running;
45686state.running = this.flushJpdbPageAddonJapaneseParse(root, state).finally(() => {
45687state.running = void 0;
45688if (state.dirty && this.isJpdbPageAddonRoot(root)) {
45689return this.parseJpdbPageAddonJapanese(root);
45690}
45691});
45692return state.running;
45693}
45694async flushJpdbPageAddonJapaneseParse(root, state) {
45695await Promise.resolve();
45696while (state.dirty && this.isJpdbPageAddonRoot(root)) {
45697state.dirty = false;
45698await this.performJpdbPageAddonJapaneseParse(root);
45699}
45700}
45701async performJpdbPageAddonJapaneseParse(root) {
45702if (!this.isJpdbPageAddonRoot(root)) return;
45703this.enrichJpdbRelatedWords(root);
45704clearNestedParseState(root);
45705const plan = nestedTextParsePlan(root, 120);
45706if (!plan || nestedParseAlreadyScheduled(root, plan.parseKey)) return;
45707await this.parseNestedJapaneseContent(root, plan, () => this.isJpdbPageAddonRoot(root));
45708}
45709async parseOnboardingJapanese(panel) {
45710if (!panel.isConnected) return;
45711clearNestedParseState(panel);
45712if (resolveUiLanguage$1(this.settings.interfaceLanguage) !== "ja" || !this.canParseJapanese()) return;
45713const plan = nestedTextParsePlan(panel, 120);
45714if (!plan || nestedParseAlreadyScheduled(panel, plan.parseKey)) return;
45715await this.parseNestedJapaneseContent(panel, plan, () => panel.isConnected, {
45716allowJpdbTimeoutFallback: true,
45717allowSegmentedFallback: true,
45718skipJpdb: true
45719});
45720}
45721enrichJpdbRelatedWords(root) {
45722const related = renderedJpdbRelatedWords(root).filter(({ word }) => word.dataset.jpdbReaderRelatedEnqueued !== "true");
45723if (!related.length) return;
45724related.forEach(({ word }) => {
45725word.dataset.jpdbReaderRelatedEnqueued = "true";
45726});
45727const tokens = related.map(({ token }) => token);
45728void this.enrichPitchWords(tokens, this.nestedPitchEnrichmentOptions());
45729this.queueAnkiWordEnrichment(tokens, [root]);
45730}
45731isJpdbPageAddonRoot(root) {
45732return Boolean(root.isConnected && root.matches("[data-yomu-jpdb-addon]"));
45733}
45734async parseSettingsJapanese(form) {
45735if (!this.isCurrentSettingsRoot(form)) return;
45736const enhancement = yomuSettingsSurfaceCompanion()?.selfEnhancement;
45737if (!enhancement || enhancement.nestedSettingsParseAlreadyRendered(form)) return;
45738if (form.dataset.yomuSettingsSelfEnhancing === "true") {
45739form.dataset.yomuSettingsSelfEnhancePending = "true";
45740return;
45741}
45742form.dataset.yomuSettingsSelfEnhancing = "true";
45743let plan = null;
45744let parseLoadingId = "";
45745try {
45746plan = this.settingsJapaneseParsePlan(form);
45747if (!plan) return;
45748parseLoadingId = `${Date.now()}:${Math.random()}`;
45749form.dataset.jpdbReaderParseLoadingKey = plan.parseKey;
45750form.dataset.jpdbReaderParseLoadingId = parseLoadingId;
45751const parsed = await this.loadSettingsParsedJapaneseContent(plan);
45752if (!this.isCurrentSettingsJapaneseParse(form, plan.parseKey, parseLoadingId)) return;
45753const currentPlan = enhancement.nestedSettingsTextParsePlan(
45754form,
45755enhancement.SETTINGS_PARSE_TARGET_LIMIT
45756);
45757if (!currentPlan) return;
45758const currentParsed = enhancement.supplementSettingsFallbackTokens(
45759currentPlan.targets,
45760enhancement.parsedSettingsTargetsForCurrentPlan(plan, parsed, currentPlan)
45761);
45762this.applySettingsJapaneseParse(form, currentPlan, currentParsed);
45763if (currentPlan.targets.length >= enhancement.SETTINGS_PARSE_TARGET_LIMIT) {
45764window.setTimeout(() => void this.parseSettingsJapanese(form), 0);
45765}
45766} catch {
45767} finally {
45768if (plan) clearNestedParseLoadingKey(form, plan.parseKey, parseLoadingId);
45769delete form.dataset.yomuSettingsSelfEnhancing;
45770if (form.dataset.yomuSettingsSelfEnhancePending === "true") {
45771delete form.dataset.yomuSettingsSelfEnhancePending;
45772void this.parseSettingsJapanese(form);
45773}
45774}
45775}
45776settingsJapaneseParsePlan(form) {
45777if (!this.isCurrentSettingsRoot(form)) return null;
45778if (resolveUiLanguage$1(this.settings.interfaceLanguage) !== "ja" || !this.canParseJapanese()) return null;
45779const enhancement = yomuSettingsSurfaceCompanion()?.selfEnhancement;
45780if (!enhancement) return null;
45781const plan = enhancement.nestedSettingsTextParsePlan(
45782form,
45783enhancement.SETTINGS_PARSE_TARGET_LIMIT
45784);
45785return plan && !nestedParseAlreadyScheduled(form, plan.parseKey) ? plan : null;
45786}
45787loadSettingsParsedJapaneseContent(plan) {
45788return this.loadParsedNestedJapaneseContent(plan.targets.map((target) => target.text), {
45789allowJpdbTimeoutFallback: true,
45790allowSegmentedFallback: true,
45791includeLocalPitch: false,
45792jpdbTimeoutMs: 1200,
45793requireJpdb: false,
45794skipJpdb: true
45795});
45796}
45797isCurrentSettingsJapaneseParse(form, parseKey, parseLoadingId) {
45798return this.isCurrentSettingsRoot(form) && form.dataset.jpdbReaderParseLoadingKey === parseKey && form.dataset.jpdbReaderParseLoadingId === parseLoadingId;
45799}
45800applySettingsJapaneseParse(form, plan, parsed) {
45801const enhancement = yomuSettingsSurfaceCompanion()?.selfEnhancement;
45802if (!enhancement) return;
45803const renderSettings = enhancement.settingsForSettingsFormParse(form, this.settings);
45804applyNestedParsePlan(plan, parsed, renderSettings);
45805enhancement.addSettingsRubyFromRenderedReadings(form, renderSettings);
45806highlightCardTargetScopes(form);
45807refreshReaderWordContrast(form);
45808form.dataset.jpdbReaderParseKey = plan.parseKey;
45809form.dataset.yomuSettingsSelfEnhanced = "true";
45810const tokens = parsed.flat();
45811void this.enrichPitchWords(tokens, this.backgroundPitchEnrichmentOptions());
45812this.queueAnkiWordEnrichment(tokens, [form]);
45813}
45814async parseNestedJapaneseContent(root, plan, isCurrent, options = {}, recordParseKey = true) {
45815const parseLoadingId = `${Date.now()}:${Math.random()}`;
45816root.dataset.jpdbReaderParseLoadingKey = plan.parseKey;
45817root.dataset.jpdbReaderParseLoadingId = parseLoadingId;
45818try {
45819const parsed = await this.loadParsedNestedJapaneseContent(plan.targets.map((target) => target.text), {
45820includeLocalPitch: false,
45821jpdbTimeoutMs: 1200,
45822...options
45823});
45824if (!isCurrent() || root.dataset.jpdbReaderParseLoadingKey !== plan.parseKey || root.dataset.jpdbReaderParseLoadingId !== parseLoadingId) return;
45825applyNestedParsePlan(plan, parsed, this.settings);
45826this.scheduleCachedPublicVocabularyHydration(root);
45827highlightCardTargetScopes(root);
45828refreshReaderWordContrast(root);
45829if (recordParseKey) root.dataset.jpdbReaderParseKey = plan.parseKey;
45830this.afterNestedJapaneseParsed(parsed, root, options.skipJpdb ? { publicLookup: false } : void 0);
45831} catch {
45832} finally {
45833clearNestedParseLoadingKey(root, plan.parseKey, parseLoadingId);
45834}
45835}
45836loadParsedNestedJapaneseContent(texts, options = {}) {
45837const parseOptions = normalizedNestedParseOptions(options, this.settings);
45838const key = parseContentCacheKey(texts, parseOptions, this.settings);
45839const now = Date.now();
45840const cached = this.cachedNestedParseContent(key, now);
45841if (cached) return cached;
45842return this.loadAndCacheNestedParseContent(key, texts, parseOptions, now);
45843}
45844cachedNestedParseContent(key, now) {
45845const cached = this.nestedParseContentCache.get(key);
45846if (!cached) return null;
45847this.nestedParseContentCache.delete(key);
45848if (cached.expiresAt <= now) return null;
45849this.nestedParseContentCache.set(key, cached);
45850return cached.promise;
45851}
45852loadAndCacheNestedParseContent(key, texts, parseOptions, now) {
45853const promise = this.parseJapanese(texts, parseOptions).catch((error) => {
45854if (this.nestedParseContentCache.get(key)?.promise === promise) this.nestedParseContentCache.delete(key);
45855throw error;
45856});
45857this.nestedParseContentCache.set(key, { expiresAt: now + NESTED_PARSE_CONTENT_CACHE_TTL_MS, promise });
45858this.pruneNestedParseContentCache(now);
45859return promise;
45860}
45861pruneNestedParseContentCache(now) {
45862for (const [key, entry] of this.nestedParseContentCache) {
45863if (entry.expiresAt <= now) this.nestedParseContentCache.delete(key);
45864}
45865while (this.nestedParseContentCache.size > NESTED_PARSE_CONTENT_CACHE_LIMIT) {
45866const oldest = this.nestedParseContentCache.keys().next().value;
45867if (typeof oldest !== "string") break;
45868this.nestedParseContentCache.delete(oldest);
45869}
45870}
45871afterNestedJapaneseParsed(parsed, root = document, pitchOptions) {
45872const tokens = parsed.flat();
45873this.preloadTermAudioForTokens(tokens);
45874void this.enrichPitchWords(tokens, pitchOptions ?? this.nestedPitchEnrichmentOptions());
45875this.queueAnkiWordEnrichment(tokens, [root]);
45876}
45877afterSubtitleJapaneseParsed(tokens, roots = []) {
45878void this.enrichPitchWords(tokens, this.backgroundPitchEnrichmentOptions());
45879const targetRoots = roots.length ? roots : this.subtitleAnkiEnrichmentRoots();
45880this.queueResolvedWordEffects(tokens, targetRoots.length ? targetRoots : [document]);
45881}
45882queueSubtitleParsedHtmlRefresh(sentence) {
45883const text2 = sentence?.trim();
45884if (!text2 || this.isDestroyed) return;
45885this.pendingSubtitleRebakeTexts.add(text2);
45886if (this.subtitleRebakeTimer !== void 0) return;
45887this.subtitleRebakeTimer = window.setTimeout(() => {
45888this.subtitleRebakeTimer = void 0;
45889this.flushSubtitleParsedHtmlRefresh();
45890}, 150);
45891}
45892flushSubtitleParsedHtmlRefresh() {
45893const texts = Array.from(this.pendingSubtitleRebakeTexts);
45894this.pendingSubtitleRebakeTexts.clear();
45895if (!texts.length || this.isDestroyed) return;
45896const subtitles = this.subtitles;
45897if (typeof subtitles.refreshParsedCueTexts !== "function") return;
45898subtitles.refreshParsedCueTexts(texts);
45899}
45900async enrichOcrTokensBeforeRender(tokens) {
45901if (!tokens.length) return;
45902this.preloadTermAudioForTokens(tokens);
45903await this.resolveOcrFallbackTokens(tokens);
45904await this.enrichPitchWords(tokens, this.ocrBeforeRenderPitchEnrichmentOptions());
45905}
45906async enrichSubtitleTokensBeforeRender(tokens) {
45907if (!tokens.length) return;
45908await this.enrichPitchWords(tokens, this.subtitleBeforeRenderPitchEnrichmentOptions());
45909}
45910ocrBeforeRenderPitchEnrichmentOptions() {
45911const hasJpdbKey = hasJpdbApiCredential(this.settings);
45912const hasAnyApiKey = hasJpdbKey || hasJitenApiCredential(this.settings);
45913return {
45914urgent: true,
45915...hasJpdbKey ? {} : { jpdbPublicLookup: false },
45916...hasAnyApiKey ? {} : { publicLookup: false }
45917};
45918}
45919subtitleBeforeRenderPitchEnrichmentOptions() {
45920const background = this.backgroundPitchEnrichmentOptions();
45921const noApiCredential = !hasJpdbApiCredential(this.settings) && !hasJitenApiCredential(this.settings);
45922const isolateKeylessYouTubeSubtitleBudget = noApiCredential && isYouTubeRuntimeHost();
45923const urgentPublicLimit = noApiCredential ? PITCH_ENRICHMENT_LIMIT * 4 : 2;
45924const backgroundPublicLimit = isolateKeylessYouTubeSubtitleBudget ? urgentPublicLimit : background.publicLookupLimit;
45925const backgroundPublicTotalLimit = isolateKeylessYouTubeSubtitleBudget ? urgentPublicLimit : background.publicLookupTotalLimit;
45926const publicLookupLimit = Math.min(urgentPublicLimit, Math.max(0, Math.floor(backgroundPublicLimit ?? urgentPublicLimit)));
45927const publicLookupTotalLimit = Math.min(publicLookupLimit, Math.max(0, Math.floor(backgroundPublicTotalLimit ?? publicLookupLimit)));
45928const publicLookupTermLimit = Math.max(3, Math.floor(background.publicLookupTermLimit ?? 3));
45929return {
45930...background,
45931urgent: true,
45932...isolateKeylessYouTubeSubtitleBudget ? { jpdbPublicLookup: true } : {},
45933publicLookupLimit,
45934publicLookupTotalLimit,
45935publicLookupPageBudget: isolateKeylessYouTubeSubtitleBudget ? void 0 : background.publicLookupPageBudget,
45936publicLookupTermLimit,
45937deferPublicLookup: false
45938};
45939}
45940async resolveOcrFallbackTokens(tokens) {
45941const fallbackTokens = tokens.filter((token) => token.card.source === "fallback");
45942if (!fallbackTokens.length) return;
45943if (!this.isJitenApiActive()) {
45944await this.resolvePublicOcrFallbackTokens(fallbackTokens);
45945return;
45946}
45947await runLimited(fallbackTokens, BACKGROUND_PITCH_ENRICHMENT_CONCURRENCY, async (token) => {
45948const fallback = token.card;
45949const resolved = await this.resolveFallbackVocabularyForPriorityRender(token);
45950if (resolved === fallback || resolved.source === "fallback") return;
45951this.applyResolvedFallbackVocabularyToToken(token, fallback, resolved);
45952});
45953}
45954async resolvePublicOcrFallbackTokens(tokens) {
45955const pending = new Map();
45956for (const token of tokens) {
45957const fallback = token.card;
45958const key = fallbackVocabularySpanCacheKey(fallback, token);
45959const cached = this.resolvedFallbackVocabularyCache.get(key);
45960if (cached && cached !== fallback && cached.source !== "fallback") {
45961this.applyResolvedFallbackVocabularyToToken(token, fallback, cached);
45962continue;
45963}
45964const group = pending.get(key) ?? { card: fallback, tokens: [] };
45965group.tokens.push(token);
45966pending.set(key, group);
45967}
45968if (!pending.size) return;
45969const resolved = await this.publicLookupFallbackCards([...pending.values()].map((group) => group.card), { jpdbPublicLookup: false });
45970for (const group of pending.values()) {
45971const card = resolved.get(cardKey(group.card));
45972if (!card || card.source === "fallback") continue;
45973for (const token of group.tokens) this.applyResolvedFallbackVocabularyToToken(token, group.card, card);
45974}
45975}
45976async resolveFallbackVocabularyForPriorityRender(token) {
45977const fallback = token.card;
45978if (fallback.source !== "fallback") return fallback;
45979const key = fallbackVocabularySpanCacheKey(fallback, token);
45980const cached = this.resolvedFallbackVocabularyCache.get(key);
45981if (cached) return cached;
45982const existing = this.fallbackVocabularyResolutionCache.get(key);
45983if (existing) return existing;
45984const lookup = this.resolveLookupCard(fallback).catch(() => fallback).finally(() => {
45985this.fallbackVocabularyResolutionCache.delete(key);
45986});
45987this.fallbackVocabularyResolutionCache.set(key, lookup);
45988return lookup;
45989}
45990applyResolvedFallbackVocabularyToToken(token, fallback, resolved) {
45991this.rememberResolvedFallbackVocabulary(token, fallback, resolved);
45992token.card = resolved;
45993token.pitchClass = getPitchClass(resolved.pitchAccent, resolved.reading || resolved.spelling) || token.pitchClass;
45994}
45995async enrichOcrRenderedTokens(tokens, root) {
45996if (!tokens.length) return;
45997this.queueResolvedWordEffects(tokens, [root]);
45998}
45999subtitleAnkiEnrichmentRoots() {
46000return Array.from(document.querySelectorAll(
46001".jpdb-subtitle-primary, .jpdb-subtitle-row-text, .asbplayer-subtitles-container-bottom"
46002));
46003}
46004isCurrentPopoverRoot(root) {
46005return Boolean(root.isConnected && this.activePopover && (root === this.activePopover || this.activePopover.contains(root)));
46006}
46007isCurrentSettingsRoot(root) {
46008return Boolean(root.isConnected && this.activePopover === root && root.classList.contains("jpdb-reader-settings"));
46009}
46010queueAnkiWordEnrichment(tokens, roots = [document]) {
46011if (tokens.length) this.queueBunproWordStateEnrichment(roots);
46012if (!tokens.length || !this.shouldRunAnkiBackgroundWork()) return;
46013void this.enrichAnkiWords(tokens, roots);
46014}
46015ankiCachedStatusLookups(uniqueTokens) {
46016return this.anki.findCachedStatusBatch(uniqueTokens.map((token) => token.card)).catch((error) => {
46017log.warnOnce("background-anki-coloring-failed", "Anki background coloring failed", error);
46018return uniqueTokens.map(() => untrustedAnkiLookupResult());
46019});
46020}
46021startAnkiLookupBatch(tokens) {
46022const uniqueTokens = uniqueTokensByCard(tokens);
46023return {
46024tokens: uniqueTokens,
46025lookupKeys: uniqueTokens.map((token) => cardKey(token.card)),
46026lookups: this.ankiCachedStatusLookups(uniqueTokens)
46027};
46028}
46029beginAnkiWordEnrichment(tokens) {
46030if (!tokens.length) return () => void 0;
46031if (!this.shouldRunAnkiBackgroundWork()) return (roots) => this.queueBunproWordStateEnrichment(roots);
46032const batch = this.startAnkiLookupBatch(tokens);
46033return (roots) => {
46034this.queueBunproWordStateEnrichment(roots);
46035void batch.lookups.then((resolved) => {
46036if (!this.shouldRunAnkiBackgroundWork()) return;
46037const current = currentAnkiLookupBatch(batch.tokens, batch.lookupKeys, resolved);
46038if (current.tokens.length) this.applyAnkiLookupsToRenderedWords(current.tokens, current.lookups, roots);
46039});
46040};
46041}
46042async prepareAnkiWordEnrichmentBeforeRender(tokens) {
46043if (!tokens.length) return () => void 0;
46044if (!this.shouldRunAnkiBackgroundWork()) return (roots) => this.queueBunproWordStateEnrichment(roots);
46045const batch = this.startAnkiLookupBatch(tokens);
46046const lookups = await batch.lookups;
46047return (roots) => {
46048this.queueBunproWordStateEnrichment(roots);
46049if (!this.shouldRunAnkiBackgroundWork()) return;
46050const current = currentAnkiLookupBatch(batch.tokens, batch.lookupKeys, lookups);
46051if (current.tokens.length) this.applyAnkiLookupsToRenderedWords(current.tokens, current.lookups, roots);
46052};
46053}
46054async enrichAnkiWords(tokens, roots = [document]) {
46055if (!tokens.length || !this.shouldRunAnkiBackgroundWork()) return;
46056const batch = this.startAnkiLookupBatch(tokens);
46057const lookups = await batch.lookups;
46058if (!this.shouldRunAnkiBackgroundWork()) return;
46059const current = currentAnkiLookupBatch(batch.tokens, batch.lookupKeys, lookups);
46060if (current.tokens.length) this.applyAnkiLookupsToRenderedWords(current.tokens, current.lookups, roots);
46061}
46062queueResolvedWordEffects(tokens, roots) {
46063this.lateCardReconciliation.queue(tokens, roots);
46064}
46065resolvedWordEffectsToken(card, pitchClass) {
46066return this.lateCardReconciliation.token(card, pitchClass);
46067}
46068async recolorRenderedAnkiWordsFromCache(root = document) {
46069if (!this.shouldRunAnkiBackgroundWork()) return;
46070const indexedTokens = this.renderedWordIndex.size ? this.renderedWords.tokensForRecolor(root, (word) => this.renderedWordTokenForRecolor(word)) : [];
46071if (indexedTokens.length || root === document && this.renderedWordIndex.size) {
46072if (indexedTokens.length) await this.enrichAnkiWords(indexedTokens, [root]);
46073return;
46074}
46075const tokens = await this.scanRenderedWordTokensForRecolor(root);
46076if (tokens.length) await this.enrichAnkiWords(tokens, [root]);
46077}
46078async scanRenderedWordTokensForRecolor(root) {
46079const seen = new Set();
46080const tokens = [];
46081for await (const word of renderedWordsInRootChunked(root, ANKI_RECOLOR_SCAN_CHUNK_SIZE)) {
46082if (!this.shouldRunAnkiBackgroundWork()) return [];
46083this.registerRenderedWord(word);
46084const token = this.renderedWordTokenForRecolor(word);
46085if (!token) continue;
46086const key = token.card.source === "fallback" ? fallbackVocabularySpanCacheKey(token.card, token) : cardKey(token.card);
46087if (seen.has(key)) continue;
46088seen.add(key);
46089tokens.push(token);
46090}
46091if (root === document) this.renderedWords.markFullyScanned();
46092return tokens;
46093}
46094renderedWordTokenForRecolor(word) {
46095const identity = renderedWordNumericIdentity(word);
46096const card = this.getCachedCard(identity.vid, identity.sid);
46097if (!card) return null;
46098const surface = readerWordSurfaceText(word);
46099const { start, end } = renderedWordSpan(word, surface.length);
46100return {
46101card,
46102start,
46103end,
46104length: end - start,
46105rubies: [],
46106pitchClass: word.dataset.pitchClass ?? "",
46107sentence: word.dataset.sentence
46108};
46109}
46110async enrichPitchWords(tokens, options = {}) {
46111if (this.isDestroyed || !this.shouldRunCanonicalCardEnrichment()) return;
46112const enrichmentHref = location.href;
46113const enrichmentScope = this.cardLookup.captureTarget();
46114tokens.forEach((token) =>
46114 this.reconcileRenderedTokenFurigana(token));
46115this.deferredPublicJitenReadings.resetIfNeeded();
46116const readingGeneration = this.deferredPublicJitenReadings.generation;
46117await this.deferredPublicJitenReadings.hydrate(tokens, enrichmentScope, enrichmentHref, options.urgent === true);
46118if (this.isDestroyed || location.href !== enrichmentHref || readingGeneration !== this.deferredPublicJitenReadings.generation || !enrichmentScope.isCurrent()) return;
46119if (!this.shouldRunPitchOrReadingEnrichment()) return;
46120let deferLocalMissesToPublicLane = false;
46121if (options.publicLookup === void 0 && options.urgent !== true && await this.hasLocalPitchDictionary()) {
46122options = { ...options, publicLookup: false };
46123deferLocalMissesToPublicLane = true;
46124}
46125const seen = new Set();
46126const tokensNeedingLookup = tokens.filter((token) => !this.applyCachedPublicVocabularyToToken(token));
46127const uniqueTokens = tokensNeedingLookup.filter((token) => {
46128if (this.deferredPublicJitenReadings.needsHydration(token)) return false;
46129if (cardHasContextPitch(token.card) || hasResolvedPitchComponents(token.card)) return false;
46130if (isLowValuePitchEnrichmentToken(token)) return false;
46131if (options.substantivePublicLookupOnly && token.card.source === "fallback" && !isSubstantivePublicPitchLookupToken(token)) return false;
46132if (!token.card.spelling.trim()) return false;
46133const key = cardKey(token.card);
46134if (seen.has(key)) return false;
46135seen.add(key);
46136return true;
46137}).sort((first, second) => pitchEnrichmentPriority(first) - pitchEnrichmentPriority(second));
46138if (options.publicLookup === false) {
46139await this.enrichLocalOnlyPitchTokens(uniqueTokens, options);
46140if (deferLocalMissesToPublicLane) {
46141const misses = uniqueTokens.filter((token) => !cardHasContextPitch(token.card));
46142if (misses.length) this.scheduleDeferredPublicPitchEnrichment(misses);
46143}
46144return;
46145}
46146if (options.urgent && typeof options.publicLookupLimit !== "number") {
46147const urgentTokens = uniqueTokens.map((token) => this.takeQueuedPitchEnrichmentToken(cardKey(token.card)) ?? token);
46148await runLimited(urgentTokens, BACKGROUND_PITCH_ENRICHMENT_CONCURRENCY, (token) => this.enrichPitchToken(token, options));
46149return;
46150}
46151if (typeof options.publicLookupLimit === "number") {
46152const publicLookupLimit = Math.max(0, Math.floor(options.publicLookupLimit));
46153const requestedPublicTotal = boundedPublicPitchLookupReservation(
46154uniqueTokens.length,
46155publicLookupLimit,
46156options.publicLookupTotalLimit
46157);
46158const publicLookupCandidateLimit = this.reserveBackgroundPublicPitchLookups(requestedPublicTotal, options);
46159const publicLookupCandidates = uniqueTokens.slice(0, publicLookupCandidateLimit);
46160const localOnlyTokens = uniqueTokens.slice(publicLookupCandidateLimit);
46161const pausePublicLookupForHover = this.shouldPauseBackgroundPublicPitchLookup(options);
46162const publicTokens = pausePublicLookupForHover ? [] : publicLookupCandidates.slice(0, publicLookupLimit);
46163const deferredPublicTokens = pausePublicLookupForHover ? publicLookupCandidates : publicLookupCandidates.slice(publicLookupLimit);
46164const shouldDeferPublicLookup = pausePublicLookupForHover || options.deferPublicLookup !== false;
46165const localOnlyRetryTokens = [...deferredPublicTokens, ...localOnlyTokens];
46166const localOnly = runLimited(
46167localOnlyRetryTokens,
46168LOCAL_PITCH_ENRICHMENT_CONCURRENCY,
46169(token) => this.enrichPitchToken(token, { publicLookup: false })
46170);
46171if (!publicTokens.length) {
46172await localOnly;
46173if (shouldDeferPublicLookup) this.scheduleDeferredPublicPitchEnrichment(localOnlyRetryTokens);
46174return;
46175}
46176const queuedPublicTokens = await this.resolvePublicFallbackPitchTokens(publicTokens, options);
46177if (!queuedPublicTokens.length) {
46178await localOnly;
46179if (shouldDeferPublicLookup) this.scheduleDeferredPublicPitchEnrichment(localOnlyRetryTokens);
46180return;
46181}
46182this.queuePitchEnrichmentTokens(queuedPublicTokens, options);
46183await Promise.all([localOnly, this.drainPitchEnrichmentQueue()]);
46184if (shouldDeferPublicLookup) this.scheduleDeferredPublicPitchEnrichment(localOnlyRetryTokens);
46185return;
46186}
46187this.queuePitchEnrichmentTokens(uniqueTokens, options);
46188await this.drainPitchEnrichmentQueue();
46189}
46190shouldRunPitchOrReadingEnrichment() {
46191return usesJapaneseProviders() && (this.settings.showPitchAccent || this.settings.showFurigana && this.settings.furiganaMode !== "off");
46192}
46193shouldRunCanonicalCardEnrichment() {
46194return usesJapaneseProviders() && (this.shouldRunPitchOrReadingEnrichment() || shouldLookupAnkiStatus(this.settings) || shouldLookupBunproWordStates(this.settings) || this.settings.yomuLocalSrsEnabled || this.canPreloadBackgroundReaderAudio());
46195}
46196reconcileRenderedTokenFurigana(token) {
46197if (!this.settings.showFurigana || this.settings.furiganaMode === "off" || token.rubies.length) return;
46198const reading = token.card.reading.trim();
46199if (!reading || reading === token.card.spelling.trim()) return;
46200const surface = (token.sentence?.slice(token.start, token.end) || token.card.spelling).trim();
46201if (![...surface].some(isKanjiCharacter)) return;
46202const pitchClass = token.pitchClass || getPitchClass(token.card.pitchAccent, reading) || "unknown";
46203this.applyPublicVocabularyToRenderedWords(token.card, token.card, pitchClass);
46204}
46205resolvePublicFallbackPitchTokens(tokens, options = {}) {
46206return resolvePublicFallbackPitchTokens(tokens, options, {
46207captureTarget: () => this.cardLookup.captureTarget(),
46208hasUnresolvedFallback: (key) => this.hasUnresolvedFallbackVocabulary(key),
46209lookupFallbackCards: (cards, lookupOptions, scope) => this.publicLookupFallbackCards(cards, lookupOptions, scope),
46210lookupJitenCards: (cards, scope) => this.cardLookup.publicLookupHydratableJitenCards(cards, scope),
46211noteFallbackMiss: (key, missedTokens) => this.noteFallbackVocabularyMiss(key, missedTokens),
46212showPitchAccent: () => this.settings.showPitchAccent,
46213rememberResolvedFallback: (token, fallback, card) => this.rememberResolvedFallbackVocabulary(token, fallback, card),
46214applyResolvedCard: (token, fallback, card, pitchClass) => this.applyResolvedPitchCardToToken(token, fallback, card, pitchClass),
46215shouldQueueResolvedPublicPitch: (card, publicLookup) => this.shouldQueueResolvedPublicPitch(card, publicLookup),
46216queueSubtitleRefresh: (sentence) => this.queueSubtitleParsedHtmlRefresh(sentence),
46217cacheCards: (cards) => this.parser.cacheCards?.(cards),
46218enrichLocalPitch: (localTokens) => runLimited(
46219localTokens,
46220LOCAL_PITCH_ENRICHMENT_CONCURRENCY,
46221(token) => this.enrichPitchToken(token, { publicLookup: false })
46222).then(() => void 0)
46223});
46224}
46225shouldQueueResolvedPublicPitch(card, publicLookup) {
46226return this.settings.showPitchAccent && !cardHasContextPitch(card) && publicLookup;
46227}
46228scheduleDeferredPublicPitchEnrichment(tokens) {
46229if (!tokens.length) return;
46230this.queueDeferredPublicPitchTokens(tokens);
46231void this.drainDeferredPublicPitchQueue().catch((error) => {
46232log.warn("Deferred pitch failed", error);
46233});
46234}
46235reserveBackgroundPublicPitchLookups(requested, options) {
46236const normalizedRequested = Math.max(0, Math.floor(requested));
46237if (!normalizedRequested) return 0;
46238if (typeof options.publicLookupPageBudget !== "number") return normalizedRequested;
46239this.resetBackgroundPublicPitchLookupBudgetIfNeeded();
46240const budget = Math.max(0, Math.floor(options.publicLookupPageBudget));
46241const remaining = Math.max(0, budget - this.backgroundPublicPitchLookupBudgetUsed);
46242const reserved = Math.min(normalizedRequested, remaining);
46243this.backgroundPublicPitchLookupBudgetUsed += reserved;
46244return reserved;
46245}
46246resetBackgroundPublicPitchLookupBudgetIfNeeded() {
46247if (this.backgroundPublicPitchLookupBudgetHref === location.href) return;
46248this.backgroundPublicPitchLookupBudgetHref = location.href;
46249this.backgroundPublicPitchLookupBudgetUsed = 0;
46250this.deferredPublicPitchEnqueuedForUrl = 0;
46251this.publicVocabularyMissRetries.clear();
46252this.misalignedPublicFuriganaRecoveries.clear();
46253}
46254queueDeferredPublicPitchTokens(tokens) {
46255this.resetBackgroundPublicPitchLookupBudgetIfNeeded();
46256for (const token of tokens) {
46257if (this.deferredPublicPitchEnqueuedForUrl >= DEFERRED_PUBLIC_PITCH_PER_URL_CAP) break;
46258const key = cardKey(token.card);
46259if (this.deferredPublicPitchQueuedKeys.has(key)) continue;
46260if (this.pitchEnrichmentQueuedKeys.has(key)) continue;
46261this.deferredPublicPitchQueuedKeys.add(key);
46262this.deferredPublicPitchQueue.push(token);
46263this.deferredPublicPitchEnqueuedForUrl++;
46264}
46265}
46266async drainDeferredPublicPitchQueue() {
46267if (this.deferredPublicPitchDrain) return this.deferredPublicPitchDrain;
46268this.deferredPublicPitchDrain = this.runDeferredPublicPitchQueue().finally(() => {
46269this.deferredPublicPitchDrain = void 0;
46270if (!this.isDestroyed && this.shouldRunPitchOrReadingEnrichment() && this.deferredPublicPitchQueue.length) {
46271void this.drainDeferredPublicPitchQueue();
46272}
46273});
46274return this.deferredPublicPitchDrain;
46275}
46276async runDeferredPublicPitchQueue() {
46277while (!this.isDestroyed && this.shouldRunPitchOrReadingEnrichment() && this.deferredPublicPitchQueue.length) {
46278await this.waitForIdle(DEFERRED_PUBLIC_PITCH_ENRICHMENT_IDLE_TIMEOUT_MS);
46279if (this.shouldPauseBackgroundPublicPitchLookup({})) {
46280await delay(DEFERRED_PUBLIC_PITCH_HOVER_PAUSE_MS);
46281continue;
46282}
46283const backoffMs = publicJitenBackoffRemainingMs();
46284if (backoffMs > 0 && this.deferredPublicPitchQueue.slice(0, DEFERRED_PUBLIC_PITCH_ENRICHMENT_CHUNK_SIZE).some((token) => token.card.source === "fallback" || isHydratablePublicJitenCard(token.card))) {
46285await delay(Math.min(backoffMs, DEFERRED_PUBLIC_PITCH_BACKOFF_WAIT_MS));
46286continue;
46287}
46288const batch = this.deferredPublicPitchQueue.splice(0, DEFERRED_PUBLIC_PITCH_ENRICHMENT_CHUNK_SIZE);
46289batch.forEach((token) => this.deferredPublicPitchQueuedKeys.delete(cardKey(token.card)));
46290await this.enrichPitchWords(batch, { publicLookupLimit: batch.length, publicLookup: true });
46291}
46292}
46293shouldPauseBackgroundPublicPitchLookup(options) {
46294return !options.urgent && this.activePopoverMode === "hover" && Boolean(this.activePopover?.isConnected);
46295}
46296queuePitchEnrichmentTokens(tokens, options = {}) {
46297for (const token of tokens) {
46298const key = cardKey(token.card);
46299if (this.pitchEnrichmentQueuedKeys.has(key)) {
46300if (options.urgent) this.promoteQueuedPitchEnrichmentToken(key, options);
46301continue;
46302}
46303this.pitchEnrichmentQueuedKeys.add(key);
46304if (options.urgent) this.pitchEnrichmentUrgentKeys.add(key);
46305this.pitchEnrichmentQueuedOptions.set(key, pitchEnrichmentQueueOptions(options));
46306this.pitchEnrichmentQueue.push(token);
46307}
46308this.sortPitchEnrichmentQueue();
46309while (this.pitchEnrichmentQueue.length > PITCH_ENRICHMENT_QUEUE_LIMIT) {
46310const dropped = this.pitchEnrichmentQueue.pop();
46311if (dropped) this.forgetQueuedPitchEnrichmentToken(cardKey(dropped.card));
46312}
46313}
46314applyCachedPublicVocabularyToToken(token) {
46315if (token.card.source !== "fallback") return false;
46316const fallback = token.card;
46317const card = this.resolvedFallbackVocabularyCache.get(fallbackVocabularySpanCacheKey(fallback, token));
46318if (!card) return false;
46319const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown";
46320token.card = card;
46321token.pitchClass = isParticleCard(card) ? "particle" : pitchClass;
46322const changedRoots = this.applyPublicVocabularyToRenderedWords(fallback, card, token.pitchClass, token);
46323this.queueResolvedWordEffects([token], changedRoots);
46324this.queueSubtitleParsedHtmlRefresh(token.sentence);
46325return true;
46326}
46327promoteQueuedPitchEnrichmentToken(key, options = {}) {
46328const index = this.pitchEnrichmentQueue.findIndex((token) => cardKey(token.card) === key);
46329if (index < 0) return;
46330this.pitchEnrichmentUrgentKeys.add(key);
46331this.pitchEnrichmentQueuedOptions.set(key, pitchEnrichmentQueueOptions({
46332...this.pitchEnrichmentQueuedOptions.get(key),
46333...options,
46334urgent: true
46335}));
46336this.sortPitchEnrichmentQueue();
46337}
46338takeQueuedPitchEnrichmentToken(key) {
46339const index = this.pitchEnrichmentQueue.findIndex((token2) => cardKey(token2.card) === key);
46340if (index < 0) return void 0;
46341const [token] = this.pitchEnrichmentQueue.splice(index, 1);
46342this.forgetQueuedPitchEnrichmentToken(key);
46343return token;
46344}
46345sortPitchEnrichmentQueue() {
46346this.pitchEnrichmentQueue.sort((first, second) => this.pitchQueuePriority(first) - this.pitchQueuePriority(second));
46347}
46348pitchQueuePriority(token) {
46349return this.pitchEnrichmentUrgentKeys.has(cardKey(token.card)) ? -1 : pitchEnrichmentPriority(token);
46350}
46351forgetQueuedPitchEnrichmentToken(key) {
46352if (this.pitchEnrichmentQueue.some((token) => cardKey(token.card) === key)) return;
46353this.pitchEnrichmentQueuedKeys.delete(key);
46354this.pitchEnrichmentUrgentKeys.delete(key);
46355this.pitchEnrichmentQueuedOptions.delete(key);
46356}
46357async drainPitchEnrichmentQueue() {
46358const previous = this.pitchEnrichmentDrain;
46359const drain = (previous ? previous.catch(() => void 0) : Promise.resolve()).then(async () => {
46360do {
46361await this.runPitchEnrichmentQueue();
46362} while (!this.isDestroyed && this.shouldRunPitchOrReadingEnrichment() && this.pitchEnrichmentQueue.length);
46363});
46364this.pitchEnrichmentDrain = drain;
46365void drain.finally(() => {
46366if (this.pitchEnrichmentDrain === drain) this.pitchEnrichmentDrain = void 0;
46367}).catch(() => void 0);
46368return drain;
46369}
46370async runPitchEnrichmentQueue() {
46371while (!this.isDestroyed && this.shouldRunPitchOrReadingEnrichment() && this.pitchEnrichmentQueue.length) {
46372const batch = this.pitchEnrichmentQueue.splice(0, PITCH_ENRICHMENT_LIMIT);
46373const batchOptions = new Map();
46374batch.forEach((token) => {
46375const key = cardKey(token.card);
46376batchOptions.set(key, this.pitchEnrichmentQueuedOptions.get(key) ?? {});
46377this.forgetQueuedPitchEnrichmentToken(key);
46378});
46379await this.waitForBackgroundEnrichmentTurn();
46380await runLimited(batch, BACKGROUND_PITCH_ENRICHMENT_CONCURRENCY, (token) => this.enrichPitchToken(token, batchOptions.get(cardKey(token.card)) ?? {}));
46381if (this.pitchEnrichmentQueue.length) await this.waitForIdle();
46382}
46383}
46384async enrichLocalOnlyPitchTokens(tokens, options) {
46385for (let index = 0; index < tokens.length; index += PITCH_ENRICHMENT_LIMIT) {
46386if (this.isDestroyed || !this.shouldRunPitchOrReadingEnrichment()) return;
46387const chunk = tokens.slice(index, index + PITCH_ENRICHMENT_LIMIT);
46388await this.waitForBackgroundEnrichmentTurn();
46389await runLimited(chunk, LOCAL_PITCH_ENRICHMENT_CONCURRENCY, (token) => this.enrichPitchToken(token, options));
46390if (index + PITCH_ENRICHMENT_LIMIT < tokens.length) await this.waitForIdle();
46391}
46392}
46393async fillCardPitchFromLocalDictionary(card) {
46394if (cardHasContextPitch(card)) return;
46395const localPitch = await this.localPitchAccentForCard(card);
46396if (localPitch.length) card.pitchAccent = mergePitchPatterns(localPitch, card.pitchAccent);
46397}
46398interactiveCardLoadDepth = 0;
46399noteInteractiveCardLoad(load) {
46400this.interactiveCardLoadDepth += 1;
46401const release = () => {
46402this.interactiveCardLoadDepth = Math.max(0, this.interactiveCardLoadDepth - 1);
46403};
46404void load.all.then(release, release);
46405return load;
46406}
46407async waitForBackgroundEnrichmentTurn() {
46408const deadline = Date.now() + 1e4;
46409while (this.interactiveCardLoadDepth > 0 && Date.now() < deadline && !this.isDestroyed) {
46410await delay(150);
46411}
46412}
46413async enrichPitchToken(token, options = {}) {
46414if (isParticleCard(token.card)) return;
46415const fallback = token.card;
46416const previousPitchClass = token.pitchClass ?? "";
46417const card = await this.pitchEnrichedRenderedCard(token, options);
46418const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling);
46419if (card !== fallback) {
46420await this.applyResolvedPitchCardToToken(token, fallback, card, pitchClass);
46421this.queueSubtitleParsedHtmlRefresh(token.sentence);
46422return;
46423}
46424if (hasPaintablePitchComponents(card)) {
46425this.applyPitchComponentsToRenderedWords(card);
46426this.queueSubtitleParsedHtmlRefresh(token.sentence);
46427return;
46428}
46429this.applyPitchClassToFallbackToken(token, card, pitchClass);
46430if (pitchClass && pitchClass !== previousPitchClass) this.queueSubtitleParsedHtmlRefresh(token.sentence);
46431}
46432async pitchEnrichedRenderedCard(token, options) {
46433const fallback = token.card;
46434if (!cardUsesPitchAccentPronunciation(fallback)) return fallback;
46435await this.fillCardPitchFromLocalDictionary(fallback);
46436const card = await this.resolvePitchFallbackCard(token, options);
46437if (card !== fallback) await this.fillCardPitchFromLocalDictionary(card);
46438await this.ensureCardPitchAccent(card, options);
46439return card;
46440}
46441async resolvePitchFallbackCard(token, options) {
46442const fallback = token.card;
46443if (cardHasContextPitch(fallback) || hasResolvedPitchComponents(fallback) || options.publicLookup === false) return fallback;
46444return await this.resolveRenderedFallbackVocabulary(token, options) ?? fallback;
46445}
46446async ensureCardPitchAccent(card, options) {
46447if (!this.settings.showPitchAccent || !cardUsesPitchAccentPronunciation(card)) return;
46448if (cardHasContextPitch(card) || hasResolvedPitchComponents(card)) return;
46449const allowPublicLookup = options.publicLookup !== false && options.jpdbPublicLookup !== false;
46450if (allowPublicLookup) {
46451const pitchAccent = await this.jpdbPublicPitch.lookup(card.spelling, card.reading).catch(() => []);
46452if (pitchAccent.length) {
46453card.pitchAccent = mergePitchPatterns(pitchAccent, card.pitchAccent);
46454return;
46455}
46456}
46457if (card.pitchComponents?.length) {
46458await this.enrichCardPitchComponents(card, card.pitchComponents, allowPublicLookup);
46459return;
46460}
46461const inferred = inferredAnnotatedPitchComponents(card);
46462if (!inferred.length) return;
46463await this.enrichCardPitchComponents(card, inferred, allowPublicLookup);
46464if (hasPaintablePitchComponents({ ...card, pitchComponents: inferred })) card.pitchComponents = inferred;
46465}
46466async enrichCardPitchComponents(card, components, allowPublicLookup) {
46467await Promise.all((components ?? []).map(async (component) => {
46468if (getPitchClass(component.pitchAccent, component.reading || component.spelling)) return;
46469if (component.inferredFromAnnotatedReading && !Array.from(component.spelling).some(isKanjiCharacter)) return;
46470const localPitch = await this.localPitchAccentForCard({
46471...card,
46472spelling: component.spelling,
46473reading: component.reading,
46474pitchAccent: [],
46475pitchComponents: void 0,
46476wordWithReading: component.wordWithReading
46477});
46478if (localPitch.length) {
46479component.pitchAccent = mergePitchPatterns(localPitch, component.pitchAccent);
46480return;
46481}
46482if (!allowPublicLookup) return;
46483component.pitchAccent = await this.jpdbPublicPitch.lookup(component.spelling, component.reading).catch(() => []);
46484}));
46485}
46486async applyResolvedPitchCardToToken(token, fallback, card, pitchClass) {
46487const surface = (token.sentence?.slice(token.start, token.end) || fallback.spelling).trim();
46488const preserved = cardWithPreservedCachedEvidence(card, fallback, surface);
46489if (preserved !== card) Object.assign(card, preserved);
46490if (isParticleCard(card)) {
46491card.pitchAccent = [];
46492card.pitchComponents = void 0;
46493}
46494token.card = card;
46495token.pitchClass = isParticleCard(card) ? "particle" : getPitchClass(card.pitchAccent, card.reading || card.spelling) || pitchClass;
46496const changedRoots = this.applyPublicVocabularyToRenderedWords(fallback, card, token.pitchClass || "unknown", token);
46497this.queueResolvedWordEffects([token], changedRoots);
46498await this.invalidateActivePopoverPitch(card, fallback);
46499}
46500applyPitchClassToFallbackToken(token, card, pitchClass) {
46501if (!pitchClass) return;
46502token.pitchClass = pitchClass;
46503this.applyPitchAccentToRenderedWords(card, pitchClass);
46504void this.invalidateActivePopoverPitch(card);
46505}
46506async invalidateActivePopoverPitch(card, resolvedFrom) {
46507const popover = this.activePopover;
46508const activeCard = this.lastCard;
46509if (!popover?.isConnected || !activeCard) return;
46510const trigger = this.activePopoverMode === "hover" ? "hover" : "modal";
46511if (cardKey(activeCard) === cardKey(card)) {
46512this.updatePopoverPitch(popover, activeCard, []);
46513this.updateCardPopoverPosition(trigger);
46514return;
46515}
46516if (!resolvedFrom || cardKey(activeCard) !== cardKey(resolvedFrom) || !this.isExactCanonicalFallbackResolution(resolvedFrom, card)) return;
46517await this.showCard(card, this.lastCardSentence, this.activePopoverAnchor, {
46518autoPlay: false,
46519trigger,
46520navigation: "preserve",
46521preservePosition: true
46522});
46523}
46524isExactCanonicalFallbackResolution(fallback, resolved) {
46525if (fallback.source !== "fallback" || !resolved.reading.trim()) return false;
46526const expression = normalizedLookupText(resolved.spelling).replace(/\s+/g, "");
46527if (!expression) return false;
46528return fallbackLookupTermsForCard(fallback).some((term) => normalizedLookupText(term).replace(/\s+/g, "") === expression);
46529}
46530clearPitchEnrichmentQueue() {
46531this.pitchEnrichmentQueue = [];
46532this.pitchEnrichmentQueuedKeys.clear();
46533this.pitchEnrichmentUrgentKeys.clear();
46534this.pitchEnrichmentQueuedOptions.clear();
46535this.deferredPublicPitchQueue = [];
46536this.deferredPublicPitchQueuedKeys.clear();
46537this.deferredPublicJitenReadings.clear();
46538}
46539hasLocalPitchDictionary() {
46540if (!this.settings.localDictionariesEnabled) return Promise.resolve(false);
46541const store = this.dictionaries;
46542if (typeof store.hasPitchMetaDictionaries !== "function") return Promise.resolve(false);
46543this.localPitchDictionaryAvailability ??= store.hasPitchMetaDictionaries().catch((error) => {
46544this.localPitchDictionaryAvailability = void 0;
46545log.warn("Local pitch dictionary availability check failed", { error });
46546return false;
46547});
46548return promiseWithTimeout(
46549this.localPitchDictionaryAvailability,
46550LOCAL_PITCH_DICTIONARY_PRESENCE_TIMEOUT_MS,
46551"Local pitch dictionary presence check timed out."
46552).catch((error) => {
46553log.warnOnce("local-pitch-dictionary-presence-timeout", "Local pitch dictionary presence check timed out", error);
46554return false;
46555});
46556}
46557async localPitchAccentForCard(card) {
46558const pattern = await this.localPitchPatternForCard(card);
46559return pattern ? [pattern] : [];
46560}
46561async localPitchPatternForCard(card) {
46562if (!this.settings.localDictionariesEnabled || !card.spelling.trim()) return "";
46563const key = this.localPitchEnrichmentCacheKey(card);
46564const cached = this.pitchEnrichmentLocalCache.get(key);
46565if (cached) return firstLocalPitchPattern(await cached);
46566const promise = localPitchResolutionFromMetaLookup(
46567card.spelling,
46568card.reading,
46569(expression) => this.dictionaries.lookupTermMeta(expression, PITCH_LOCAL_META_LIMIT, this.settings.dictionaryPreferences)
46570).catch((error) => {
46571log.warn("Local pitch enrichment failed", { term: card.spelling }, error);
46572return { patterns: [] };
46573});
46574this.rememberLocalPitchEnrichment(key, promise);
46575return firstLocalPitchPattern(await promise);
46576}
46577localPitchEnrichmentCacheKey(card) {
46578return JSON.stringify({
46579spelling: card.spelling,
46580reading: card.reading,
46581dictionaries: this.settings.dictionaryPreferences.map((preference) => ({
46582name: preference.name,
46583enabled: preference.enabled,
46584priority: preference.priority
46585}))
46586});
46587}
46588rememberLocalPitchEnrichment(key, promise) {
46589this.pitchEnrichmentLocalCache.set(key, promise);
46590evictOldestStringKeysWhileOverLimit(this.pitchEnrichmentLocalCache, PITCH_ENRICHMENT_LOCAL_CACHE_LIMIT);
46591}
46592async resolveRenderedFallbackVocabulary(token, options = {}) {
46593const card = token.card;
46594if (card.source !== "fallback") return void 0;
46595const scope = this.cardLookup.captureTarget();
46596const key = fallbackVocabularySpanCacheKey(card, token);
46597if (!options.urgent && this.hasUnresolvedFallbackVocabulary(key)) return void 0;
46598const publicCard = await this.lookupFallbackApiCard(card, options, scope);
46599if (!scope.isCurrent()) return void 0;
46600if (!publicCard) {
46601this.noteFallbackVocabularyMiss(key, []);
46602return void 0;
46603}
46604if (cardUsesPitchAccentPronunciation(publicCard) && !publicCard.pitchAccent.length && options.jpdbPublicLookup !== false) {
46605const pitchAccent = await this.jpdbPublicPitch.lookup(publicCard.spelling, publicCard.reading).catch(() => []);
46606if (!scope.isCurrent()) return void 0;
46607publicCard.pitchAccent = pitchAccent;
46608}
46609this.rememberResolvedFallbackVocabulary(token, card, publicCard);
46610this.parser.cacheCards?.([publicCard]);
46611return publicCard;
46612}
46613rememberResolvedFallbackVocabulary(token, fallback, card) {
46614if (fallback.source !== "fallback") return;
46615const key = fallbackVocabularySpanCacheKey(fallback, token);
46616this.unresolvedFallbackVocabularyCache.delete(key);
46617this.resolvedFallbackVocabularyCache.delete(key);
46618this.resolvedFallbackVocabularyCache.set(key, card);
46619evictOldestStringKeysWhileOverLimit(this.resolvedFallbackVocabularyCache, RESOLVED_FALLBACK_VOCABULARY_CACHE_LIMIT);
46620this.scheduleCachedPublicVocabularyHydration(document, { fallback, card, span: token });
46621}
46622rememberUnresolvedFallbackVocabulary(key) {
46623this.unresolvedFallbackVocabularyCache.delete(key);
46624this.unresolvedFallbackVocabularyCache.set(key, Date.now() + UNRESOLVED_FALLBACK_VOCABULARY_RETRY_TTL_MS);
46625evictOldestStringKeysWhileOverLimit(this.unresolvedFallbackVocabularyCache, UNRESOLVED_FALLBACK_VOCABULARY_CACHE_LIMIT);
46626}
46627hasUnresolvedFallbackVocabulary(key) {
46628const retryAfter = this.unresolvedFallbackVocabularyCache.get(key);
46629if (retryAfter === void 0) return false;
46630if (retryAfter > Date.now()) return true;
46631this.unresolvedFallbackVocabularyCache.delete(key);
46632return false;
46633}
46634noteFallbackVocabularyMiss(key, tokens) {
46635if (publicJitenBackoffRemainingMs() > 0) {
46636this.requeueDeferredPublicPitchTokens(tokens);
46637return;
46638}
46639const attempts = (this.publicVocabularyMissRetries.get(key) ?? 0) + 1;
46640this.publicVocabularyMissRetries.set(key, attempts);
46641evictOldestStringKeysWhileOverLimit(this.publicVocabularyMissRetries, UNRESOLVED_FALLBACK_VOCABULARY_CACHE_LIMIT);
46642if (attempts <= PUBLIC_VOCABULARY_MISS_RETRY_LIMIT) {
46643this.requeueDeferredPublicPitchTokens(tokens);
46644return;
46645}
46646this.rememberUnresolvedFallbackVocabulary(key);
46647}
46648requeueDeferredPublicPitchTokens(tokens) {
46649let queued = false;
46650for (const token of tokens) {
46651const key = cardKey(token.card);
46652if (this.deferredPublicPitchQueuedKeys.has(key)) continue;
46653if (this.pitchEnrichmentQueuedKeys.has(key)) continue;
46654this.deferredPublicPitchQueuedKeys.add(key);
46655this.deferredPublicPitchQueue.push(token);
46656queued = true;
46657}
46658if (!queued) return;
46659void this.drainDeferredPublicPitchQueue().catch((error) => {
46660log.warn("Deferred pitch retry failed", error);
46661});
46662}
46663preloadTermAudioForTokens(tokens) {
46664if (!this.canPreloadBackgroundReaderAudio()) return;
46665this.queueTermAudioPreloads(tokens);
46666}
46667preloadParsedTokens(tokens) {
46668if (!tokens.length) return;
46669this.parser.cacheCards?.(tokens.map((token) => token.card));
46670this.preloadTermAudioForTokens(tokens);
46671}
46672queueTermAudioPreloads(tokens) {
46673let queued = 0;
46674for (const token of tokens) {
46675if (this.preloadTermAudioForToken(token)) queued++;
46676if (queued >= TERM_AUDIO_PRELOAD_LIMIT) break;
46677}
46678return queued;
46679}
46680preloadTermAudioForToken(token) {
46681if (!isUsefulImmersionPreloadQuery(token.card.spelling)) return false;
46682return this.preloadReaderCardAudio(token.card, { sourceLimit: 1, candidateLimit: 1, prepareAudio: false });
46683}
46684rememberPreloadedTermAudioKey(key) {
46685this.preloadedTermAudioKeys.add(key);
46686evictOldestStringKeysWhileOverLimit(this.preloadedTermAudioKeys, PRELOADED_TERM_AUDIO_KEY_LIMIT);
46687}
46688rememberPreloadedPreparedTermAudioKey(key) {
46689this.preloadedPreparedTermAudioKeys.add(key);
46690evictOldestStringKeysWhileOverLimit(this.preloadedPreparedTermAudioKeys, PRELOADED_TERM_AUDIO_KEY_LIMIT);
46691}
46692dictionaryLabel(name) {
46693return this.settings.dictionaryPreferences.find((item) => item.name === name)?.alias || name;
46694}
46695kanjiSourceTitle(sourceId) {
46696return kanjiSourceLabel(this.settings, sourceId, this.defaultKanjiSourceTitle(sourceId));
46697}
46698kanjiFactSourceTitle(source) {
46699const sourceId = source === "jpdb" ? JPDB_DEFINITION_SOURCE_ID : JITEN_DEFINITION_SOURCE_ID;
46700return definitionSourceLabel(this.settings, sourceId, kanjiFactProviderTitle(source));
46701}
46702defaultKanjiSourceTitle(sourceId) {
46703if (sourceId === KANJI_STROKE_SOURCE_ID) return uiText$1(this.settings.interfaceLanguage, "strokePractice");
46704if (sourceId === KANJI_JPDB_SOURCE_ID) return uiText$1(this.settings.interfaceLanguage, "readingsComponents");
46705if (sourceId === KANJI_RTK_SOURCE_ID) return "RTK";
46706if (sourceId === KANJI_DICTIONARIES_SOURCE_ID) return uiText$1(this.settings.interfaceLanguage, "kanjiDictionaries");
46707if (sourceId === KANJI_ORIGINS_SOURCE_ID) return uiText$1(this.settings.interfaceLanguage, "originStructure");
46708return "";
46709}
46710renderKanjiFactSourcesHtml(jpdbInfo, jitenInfo, language) {
46711const sections = [];
46712const jpdbKey = kanjiSourceStateKey(KANJI_JPDB_SOURCE_ID);
46713if (jpdbInfo && this.kanjiCompanion) {
46714sections.push(this.kanjiCompanion.renderJpdbKanjiInfo(jpdbInfo, language, this.dictionarySourceState.isOpen(jpdbKey), jpdbKey, this.kanjiFactSourceTitle("jpdb")));
46715}
46716if (jitenInfo) {
46717const jitenKey = `${jpdbKey}:jiten`;
46718sections.push(renderJitenKanjiInfo(jitenInfo, language, this.dictionarySourceState.isOpen(jitenKey), jitenKey, this.kanjiFactSourceTitle("jiten")));
46719}
46720return sections.join("");
46721}
46722applyAnkiLookupToRenderedWords(card, ankiLookup, options = {}) {
46723this.applyAnkiLookupMapToRenderedWords(new Map([[renderedWordCardKey(card.vid, card.sid), ankiLookup]]), options.roots ?? [document], options);
46724}
46725applyAnkiLookupsToRenderedWords(tokens, lookups, roots) {
46726const lookupByWordKey = new Map();
46727const empty = () => ({ state: "not-in-deck", notes: [], primary: null });
46728tokens.forEach((token, index) => {
46729lookupByWordKey.set(renderedWordCardKey(token.card.vid, token.card.sid), lookups[index] ?? empty());
46730});
46731this.applyAnkiLookupMapToRenderedWords(lookupByWordKey, roots.length ? roots : [document]);
46732}
46733applyAnkiLookupMapToRenderedWords(lookupByWordKey, roots, options = {}) {
46734if (!lookupByWordKey.size) return;
46735this.pauseAutoScanObserver(() => {
46736const targetRoots = this.renderedAnnotationRoots(roots);
46737if (!this.shouldRunAnkiBackgroundWork()) {
46738this.clearRenderedAnkiLookupStateForKeys(lookupByWordKey, targetRoots);
46739return;
46740}
46741this.renderedWords.prepareForLookups(lookupByWordKey, targetRoots);
46742const touchedWords = [];
46743lookupByWordKey.forEach((lookup, key) => {
46744this.renderedWords.wordsForLookupKey(key, targetRoots).forEach((word) => {
46745applyAnkiLookupToRenderedWord(word, lookup, this.settings.interfaceLanguage, options);
46746touchedWords.push(word);
46747});
46748});
46749refreshContrastForChangedWords(touchedWords);
46750});
46751}
46752clearRenderedAnkiLookupStateForKeys(lookupByWordKey, roots) {
46753const touchedWords = [];
46754lookupByWordKey.forEach((_lookup, key) => {
46755this.renderedWords.wordsForLookupKey(key, roots).forEach((word) => {
46756clearRenderedWordAnkiState(word);
46757touchedWords.push(word);
46758});
46759});
46760refreshContrastForChangedWords(touchedWords);
46761}
46762clearRenderedAnkiWordStates(root = document) {
46763this.pauseAutoScanObserver(() => {
46764this.renderedAnnotationRoots([root]).forEach((targetRoot) => {
46765renderedWordsInRoot(targetRoot).forEach((word) => clearRenderedWordAnkiState(word));
46766refreshReaderWordContrast(targetRoot);
46767});
46768});
46769}
46770registerRenderedWordsInRoot(root) {
46771this.renderedWords.registerRoot(root);
46772}
46773registerRenderedWord(word) {
46774this.renderedWords.register(word);
46775}
46776renderedAnnotationRoots(roots = [document]) {
46777const expanded = [...roots];
46778if (roots.includes(document)) {
46779forEachScannedShadowRoot((root) => expanded.push(root));
46780}
46781return uniqueParentNodes(expanded);
46782}
46783clearRenderedWordIndex() {
46784this.renderedWords.clear();
46785}
46786applyPitchAccentToRenderedWords(card, pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling), roots = [document]) {
46787if (!pitchClass) return;
46788this.applyPitchComponentsToRenderedWords(card, roots, pitchClass);
46789}
46790applyPitchComponentsToRenderedWords(card, roots = [document], pitchClass = "") {
46791if (!pitchClass && !hasPaintablePitchComponents(card)) return;
46792const key = renderedWordCardKey(card.vid, card.sid);
46793this.pauseAutoScanObserver(() => {
46794const changedRoots = new Set();
46795const apply = (word) => {
46796if (pitchClass) {
46797this.applyPitchClassToRenderedSurface(word, pitchClass);
46798setRenderedWordPitchAccentPattern(word, card);
46799}
46800setRenderedWordPitchComponents(word, card);
46801changedRoots.add(word.parentElement ?? word);
46802};
46803const targetRoots = this.renderedAnnotationRoots(roots);
46804this.renderedWords.prepareForLookups(new Map([[key, true]]), targetRoots);
46805this.renderedWords.wordsForLookupKey(key, targetRoots).forEach(apply);
46806changedRoots.forEach((root) => refreshReaderWordContrast(root));
46807});
46808}
46809applyPublicVocabularyToRenderedWords(fallback, card, pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown", span) {
46810return this.lateCardReconciliation.repaint(fallback, card, pitchClass, span);
46811}
46812lateAnnotationRootForRenderedWord(word) {
46813return renderedWordSentenceScope(word);
46814}
46815scheduleReaderKnownStateBackfill() {
46816if (this.knownStateBackfillTimer !== void 0) return;
46817if (!this.canScheduleKnownStateBackfill()) return;
46818this.knownStateBackfillTimer = window.setTimeout(() => {
46819this.knownStateBackfillTimer = void 0;
46820if (!this.canScheduleKnownStateBackfill()) return;
46821if (Date.now() < this.knownStateBackfillBackoffUntil) return;
46822const requestIdle = window.requestIdleCallback;
46823const run = () => {
46824void this.runReaderKnownStateBackfill();
46825};
46826if (typeof requestIdle === "function") requestIdle(run, { timeout: KNOWN_STATE_BACKFILL_IDLE_TIMEOUT_MS });
46827else run();
46828}, KNOWN_STATE_BACKFILL_DELAY_MS);
46829}
46830canScheduleKnownStateBackfill() {
46831return !this.isDestroyed && this.isJitenApiActive() && !(typeof document !== "undefined" && document.hidden);
46832}
46833async runReaderKnownStateBackfill() {
46834if (this.knownStateBackfillRunning || !this.canScheduleKnownStateBackfill()) return;
46835if (Date.now() < this.knownStateBackfillBackoffUntil) return;
46836this.resetKnownStateBackfillForUrlIfNeeded();
46837const wordsBySurface = this.collectProvisionalWordsBySurface();
46838if (!wordsBySurface.size) return;
46839const toParse = [];
46840const recycledChangedRoots = new Set();
46841const recycledGeometryRoots = new Set();
46842const recycledEffectTokens = new Map();
46843this.pauseAutoScanObserver(() => {
46844for (const [surface, words] of wordsBySurface) {
46845const cached = this.knownStateBackfillResolvedCards.get(surface);
46846if (cached) {
46847this.applyKnownStateBackfillCardToWords(cached, words, recycledChangedRoots, recycledGeometryRoots);
46848recycledEffectTokens.set(cardKey(cached), this.resolvedWordEffectsToken(cached));
46849continue;
46850}
46851if (this.knownStateBackfillRequestedSurfaces.has(surface)) continue;
46852if (toParse.length < KNOWN_STATE_BACKFILL_BATCH_LIMIT) toParse.push(surface);
46853}
46854});
46855this.pageScanner.scheduleLateAnnotationRefresh(recycledChangedRoots, recycledGeometryRoots);
46856if (recycledChangedRoots.size) {
46857this.queueResolvedWordEffects([...recycledEffectTokens.values()], [...recycledChangedRoots]);
46858}
46859if (!toParse.length) return;
46860toParse.forEach((surface) => this.knownStateBackfillRequestedSurfaces.add(surface));
46861this.knownStateBackfillRunning = true;
46862let parsed;
46863try {
46864parsed = await this.jiten.parse(toParse);
46865} catch (error) {
46866toParse.forEach((surface) => this.knownStateBackfillRequestedSurfaces.delete(surface));
46867this.knownStateBackfillBackoffUntil = Date.now() + KNOWN_STATE_BACKFILL_BACKOFF_MS;
46868log.warnOnce("known-state-backfill-failed", "Jiten known-state backfill failed", error);
46869return;
46870} finally {
46871this.knownStateBackfillRunning = false;
46872}
46873if (this.isDestroyed) return;
46874this.applyKnownStateBackfill(toParse, parsed, wordsBySurface);
46875}
46876applyKnownStateBackfill(surfaces, parsed, wordsBySurface) {
46877const changedRoots = new Set();
46878const geometryRoots = new Set();
46879const effectTokens = new Map();
46880this.pauseAutoScanObserver(() => {
46881surfaces.forEach((surface, index) => {
46882const card = knownStateBackfillCardForSurface(surface, parsed[index] ?? []);
46883if (!card) return;
46884this.knownStateBackfillResolvedCards.set(surface, card);
46885this.applyKnownStateBackfillCardToWords(card, wordsBySurface.get(surface) ?? [], changedRoots, geometryRoots);
46886effectTokens.set(cardKey(card), this.resolvedWordEffectsToken(card));
46887});
46888});
46889this.pageScanner.scheduleLateAnnotationRefresh(changedRoots, geometryRoots);
46890if (changedRoots.size) this.queueResolvedWordEffects([...effectTokens.values()], [...changedRoots]);
46891}
46892applyKnownStateBackfillCardToWords(card, words, changedRoots, geometryRoots) {
46893const pitchClass = getPitchClass(card.pitchAccent, card.reading || card.spelling) || "unknown";
46894words.forEach((word) => {
46895if (!word.isConnected) return;
46896const changedRoot = this.lateAnnotationRootForRenderedWord(word);
46897if (this.applyPublicVocabularyToRenderedWord(word, card, pitchClass)) geometryRoots.add(changedRoot);
46898changedRoots.add(changedRoot);
46899});
46900}
46901resetKnownStateBackfillForUrlIfNeeded() {
46902if (this.knownStateBackfillRequestedForUrl === location.href) return;
46903this.knownStateBackfillRequestedForUrl = location.href;
46904this.knownStateBackfillRequestedSurfaces.clear();
46905this.knownStateBackfillResolvedCards.clear();
46906this.knownStateBackfillBackoffUntil = 0;
46907}
46908collectProvisionalWordsBySurface() {
46909const bySurface = new Map();
46910for (const word of this.provisionalKnownStateWords()) {
46911const surface = knownStateBackfillSurface(word);
46912if (!surface) continue;
46913const existing = bySurface.get(surface);
46914if (existing) {
46915existing.push(word);
46916continue;
46917}
46918if (this.knownStateBackfillRequestedSurfaces.has(surface) && !this.knownStateBackfillResolvedCards.has(surface)) continue;
46919if (bySurface.size >= KNOWN_STATE_BACKFILL_BATCH_LIMIT) continue;
46920bySurface.set(surface, [word]);
46921}
46922return bySurface;
46923}
46924provisionalKnownStateWords() {
46925const annotatedWords = this.renderedAnnotationRoots().flatMap((root) => renderedWordsInRoot(root));
46926const indexedWords = [...this.renderedWordIndex.values()].flatMap((words) => [...words]).filter((word) => word.isConnected);
46927return [...new Set([...annotatedWords, ...indexedWords])].filter(isProvisionalRenderedWord);
46928}
46929applyCachedPublicVocabularyToRenderedFallbackWords(root) {
46930if (!this.resolvedFallbackVocabularyCache.size) return;
46931const changedRoots = new Set();
46932const geometryRoots = new Set();
46933const effectTokens = new Map();
46934this.pauseAutoScanObserver(() => {
46935this.renderedAnnotationRoots([root]).flatMap((targetRoot) => renderedWordsInRoot(targetRoot)).forEach((word) => this.applyCachedPublicVocabularyToRenderedWord(word, changedRoots, geometryRoots, effectTokens));
46936});
46937this.pageScanner.scheduleLateAnnotationRefresh(changedRoots, geometryRoots);
46938if (changedRoots.size) this.queueResolvedWordEffects([...effectTokens.values()], [...changedRoots]);
46939}
46940applyCachedPublicVocabularyToRenderedWord(word, changedRoots, geometryRoots, effectTokens) {
46941const hydration = cachedRenderedWordHydration(word, this.resolvedFallbackVocabularyCache);
46942if (!hydration) return;
46943const { card, pitchClass } = hydration;
46944const changedRoot = this.lateAnnotationRootForRenderedWord(word);
46945if (this.applyPublicVocabularyToRenderedWord(word, card, pitchClass)) geometryRoots.add(changedRoot);
46946changedRoots.add(changedRoot);
46947effectTokens.set(cardKey(card), this.resolvedWordEffectsToken(card, pitchClass));
46948}
46949scheduleCachedPublicVocabularyHydration(root, resolved) {
46950if (resolved) {
46951const changedRoots = this.applyPublicVocabularyToRenderedWords(
46952resolved.fallback,
46953resolved.card,
46954void 0,
46955resolved.span
46956);
46957if (changedRoots.length) {
46958this.queueResolvedWordEffects([this.resolvedWordEffectsToken(resolved.card)], changedRo
46958ots);
46959}
46960} else {
46961this.applyCachedPublicVocabularyToRenderedFallbackWords(root);
46962}
46963if (this.cachedPublicVocabularyHydrationTimer !== void 0) return;
46964const delays = [120, 500, 1500, 5e3, 1e4];
46965let index = 0;
46966const scheduleNext = () => {
46967const delay2 = delays[index++];
46968if (delay2 === void 0) {
46969this.cachedPublicVocabularyHydrationTimer = void 0;
46970return;
46971}
46972this.cachedPublicVocabularyHydrationTimer = window.setTimeout(() => {
46973if (this.isDestroyed) {
46974this.cachedPublicVocabularyHydrationTimer = void 0;
46975return;
46976}
46977this.applyCachedPublicVocabularyToRenderedFallbackWords(document);
46978scheduleNext();
46979}, delay2);
46980};
46981scheduleNext();
46982}
46983applyPublicVocabularyToRenderedWord(word, card, pitchClass) {
46984const previousKey = renderedWordElementKey(word);
46985const previousWords = this.renderedWordIndex.get(previousKey);
46986previousWords?.delete(word);
46987if (previousWords && !previousWords.size) this.renderedWordIndex.delete(previousKey);
46988const showPitch = this.settings.showPitchAccent;
46989this.applyPitchClassToRenderedSurface(word, showPitch ? pitchClass : "");
46990setRenderedWordCardIdentity(word, card, { pitchPolicy: showPitch ? "replace" : "clear" });
46991this.registerRenderedWord(word);
46992const furiganaChanged = applyPublicVocabularyFurigana(word, card, this.settings);
46993this.ocr.reconcileRenderedWordVocabulary(word, card, word.dataset.pitchClass ?? "");
46994this.recoverMisalignedPublicVocabularyWord(word, card);
46995return furiganaChanged;
46996}
46997recoverMisalignedPublicVocabularyWord(word, card) {
46998if (!this.shouldRunPitchOrReadingEnrichment()) return;
46999if (word.classList.contains("jpdb-reader-has-furi") || word.closest("ruby")) return;
47000const surface = (word.dataset.surface ?? "").trim();
47001if (!surface || surface === card.spelling.trim()) return;
47002if (![...surface].some(isKanjiCharacter)) return;
47003if (this.misalignedPublicFuriganaRecoveries.has(surface)) return;
47004if (this.misalignedPublicFuriganaRecoveries.size >= MISALIGNED_PUBLIC_FURIGANA_RECOVERY_LIMIT) return;
47005this.misalignedPublicFuriganaRecoveries.add(surface);
47006void this.jitenPublicVocabulary.lookup(surface).then((better) => {
47007if (!better || this.isDestroyed) return;
47008if (better.spelling.trim() === card.spelling.trim() && better.reading.trim() === card.reading.trim()) return;
47009const changedRoots = this.applyPublicVocabularyToRenderedWords(card, better);
47010this.queueResolvedWordEffects([this.resolvedWordEffectsToken(better)], changedRoots);
47011}).catch(() => void 0);
47012}
47013applyPitchClassToRenderedSurface(word, pitchClass) {
47014setRenderedWordPitchClass(word, pitchClass);
47015}
47016async handleCardAction(button, card, sentence, suppliedCommand) {
47017if (button.disabled) return;
47018const command = mainCardCommand(button, suppliedCommand);
47019if (!command) return;
47020const action = command.action;
47021const anchor = this.connectedActivePopoverAnchor();
47022const trigger = this.activeTextLookupTrigger();
47023const done = log.time("cardAction", { action, term: card.spelling, trigger });
47024await runCardActionOperation(
47025button,
47026() => refreshAfterCardAction(
47027command.action,
47028() => this.cardActions.perform(command, button, card, sentence, this.cardActionContext(anchor)),
47029() => this.dismissAfterReview(),
47030() => this.showCard(card, sentence, anchor, { autoPlay: false, trigger, navigation: "preserve", preservePosition: true })
47031),
47032{ logger: log, warning: "Card action failed", action, term: card.spelling, language: this.settings.interfaceLanguage, toast: (message) => this.toast(message) },
47033done
47034);
47035}
47036dismissAfterReview() {
47037this.dismiss({ suppressHoverTarget: true });
47038}
47039connectedActivePopoverAnchor() {
47040return this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0;
47041}
47042cardActionContext(anchor) {
47043const sentenceTarget = this.cardActionSentenceTarget(anchor);
47044return sentenceTarget ? { sentenceTarget } : {};
47045}
47046cardActionSentenceTarget(anchor) {
47047const word = anchor?.closest(".jpdb-reader-word");
47048const surface = word ? normalizedLookupText(readerWordSurfaceText(word)) : "";
47049return surface.length <= 80 ? surface : "";
47050}
47051async resolveMiningContext(card, sentence) {
47052const activeContext = this.immersionPopover?.activeContextFor(card);
47053const storedImmersionContext = this.immersionPopover?.storedContextFor(card);
47054const anchor = this.activePopoverAnchor;
47055const context = await resolveMiningContext({
47056term: card.spelling,
47057sentence,
47058settings: this.settings,
47059activeContext,
47060storedContext: storedImmersionContext,
47061sourceKind: inferMiningSourceKind({
47062hostname: location.hostname,
47063hasVideo: Boolean(anchor?.closest(SUBTITLE_SURFACE_SELECTOR)) || Boolean(document.querySelector("video"))
47064}),
47065imageDataUrl: this.settings.ankiCaptureScreenshot ? this.ocr.captureSourceImageForElement(anchor ?? null) : void 0,
47066videoImageDataUrl: this.settings.ankiCaptureScreenshot ? captureActiveVideoFrame() : void 0,
47067fetchImageDataUrl: (imageUrl, timeoutMs) => this.immersionKit ? this.immersionKit.fetchDataUrl(imageUrl, timeoutMs, this.settings.corsProxyUrl, this.settings.interfaceLanguage) : Promise.resolve(void 0),
47068fetchAudioDataUrl: (audioUrls, timeoutMs) => this.immersionKit ? this.immersionKit.fetchDataUrl(audioUrls, timeoutMs, this.settings.corsProxyUrl, this.settings.interfaceLanguage) : Promise.resolve(void 0)
47069});
47070return context;
47071}
47072showSettings(panel) {
47073const settingsSurface = this.settingsSurface;
47074if (settingsSurface) {
47075void Promise.resolve().then(() => settingsSurface.open(panel)).catch((error) => {
47076log.warn("Host Settings surface failed", error);
47077this.toast(uiText$1(this.settings.interfaceLanguage, "settingsCompanionUnavailable"));
47078});
47079return;
47080}
47081const dialog = this.getSettingsDialog();
47082if (dialog) {
47083dialog.open(panel);
47084return;
47085}
47086log.warnOnce("settings-dialog-companion-missing", "Opening the owned Settings surface.");
47087const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : void 0;
47088mountSettingsSurfaceLauncher({
47089createBackdrop: () => this.createLanguageAwareBackdrop(() => this.dismiss()),
47090mountDialog: (backdrop, surface) => this.mountSettingsDialog(backdrop, surface),
47091dismiss: () => this.dismiss(),
47092toast: (message) => this.toast(message)
47093}, this.lookupModal, this.settings.interfaceLanguage, panel, trigger);
47094}
47095settingsAfterDialogTargetChange(previous, settings2, transient) {
47096if (transient) return settings2;
47097if (targetLanguageOf(settings2) === targetLanguageOf(previous)) return settings2;
47098return { ...settings2, learningTargetChosen: true };
47099}
47100applyDialogSettingsTransitions(previous, settings2, pauseChanged) {
47101this.syncCardLookupTarget(settings2);
47102this.stagePreferredJapaneseSiteLanguage(previous, settings2);
47103if (!settings2.ankiEnabled) this.clearRenderedAnkiWordStates();
47104if (pauseChanged) this.applyAnnotationsPausedState();
47105}
47106getSettingsDialog() {
47107const Controller = yomuSettingsDialogController();
47108if (!Controller) return void 0;
47109this.settingsDialog ??= new Controller({
47110getSettings: () => this.settings,
47111setSettings: (settings2, options) => {
47112const transient = options?.transient === true;
47113const pauseChanged = settings2.annotationsPaused !== this.settings.annotationsPaused;
47114const previous = this.settings;
47115const nextSettings = this.settingsAfterDialogTargetChange(previous, settings2, transient);
47116this.settings = nextSettings;
47117if (transient) return;
47118this.applyDialogSettingsTransitions(previous, nextSettings, pauseChanged);
47119},
47120saveSettings: (settings2, options) => this.persistSettings(settings2, options),
47121onSettingsPersisted: (settings2) => this.completePreferredJapaneseSiteLanguageSave(settings2),
47122onSettingsPersistenceFailed: (settings2) => this.failPreferredJapaneseSiteLanguageSave(settings2),
47123jpdb: this.jpdb,
47124dictionaries: this.dictionaries,
47125anki: this.anki,
47126audio: this.audio,
47127subtitles: this.subtitles,
47128ocr: this.ocr,
47129youtube: this.youtube,
47130createBackdrop: () => this.createLanguageAwareBackdrop(() => this.dismiss()),
47131mountDialog: (backdrop, form) => this.mountSettingsDialog(backdrop, form),
47132dismiss: () => this.dismiss(),
47133toast: (message) => this.toast(message),
47134applyTheme: (settings2) => this.applyTheme(settings2),
47135applyAccentColor: (color) => this.applyAccentColor(color),
47136applyWordColors: (settings2) => this.applyWordColors(settings2),
47137lookupText: (text2, sentence, anchor) => this.lookupText(text2, sentence || text2, { anchor, stackOverSettings: true }),
47138parseSettingsJapanese: (form) => this.parseSettingsJapanese(form),
47139installFab: () => this.installFab(),
47140refreshDictionaryStyles: () => this.refreshDictionaryStyles(),
47141scheduleDictionaryRescan: () => this.scheduleDictionaryRescan(),
47142refreshNewTabIfCurrent: () => void 0,
47143clearDictionarySourceOpenOverrides: () => this.dictionarySourceState.clear(),
47144resetAllData: () => this.factoryReset.resetAllData(),
47145beginSettingsPreview: (accent, language, theme) => {
47146this.settingsPreviewOriginalAccent = accent;
47147this.settingsPreviewOriginalLanguage = language;
47148this.settingsPreviewOriginalTheme = theme;
47149},
47150clearSettingsPreview: () => {
47151this.settingsPreviewOriginalAccent = void 0;
47152this.settingsPreviewOriginalLanguage = void 0;
47153this.settingsPreviewOriginalTheme = void 0;
47154}
47155});
47156return this.settingsDialog;
47157}
47158mountSettingsDialog(backdrop, surface) {
47159this.dismiss({ forceAll: true });
47160this.syncReaderRootLanguage(backdrop);
47161this.syncReaderRootLanguage(surface);
47162applyOverlayPageScale(surface);
47163document.body.append(backdrop, surface);
47164this.activeBackdrop = backdrop;
47165this.activePopover = surface;
47166surface.focus();
47167}
47168createPopover(trigger = "modal") {
47169const popover = createReaderPopover(APP_NAME, this.settings, trigger);
47170this.syncReaderRootLanguage(popover);
47171return popover;
47172}
47173mountPopover(popover, anchor, options = {}) {
47174const settingsStack = this.settingsStackForMountedPopover(options);
47175if (settingsStack) forceReaderPopoverSurface(popover, this.settings);
47176const state = this.popoverMountState(anchor, { ...options, stackOverSettings: Boolean(settingsStack) });
47177const preserveOcrLookupLine = Boolean(
47178state.resolvedAnchor && this.activePopover?.contains(state.resolvedAnchor)
47179);
47180if (settingsStack) {
47181this.prepareSettingsStackedPopover(settingsStack);
47182} else {
47183this.dismiss({
47184suppressHoverTarget: false,
47185preserveNavigation: true,
47186preserveHoverGeneration: state.mode === "hover",
47187preserveKeyboardActive: state.resolvedAnchor === this.keyboardActiveWord,
47188preserveOcrLookupState: true,
47189preserveLookupModalSession: state.assistiveModal
47190});
47191}
47192this.appendMountedPopover(popover, state);
47193this.activateMountedPopover(popover, state, options, preserveOcrLookupLine);
47194this.dictionarySourceState.installTracking(popover);
47195this.installMountedPopoverSurface(popover, state);
47196this.finishMountedPopoverLifecycle(popover, state.mode, options);
47197}
47198popoverMountState(anchor, options) {
47199const mode = options.mode ?? "modal";
47200const assistiveModal = mode === "modal" && options.focusOnMount !== false;
47201const backdrop = options.stackOverSettings || mode === "hover" || shouldUseSheet(this.settings) || !this.settings.popoverBackdropEnabled ? void 0 : this.createLanguageAwareBackdrop(() => {
47202this.dismiss();
47203});
47204const resolvedAnchor = connectedElement(anchor) ?? connectedElement(this.activePopoverAnchor);
47205const anchorRect = popoverAnchorRect(resolvedAnchor, this.activePopoverAnchorRect);
47206const previousPopoverRect = options.preservePosition && this.activePopover ? this.popoverOverlayRect(this.activePopover) : void 0;
47207const previousHoverPointerPosition = this.hoverPopoverPointerPosition;
47208const mountParent = fullscreenPopoverMountParent(resolvedAnchor);
47209return { mode, assistiveModal, backdrop, mountParent, resolvedAnchor, anchorRect, previousPopoverRect, previousHoverPointerPosition };
47210}
47211createLanguageAwareBackdrop(onDismiss) {
47212const backdrop = createReaderBackdrop(onDismiss);
47213this.syncReaderRootLanguage(backdrop);
47214return backdrop;
47215}
47216syncReaderRootLanguages() {
47217document.querySelectorAll(READER_ROOT_SELECTOR).forEach((root) => this.syncReaderRootLanguage(root));
47218forEachScannedShadowRoot((shadowRoot) => {
47219shadowRoot.querySelectorAll(READER_ROOT_SELECTOR).forEach((root) => this.syncReaderRootLanguage(root));
47220});
47221}
47222syncReaderRootLanguage(root) {
47223syncLanguageFamilyDom(root, activeLearningTarget().language);
47224root.setAttribute("data-language", root.dataset.language ?? activeLearningTarget().language);
47225applyInterfaceLocaleToRoot(root, this.activeInterfaceLocale());
47226}
47227activeInterfaceLocale() {
47228return resolveInterfaceLocale(this.settings.interfaceLanguage, {
47229browserLocales: typeof navigator === "undefined" ? [] : [...Array.isArray(navigator.languages) ? navigator.languages : [], navigator.language]
47230}).locale;
47231}
47232settingsStackForMountedPopover(options) {
47233return this.activeSettingsLookupStack() ?? (options.stackOverSettings ? this.currentSettingsDialogStack() : void 0);
47234}
47235activeSettingsLookupStack() {
47236if (!this.stackedSettingsDialog) return void 0;
47237return this.activePopover !== this.stackedSettingsDialog.form ? this.stackedSettingsDialog : void 0;
47238}
47239currentSettingsDialogStack() {
47240const form = this.activePopover?.classList.contains("jpdb-reader-settings") ? this.activePopover : void 0;
47241if (!form?.isConnected) return void 0;
47242const backdrop = this.activeBackdrop?.classList.contains("jpdb-reader-backdrop") ? this.activeBackdrop : void 0;
47243return { form, backdrop };
47244}
47245prepareSettingsStackedPopover(stack) {
47246this.stackedSettingsDialog = stack;
47247if (this.activePopover && this.activePopover !== stack.form) {
47248this.immersionPopover?.abortPendingRequests(this.activePopover);
47249this.activePopover.remove();
47250}
47251if (this.activeBackdrop && this.activeBackdrop !== stack.backdrop) this.activeBackdrop.remove();
47252this.activePopoverResizeObserver?.disconnect();
47253this.activePopoverResizeObserver = void 0;
47254this.activeBackdrop = void 0;
47255}
47256appendMountedPopover(popover, state) {
47257const mountParent = state.mountParent ?? document.body;
47258applyOverlayPageScale(popover);
47259if (state.backdrop) mountParent.append(state.backdrop, popover);
47260else mountParent.append(popover);
47261}
47262activateMountedPopover(popover, state, options, preserveOcrLookupLine) {
47263this.activeBackdrop = state.backdrop;
47264this.activePopover = popover;
47265this.activePopoverMode = state.mode;
47266this.activePopoverAnchor = state.resolvedAnchor;
47267this.activePopoverAnchorRect = state.anchorRect;
47268this.activePopoverPositionLocked = shouldLockMountedPopoverPosition(popover, state);
47269this.activeHoverWord = state.mode === "hover" && !options.pointerTextLookup ? state.resolvedAnchor : void 0;
47270this.activeHoverLookupKey = state.mode === "hover" ? options.hoverLookupKey ?? "" : "";
47271this.activePointerTextLookup = state.mode === "hover" ? options.pointerTextLookup : void 0;
47272if (state.assistiveModal) {
47273this.lookupModal.activate(popover, state.resolvedAnchor);
47274}
47275this.retainOcrLookupLineForAnchor(state.resolvedAnchor, { preserveExisting: preserveOcrLookupLine });
47276this.hoverPopoverPointerPosition = mountedHoverPointerPosition(state, this.lastPointerPosition);
47277popover.classList.toggle("jpdb-reader-sheet-sticky", this.isStickyMountedSheet(popover, state));
47278this.nativeTitleGuard.suppressForPopover(popover, state.resolvedAnchor);
47279if (this.shouldPauseForMountedLookup(state)) {
47280this.pauseVideoForSubtitleMining();
47281}
47282}
47283installMountedPopoverSurface(popover, state) {
47284this.installReaderControlPointerActivation(popover);
47285this.installPopoverBodyStabilizers(popover);
47286if (!popover.classList.contains("jpdb-reader-sheet")) {
47287if (typeof ResizeObserver === "function") {
47288this.activePopoverResizeObserver = new ResizeObserver(() => this.scheduleRepositionActivePopoverFrame());
47289this.activePopoverResizeObserver.observe(popover);
47290}
47291if (state.mode !== "hover" && state.previousPopoverRect) {
47292this.lockActivePopoverPosition(state.previousPopoverRect);
47293this.placeActivePopoverWithoutMoving(popover, state.previousPopoverRect);
47294this.syncActivePopoverFixedHeight();
47295} else {
47296this.activePopoverPositionLocked = false;
47297this.activePopoverLockedPosition = void 0;
47298this.repositionActivePopover();
47299if (state.mode !== "hover") this.lockActivePopoverPosition(this.popoverOverlayRect(popover));
47300}
47301requestAnimationFrame(() => this.repositionActivePopover());
47302} else {
47303installSheetHandle(popover, () => this.dismiss(), uiText$1(this.settings.interfaceLanguage, "resizeLookupSheet"));
47304if (this.isStickyMountedSheet(popover, state)) {
47305installSheetCloseButton(popover, () => this.dismiss(), uiText$1(this.settings.interfaceLanguage, "closeDrawer"));
47306}
47307}
47308}
47309isStickyMountedSheet(popover, state) {
47310return state.mode === "modal" && this.settings.stickyBottomSheet && popover.classList.contains("jpdb-reader-sheet");
47311}
47312finishMountedPopoverLifecycle(popover, mode, options) {
47313if (mode === "hover") {
47314this.installHoverPopoverLifecycle(popover);
47315this.hoverClose.startWatch();
47316return;
47317}
47318if (options.focusOnMount === false) return;
47319popover.focus();
47320}
47321repositionActivePopover() {
47322const popover = this.repositionableActivePopover();
47323if (!popover) return;
47324applyOverlayPageScale(popover);
47325const scrollBody = this.popoverScrollBody(popover);
47326const scrollTop = scrollBody.scrollTop;
47327this.prepareActivePopoverForPositioning(popover);
47328if (this.repositionLockedActivePopoverIfNeeded(popover)) {
47329this.restorePopoverScrollTop(scrollBody, scrollTop);
47330return;
47331}
47332this.repositionUnlockedActivePopover(popover);
47333this.restorePopoverScrollTop(scrollBody, scrollTop);
47334}
47335prepareActivePopoverForPositioning(popover) {
47336if (!this.shouldUseFixedModalHeight(popover)) return;
47337const fixedHeight = configuredPopoverMaxHeight(this.settings);
47338if (fixedHeight) popover.style.height = `${fixedHeight}px`;
47339}
47340repositionLockedActivePopoverIfNeeded(popover) {
47341if (!this.activePopoverPositionLocked) return false;
47342this.repositionLockedActivePopover(popover);
47343return true;
47344}
47345repositionUnlockedActivePopover(popover) {
47346this.refreshActivePopoverAnchorRect();
47347positionPopover(
47348popover,
47349this.activePopoverAnchor?.isConnected ? this.activePopoverAnchor : void 0,
47350this.activePopoverAnchorRect,
47351{
47352followPoint: this.shouldFollowActiveHoverPointer() ? this.hoverPopoverPointerPosition : void 0,
47353maxHeight: configuredPopoverMaxHeight(this.settings),
47354preferBefore: this.shouldPreferActiveHoverPopoverBefore(),
47355keepPlacementSide: true
47356}
47357);
47358this.syncActivePopoverFixedHeight();
47359}
47360repositionableActivePopover() {
47361if (!this.activePopover) return null;
47362if (this.activePopover.classList.contains("jpdb-reader-sheet")) return null;
47363if (this.activePopover.classList.contains("jpdb-reader-settings")) return null;
47364return this.activePopover;
47365}
47366repositionLockedActivePopover(popover) {
47367const rect = this.popoverOverlayRect(popover);
47368if (!this.activePopoverLockedPosition) this.lockActivePopoverPosition(rect);
47369this.placeActivePopoverWithoutMoving(popover, this.activePopoverLockedPosition ?? rect);
47370this.syncActivePopoverFixedHeight();
47371}
47372popoverOverlayRect(popover) {
47373return sourceRectToOverlay(popover.getBoundingClientRect(), popover);
47374}
47375lockActivePopoverPosition(rect) {
47376this.activePopoverPositionLocked = true;
47377this.activePopoverLockedPosition = { left: rect.left, top: rect.top };
47378}
47379placeActivePopoverWithoutMoving(popover, rect) {
47380const maxHeight = this.activePopoverMaxHeightAtTop(rect.top);
47381popover.style.left = `${rect.left}px`;
47382popover.style.top = `${rect.top}px`;
47383popover.style.maxHeight = `${maxHeight}px`;
47384}
47385activePopoverMaxHeightAtTop(top) {
47386return popoverMaxHeightAtTop(this.settings, top);
47387}
47388refreshActivePopoverAnchorRect() {
47389if (!this.activePopoverAnchor?.isConnected) return;
47390this.activePopoverAnchorRect = popoverAnchorRect(this.activePopoverAnchor, this.activePopoverAnchorRect);
47391}
47392shouldFollowActiveHoverPointer() {
47393return this.activePopoverMode === "hover" && Boolean(this.hoverPopoverPointerPosition);
47394}
47395shouldPreferActiveHoverPopoverBefore() {
47396return this.shouldFollowActiveHoverPointer();
47397}
47398shouldUseFixedModalHeight(popover) {
47399return shouldUseFixedPopoverHeight(popover, this.settings, this.activePopoverMode !== "hover");
47400}
47401syncActivePopoverFixedHeight() {
47402const popover = this.activePopover;
47403syncFixedPopoverHeight(popover, popover ? this.shouldUseFixedModalHeight(popover) : false);
47404}
47405installPopoverBodyStabilizers(popover) {
47406installPopoverBodyStabilizers(popover);
47407}
47408popoverScrollBody(popover) {
47409return popover.querySelector(".jpdb-reader-popover-body") ?? popover;
47410}
47411restorePopoverScrollTop(scrollBody, scrollTop) {
47412if (scrollBody.scrollTop !== scrollTop) scrollBody.scrollTop = scrollTop;
47413}
47414installHoverPopoverLifecycle(popover) {
47415popover.addEventListener("pointerenter", (event) => {
47416this.lastPointerPosition = { x: event.clientX, y: event.clientY };
47417this.latchHoverPopoverPointer(popover);
47418this.cancelHoverClose();
47419});
47420popover.addEventListener("pointerleave", (event) => {
47421this.lastPointerPosition = { x: event.clientX, y: event.clientY };
47422if (this.isInsideActivePopover(event.relatedTarget)) return;
47423this.hoverPopoverPointerLatched = false;
47424if (this.activeHoverWord && this.isInsideNode(event.relatedTarget, this.activeHoverWord)) return;
47425this.scheduleHoverClose(void 0, { ignoreCssHover: true });
47426});
47427popover.addEventListener("toggle", (event) => {
47428if (this.activePopoverMode !== "hover") return;
47429if (event.target instanceof HTMLDetailsElement) this.markHoverPopoverSelfResize();
47430}, true);
47431}
47432/**
47433* The learner's cursor has entered the hover panel. Two things become true and
47434* must STAY true until a real exit event says otherwise.
47435*
47436* 1. The pointer is inside. The hover watchdog re-asks that question every
47437*    HOVER_WATCH_PERIOD_MS by hit-testing the last known pointer point,
47438*    which is a *sample* of a moving DOM: a re-render between two samples can
47439*    put a different element under a parked cursor, and Firefox transiently
47440*    clears CSS :hover while a scroll changes the descendant beneath it. Over a
47441*    20-second read that is ~220 chances to guess wrong once — which is exactly
47442*    the reported "the hover closed while I was scrolling inside it". A latch set
47443*    by `pointerenter` and cleared by `pointerleave` uses the browser's own
47444*    hit-test at event time instead of re-deriving it from stale geometry.
47445* 2. The panel must stop moving. A hover popover placed above the cursor is
47446*    bottom-pinned, so every hydration re-render moves its TOP edge by the full
47447*    height delta and can slide the frame out from under a stationary cursor.
47448*    Locking the position pins the top and lets growth extend downward instead,
47449*    capped by popoverMaxHeightAtTop so it cannot run off the viewport.
47450*
47451* Locking is deliberately deferred to first hover rather than done at mount:
47452* before the cursor arrives, following the anchor and flipping sides is the
47453* correct behaviour and is what keeps the panel reachable.
47454*/
47455latchHoverPopoverPointer(popover) {
47456if (this.activePopoverMode !== "hover" || this.activePopover !== popover) return;
47457this.hoverPopoverPointerLatched = true;
47458if (this.activePopoverPositionLocked || popover.classList.contains("jpdb-reader-sheet")) return;
47459this.lockActivePopoverPosition(this.popoverOverlayRect(popover));
47460this.placeActivePopoverWithoutMoving(popover, this.activePopoverLockedPosition ?? this.popoverOverlayRect(popover));
47461}
47462hasLatchedHoverPopoverPointer() {
47463return this.hoverPopoverPointerLatched && this.activePopoverMode === "hover" && Boolean(this.activePopover?.isConnected);
47464}
47465clearLatchedHoverPopoverPointerForOutsideEvent(target) {
47466if (!this.hoverPopoverPointerLatched || this.isInsideActivePopover(target)) return;
47467this.hoverPopoverPointerLatched = false;
47468}
47469dismiss(options = { suppressHoverTarget: true }) {
47470if (!options.forceAll && this.shouldDismissStackedLookupOnly()) {
47471this.dismissStackedLookupOverSettings(options);
47472return;
47473}
47474const hadSettingsDialog = Boolean(this.activePopover?.classList.contains("jpdb-reader-settings"));
47475this.lookupModal.release(options.preserveLookupModalSession);
47476this.prepareActivePopoverDismiss(options);
47477if (!options.preserveOcrLookupState) this.releaseOwnedModalOcrPin();
47478this.restoreSettingsPreviewState();
47479this.removeReaderDialogNodes();
47480this.stackedSettingsDialog = void 0;
47481this.clearActivePopoverState(options);
47482if (!options.preserveNavigation) {
47483this.navigation.clearWord();
47484this.navigation.clearKanji();
47485}
47486if (hadSettingsDialog) {
47487this.settingsDialog?.releaseModalBackground();
47488}
47489if (!options.preserveNavigation) this.schedulePendingDictionaryRescan();
47490}
47491shouldDismissStackedLookupOnly() {
47492return Boolean(this.stackedSettingsDialog && this.activePopover && this.activePopover !== this.stackedSettingsDialog.form);
47493}
47494dismissStackedLookupOverSettings(options) {
47495const restoredLookupFocus = this.lookupModal.release(options.preserveLookupModalSession);
47496this.prepareActivePopoverDismiss(options);
47497if (!options.preserveOcrLookupState) this.releaseOwnedModalOcrPin();
47498this.nativeTitleGuard.restore();
47499this.activePopover?.remove();
47500this.activeBackdrop?.remove();
47501this.activePopoverResizeObserver?.disconnect();
47502const stack = this.stackedSettingsDialog;
47503this.clearActivePopoverState(options);
47504if (stack?.form.isConnected) {
47505this.activePopover = stack.form;
47506this.activeBackdrop = stack.backdrop;
47507if (!restoredLookupFocus) stack.form.focus();
47508}
47509this.stackedSettingsDialog = void 0;
47510if (!options.preserveNavigation) {
47511this.navigation.clearWord();
47512this.navigation.clearKanji();
47513}
47514}
47515prepareActivePopoverDismiss(options) {
47516if (this.activePopover) this.immersionPopover?.abortPendingRequests(this.activePopover);
47517if (!options.preserveNavigation) {
47518this.scheduleSubtitleMiningVideoResume(options.deferSubtitleMiningResume ? SUBTITLE_HOVER_MINING_RESUME_GRACE_MS : 0);
47519}
47520this.clearHoverDismissState(options);
47521this.audio.stop();
47522this.immersionPopover?.stopAudio();
47523this.updateSuppressedHoverTarget(options);
47524this.cardRenderRequest++;
47525}
47526clearHoverDismissState(options) {
47527window.clearTimeout(this.hoverLookupTimer);
47528this.hoverClose.stop();
47529if (this.popoverRepositionFrame !== void 0) {
47530window.cancelAnimationFrame(this.popoverRepositionFrame);
47531this.popoverRepositionFrame = void 0;
47532}
47533this.hoverLookupTimer = void 0;
47534this.clearHoverPopoverResizeSticky();
47535this.hoverPopoverPointerLatched = false;
47536this.hoverPopoverPointerPosition = void 0;
47537this.hoverPendingWord = void 0;
47538this.hoverPendingLookupKey = "";
47539this.hoverLookupInFlightKey = "";
47540if (!options.preserveHoverGeneration) this.nextHoverLookupGeneration();
47541}
47542updateSuppressedHoverTarget(options) {
47543const suppressTarget = this.activePopoverMode === "hover" ? this.activeHoverWord : this.activePopoverAnchor;
47544if (options.suppressHoverTarget && suppressTarget?.isConnected && suppressTarget.classList.contains("jpdb-reader-word")) {
47545this.suppressedHoverWord = suppressTarget;
47546this.suppressedHoverLookupKey = this.hoverLookupKeyForWord(suppressTarget);
47547} else {
47548this.suppressedHoverLookupKey = "";
47549}
47550}
47551restoreSettingsPreviewState() {
47552if (!this.hasActiveSettingsPreviewPopover()) {
47553this.clearSettingsPreviewOriginals();
47554return;
47555}
47556const restoredSettings = {};
47557if (this.settingsPreviewOriginalAccent !== void 0) {
47558this.applyAccentColor(this.settingsPreviewOriginalAccent);
47559this.applyWordColors();
47560restoredSettings.accentColor = this.settingsPreviewOriginalAccent;
47561}
47562if (this.settingsPreviewOriginalLanguage !== void 0) {
47563this.settings.interfaceLanguage = this.settingsPreviewOriginalLanguage;
47564}
47565if (this.settingsPreviewOriginalTheme !== void 0) {
47566this.settings.theme = this.settingsPreviewOriginalTheme;
47567restoredSettings.theme = this.settingsPreviewOriginalTheme;
47568}
47569this.applyTheme();
47570if (Object.keys(restoredSettings).length) this.publishSettingsChange(restoredSettings);
47571this.clearSettingsPreviewOriginals();
47572}
47573clearSettingsPreviewOriginals() {
47574this.settingsPreviewOriginalAccent = void 0;
47575this.settingsPreviewOriginalLanguage = void 0;
47576this.settingsPreviewOriginalTheme = void 0;
47577}
47578hasActiveSettingsPreviewPopover() {
47579return Boolean(this.activePopover?.classList.contains("jpdb-reader-settings"));
47580}
47581removeReaderDialogNodes() {
47582this.nativeTitleGuard.restore();
47583this.activePopover?.remove();
47584this.activeBackdrop?.remove();
47585this.activePopoverResizeObserver?.disconnect();
47586document.querySelectorAll("[data-jpdb-reader-root].jpdb-reader-popover, [data-jpdb-reader-root].jpdb-reader-settings, [data-jpdb-reader-root].jpdb-reader-backdrop").forEach((element) => element.remove());
47587}
47588clearActivePopoverState(options = {}) {
47589if (!options.preserveOcrLookupState) this.releaseOcrLookupLine();
47590this.activePopover = void 0;
47591this.activeBackdrop = void 0;
47592this.activePopoverResizeObserver = void 0;
47593this.activePopoverPositionLocked = false;
47594this.activePopoverLockedPosition = void 0;
47595this.activePopoverAnchorRect = void 0;
47596this.activePopoverMode = void 0;
47597this.activePopoverAnchor = void 0;
47598this.activeHoverWord = void 0;
47599this.activeHoverLookupKey = "";
47600this.activePointerTextLookup = void 0;
47601if (!options.preserveKeyboardActive) this.clearKeyboardActiveWord();
47602}
47603retainOcrLookupLineForAnchor(anchor, options = {}) {
47604const release = this.ocr.retainLineForLookup(anchor ?? null);
47605if (!release) {
47606if (!options.preserveExisting) this.releaseOcrLookupLine();
47607return;
47608}
47609const releasePrevious = this.releaseActiveOcrLookupLine;
47610this.releaseActiveOcrLookupLine = release;
47611releasePrevious?.();
47612}
47613releaseOcrLookupLine() {
47614const release = this.releaseActiveOcrLookupLine;
47615this.releaseActiveOcrLookupLine = void 0;
47616release?.();
47617}
47618pinOcrLineForModalLookup(anchor) {
47619const line = anchor.closest(".jpdb-ocr-line");
47620if (!line) {
47621this.releaseOwnedModalOcrPin();
47622return;
47623}
47624if (this.ownedModalOcrPin && this.ownedModalOcrPin !== line) this.releaseOwnedModalOcrPin();
47625if (line.dataset.pinned === "true") return;
47626this.ocr.pinLineForElement(anchor);
47627if (line.dataset.pinned === "true") this.ownedModalOcrPin = line;
47628}
47629releaseOwnedModalOcrPin() {
47630const line = this.ownedModalOcrPin;
47631this.ownedModalOcrPin = void 0;
47632if (line?.dataset.pinned === "true") this.ocr.unpinLineForElement(line);
47633}
47634releaseOrphanedModalOcrPin() {
47635if (!this.activePopover || this.activePopoverMode !== "modal") this.releaseOwnedModalOcrPin();
47636}
47637schedulePendingDictionaryRescan() {
47638if (!this.dictionaryRescanPending) return;
47639this.dictionaryRescanPending = false;
47640window.setTimeout(() => this.scheduleDictionaryRescan(), 80);
47641}
47642toast(message) {
47643showReaderToast(message);
47644}
47645}
47646function jpdbPageAddonActionButton(event, root) {
47647if (!(event.target instanceof Element)) return null;
47648const button = event.target.closest("[data-action]");
47649return button && root.contains(button) ? button : null;
47650}
47651function cardPopoverDeckPickerCommand(button) {
47652return readCardUiCommandCapability(button)?.action === "deck-picker";
47653}
47654function cardPopoverAddCommand(button) {
47655return readCardCommandCapability(button)?.action === "add";
47656}
47657function mainCardCommand(button, supplied) {
47658return supplied ?? readCardCommandCapability(button);
47659}
47660const READER_RUNTIME_MARKER_ID = "jpdb-reader-runtime-owner";
47661const READER_RUNTIME_HEALTH_VERSION = 1;
47662const READER_RUNTIME_SERVICES = [
47663"localization",
47664"local-dictionary",
47665"jiten",
47666"yomu-srs",
47667"jpdb",
47668"bunpro",
47669"translation",
47670"grammar",
47671"mining",
47672"anki",
47673"annotation-layout",
47674"pitch",
47675"audio",
47676"nested-lookup"
47677];
47678const CORE_RUNTIME_SERVICES = [
47679"jiten",
47680"yomu-srs",
47681"jpdb",
47682"audio",
47683"nested-lookup"
47684];
47685const OPTIONAL_RUNTIME_SERVICE_PROBES = [
47686["annotation-layout", hasAnnotationLayoutRuntime],
47687["pitch", hasAnnotationLayoutRuntime],
47688["localization", () => typeof yomuI18nCompanion()?.uiText === "function"],
47689["local-dictionary", () => typeof yomuLocalDictionaries()?.YomitanDictionaryStore === "function"],
47690["translation", () => typeof yomuKanjiStudyCompanion()?.translateTargetSentence === "function"],
47691["grammar", hasGrammarRuntime],
47692["mining", hasMiningRuntime],
47693["anki", () => typeof yomuAnkiCompanion()?.AnkiConnectClient === "function"],
47694["bunpro", () => typeof yomuBunproCompanion()?.BunproClient === "function"]
47695];
47696function currentReaderRuntimeHealth() {
47697const available = new Set(CORE_RUNTIME_SERVICES);
47698for (const [service, isAvailable] of OPTIONAL_RUNTIME_SERVICE_PROBES) {
47699if (isAvailable()) available.add(service);
47700}
47701const services = READER_RUNTIME_SERVICES.filter((service) => available.has(service));
47702const missing = READER_RUNTIME_SERVICES.filter((service) => !available.has(service));
47703return {
47704version: READER_RUNTIME_HEALTH_VERSION,
47705state: missing.length ? "degraded" : "ready",
47706services,
47707missing
47708};
47709}
47710function hasAnnotationLayoutRuntime() {
47711const annotations = yomuAnnotationsCompanion();
47712return typeof annotations?.syncProjectedReadings === "function" && typeof annotations?.clearProjectedReadings === "function";
47713}
47714function hasGrammarRuntime() {
47715const study = yomuKanjiStudyCompanion();
47716return typeof study?.detectGrammarHints === "function" && typeof study?.listLocalGrammarRules === "function";
47717}
47718function hasMiningRuntime() {
47719const study = yomuKanjiStudyCompanion();
47720return typeof study?.normalizeMiningSentence === "function" && typeof study?.StudySourceController === "function";
47721}
47722function publishReaderRuntimeHealth(ownerId, root = document) {
47723const marker2 = root.querySelector(`#${READER_RUNTIME_MARKER_ID}`);
47724if (!marker2 || marker2.dataset.yomuRuntimeOwner !== ownerId) return null;
47725const health = currentReaderRuntimeHealth();
47726marker2.dataset.yomuRuntimeHealth = health.state;
47727marker2.dataset.yomuRuntimeHealthVersion = String(health.version);
47728marker2.dataset.yomuRuntimeServices = health.services.join(",");
47729marker2.dataset.yomuRuntimeMissingServices = health.missing.join(",");
47730return health;
47731}
47732function clearReaderRuntimeHealth(marker2) {
47733delete marker2.dataset.yomuRuntimeHealth;
47734delete marker2.dataset.yomuRuntimeHealthVersion;
47735delete marker2.dataset.yomuRuntimeServices;
47736delete marker2.dataset.yomuRuntimeMissingServices;
47737}
47738const RECOVERY_PANEL = "backup";
47739const WEB_PROTOCOL = /^https?:$/;
47740const PUCK_STYLE = {
47741position: "fixed",
47742right: "max(14px, env(safe-area-inset-right))",
47743bottom: "max(14px, env(safe-area-inset-bottom))",
47744"z-index": "2147483646",
47745display: "inline-flex",
47746"align-items": "center",
47747"justify-content": "center",
47748width: "52px",
47749height: "52px",
47750margin: "0",
47751padding: "0",
47752border: "2px solid #c5221f",
47753"border-radius": "50%",
47754background: "Canvas",
47755color: "CanvasText",
47756font: "600 15px/1 system-ui, sans-serif",
47757"box-shadow": "0 10px 28px rgba(0, 0, 0, 0.28)",
47758cursor: "pointer",
47759opacity: "1"
47760};
47761const BADGE_STYLE = {
47762position: "absolute",
47763top: "-4px",
47764right: "-4px",
47765width: "18px",
47766height: "18px",
47767"border-radius": "50%",
47768background: "#c5221f",
47769color: "#ffffff",
47770font: "700 12px/18px system-ui, sans-serif",
47771"text-align": "center"
47772};
47773let menuCommandRegistered = false;
47774let recoveryPuck;
47775let pendingMount;
47776function offerSettingsRecovery(language = "auto") {
47777const label = settingsRecoveryLabel(language);
47778registerRecoveryMenuCommand(label, language);
47779mountRecoveryPuckWhenReady(label, language);
47780}
47781function withdrawSettingsRecovery() {
47782if (pendingMount) document.removeEventListener("DOMContentLoaded", pendingMount);
47783pendingMount = void 0;
47784recoveryPuck?.remove();
47785recoveryPuck = void 0;
47786}
47787function openStudySettingsRecovery() {
47788const access = sensitiveSettingsSurfaceAccess(location.href, firefoxAuthenticationInfoSettingsPageUrl());
47789const url = sensitiveSettingsLauncherForPanel(access.launcherUrl, RECOVERY_PANEL);
47790if (WEB_PROTOCOL.test(new URL(url).protocol)) return Promise.resolve(openUrlInNewTab(url));
47791return openOwnedFirefoxStudySettings(url);
47792}
47793function settingsRecoveryLabel(language) {
47794const state = uiText$1(language, "extensionSettingsRecoveryTitle");
47795return `${APP_NAME}: ${state} · ${uiText$1(language, "openAccountSettingsTrustedSurface")}`;
47796}
47797function launchStudySettingsRecovery(language) {
47798void openStudySettingsRecovery().catch(() => false).then((opened) => {
47799if (!opened) markLaunchBlocked(language);
47800});
47801}
47802function markLaunchBlocked(language) {
47803if (!recoveryPuck) return;
47804const label = `${APP_NAME}: ${uiText$1(language, "settingsCompanionUnavailable")}`;
47805recoveryPuck.dataset.yomuSettingsRecovery = "open-failed";
47806recoveryPuck.title = label;
47807recoveryPuck.setAttribute("aria-label", label);
47808}
47809function registerRecoveryMenuCommand(label, language) {
47810const register = userscriptMenuCommandRegister();
47811if (menuCommandRegistered || !register) return;
47812menuCommandRegistered = true;
47813register(label, () => launchStudySettingsRecovery(language));
47814}
47815function userscriptMenuCommandRegister() {
47816if (typeof GM_registerMenuCommand === "function") return GM_registerMenuCommand;
47817if (typeof GM !== "undefined" && typeof GM?.registerMenuCommand === "function") {
47818return (name, run) => GM.registerMenuCommand?.(name, run);
47819}
47820return null;
47821}
47822function mountRecoveryPuckWhenReady(label, language) {
47823if (recoveryPuck?.isConnected || pendingMount) return;
47824if (document.body) {
47825mountRecoveryPuck(label, language);
47826return;
47827}
47828const mount = () => {
47829pendingMount = void 0;
47830if (document.body) mountRecoveryPuck(label, language);
47831};
47832pendingMount = mount;
47833document.addEventListener("DOMContentLoaded", mount, { once: true });
47834}
47835function mountRecoveryPuck(label, language) {
47836const puck = recoveryPuckElement(label);
47837puck.addEventListener("click", (event) => {
47838event.preventDefault();
47839event.stopPropagation();
47840if (isDirectTrustedReaderInteraction(event)) launchStudySettingsRecovery(language);
47841});
47842recoveryPuck = puck;
47843document.body.append(puck);
47844}
47845function recoveryPuckElement(label) {
47846const puck = document.createElement("button");
47847puck.type = "button";
47848puck.className = "jpdb-reader-fab jpdb-reader-fab--settings-error";
47849puck.dataset.jpdbReaderRoot = "true";
47850puck.dataset.yomuSettingsRecovery = "unavailable";
47851puck.title = label;
47852puck.setAttribute("aria-label", label);
47853applyImportantStyle(puck, PUCK_STYLE);
47854const badge = document.createElement("span");
47855badge.textContent = "!";
47856badge.setAttribute("aria-hidden", "true");
47857applyImportantStyle(badge, BADGE_STYLE);
47858puck.append(APP_PUCK, badge);
47859return puck;
47860}
47861function applyImportantStyle(element, declarations) {
47862for (const [property, value] of Object.entries(declarations)) {
47863element.style.setProperty(property, value, "important");
47864}
47865}
47866const RUNTIME_MARKER_ID = READER_RUNTIME_MARKER_ID;
47867const RUNTIME_MARKER_OBSERVER_OPTIONS = {
47868attributes: true,
47869attributeFilter: ["data-yomu-runtime-kind", "data-yomu-runtime-owner"]
47870};
47871const YOUTUBE_PLAYBACK_HOST_RE = /(^|\.)youtube(?:-nocookie)?\.com$/i;
47872const YOUTUBE_PLAYBACK_PATH_RE = /^\/(?:embed|watch|shorts|live_chat(?:_replay)?)(?:[/?#]|$)/i;
47873let activeRuntime;
47874let storageBootInFlight;
47875let retainedStartupSettings;
47876let retainedSettingsSurface;
47877function bootReaderApp() {
47878void requestReaderBoot();
47879}
47880function requestReaderBoot(replaceMismatchedRuntime = false) {
47881const existingRequest = reusableBootRequest(replaceMismatchedRuntime);
47882if (existingRequest) return existingRequest;
47883const synchronousBoot = bootReaderAppThroughSynchronousStorageGate();
47884if (synchronousBoot) return synchronousBoot;
47885return startStorageGatedBoot();
47886}
47887function reusableBootRequest(replaceMismatchedRuntime) {
47888if (!replaceMismatchedRuntime) return storageBootInFlight;
47889const runtime2 = activeRuntime;
47890if (!runtime2) return storageBootInFlight;
47891return packagedRuntimeRequest(runtime2);
47892}
47893function packagedRuntimeRequest(runtime2) {
47894if (runtime2.startupSettings === retainedStartupSettings && runtime2.settingsSurface === retainedSettingsSurface) return runtime2.initialization;
47895releaseActiveRuntime(runtime2);
47896const gate = storageBootInFlight;
47897if (!gate) return void 0;
47898return gate.then(() => requestReaderBoot(true));
47899}
47900function startStorageGatedBoot() {
47901storageBootInFlight = bootReaderAppAfterStorageGate().catch((error) => {
47902console.error("[Yomu Reader] Failed to initialize managed web storage", error);
47903offerSettingsRecoveryAfterStorageGateFailure();
47904return false;
47905}).finally(() => {
47906storageBootInFlight = void 0;
47907});
47908return storageBootInFlight;
47909}
47910function bootReaderAppThroughSynchronousStorageGate() {
47911try {
47912if (!ensureManagedWebStorageCurrentSync()) return null;
47913return bootReaderAppAfterStorageBarrier();
47914} catch (error) {
47915console.error("[Yomu Reader] Failed to initialize managed web storage", error);
47916offerSettingsRecoveryAfterStorageGateFailure();
47917return Promise.resolve(false);
47918}
47919}
47920async function bootReaderAppAfterStorageGate() {
47921await ensureManagedWebStorageCurrent();
47922return bootReaderAppAfterStorageBarrier();
47923}
47924function bootReaderAppAfterStorageBarrier() {
47925reconcileActiveRuntimeMarker();
47926const context = resolveBootContext();
47927if (!context) return Promise.resolve(false);
47928return bootResolvedContext(context);
47929}
47930function bootResolvedContext(context) {
47931const runtime2 = createOwnedRuntime(context);
47932if (!runtime2) return Promise.resolve(false);
47933registerRuntime(context.bootWindow, runtime2, isInstalledRuntime(runtime2.kind));
47934runtime2.startupSettings = retainedStartupSettings;
47935runtime2.settingsSurface = retainedSettingsSurface;
47936runtime2.initialization = startRuntime(runtime2, context.embeddedFrame);
47937return runtime2.initialization;
47938}
47939function resolveBootContext() {
47940const embeddedFrame = isEmbeddedFrameWindow();
47941if (embeddedFrame) {
47942ensureEmbeddedFrameTargetPolicySubscription();
47943prepareEmbeddedFrameTargetTextEligibility();
47944}
47945if (embeddedFrame && !embeddedFrameHasImmediateMediaSignal()) {
47946watchEmbeddedFrameForEligibleContent();
47947return void 0;
47948}
47949return claimableBootContext(embeddedFrame);
47950}
47951function claimableBootContext(embeddedFrame) {
47952const bootWindow = window;
47953const runtimeKind = detectRuntimeKind();
47954if (!canReplaceExistingRuntime(bootWindow, runtimeKind)) return void 0;
47955return { bootWindow, embeddedFrame, runtimeKind };
47956}
47957function createOwnedRuntime(context) {
47958const { bootWindow, runtimeKind } = context;
47959destroyExistingApps(bootWindow);
47960const ownerId = claimRuntime(runtimeKind);
47961if (!ownerId) return void 0;
47962const app = createReaderApp();
47963const runtime2 = { app, kind: runtimeKind, ownerId };
47964activeRuntime = runtime2;
47965writeBootWindowOwner(bootWindow, runtime2);
47966runtime2.release = bindClaims(runtime2);
47967return runtime2;
47968}
47969function createReaderApp() {
47970return new ReaderApp();
47971}
47972function isInstalledRuntime(runtimeKind) {
47973return runtimeKind === "userscript" || runtimeKind === "extension";
47974}
47975function reconcileActiveRuntimeMarker() {
47976const runtime2 = activeRuntime;
47977if (!runtime2) return;
47978const marker2 = document.getElementById(RUNTIME_MARKER_ID);
47979if (marker2?.dataset.yomuRuntimeOwner === runtime2.ownerId) return;
47980releaseActiveRuntime(runtime2);
47981removeOwnerlessDisplacedMarker(marker2, runtime2.kind);
47982}
47983function removeOwnerlessDisplacedMarker(marker2, displacedKind) {
47984if (!marker2?.isConnected) return;
47985if (priority(marker2.dataset.yomuRuntimeKind) >
47985 priority(displacedKind)) return;
47986const bootWindow = window;
47987if (bootWindow.__yomuRuntimeOwnerId === marker2.dataset.yomuRuntimeOwner) return;
47988marker2.remove();
47989}
47990function canReplaceExistingRuntime(bootWindow, runtimeKind) {
47991if (activeRuntime) return canClaimOverExistingRuntime(activeRuntime.kind, runtimeKind);
47992if (!bootWindow.__yomuReaderAppInitialized) return true;
47993return canClaimOverExistingRuntime(bootWindow.__yomuRuntimeKind ?? "page", runtimeKind);
47994}
47995function destroyExistingApps(bootWindow) {
47996if (activeRuntime) {
47997releaseActiveRuntime(activeRuntime);
47998}
47999if (!bootWindow.__yomuReaderAppInitialized) return;
48000bootWindow.__yomuRealApp?.destroy({ preservePageWords: true });
48001}
48002function releaseActiveRuntime(runtime2) {
48003if (runtime2.release) {
48004runtime2.release();
48005return;
48006}
48007runtime2.app.destroy({ preservePageWords: true });
48008releaseRuntime(runtime2.ownerId);
48009clearBootWindowOwner(runtime2.app, runtime2.ownerId);
48010}
48011function writeBootWindowOwner(bootWindow, runtime2) {
48012setBootWindowValue(bootWindow, "__yomuReaderAppInitialized", true);
48013setBootWindowValue(bootWindow, "__yomuRuntimeKind", runtime2.kind);
48014setBootWindowValue(bootWindow, "__yomuRuntimeOwnerId", runtime2.ownerId);
48015}
48016function setBootWindowValue(bootWindow, key, value) {
48017try {
48018bootWindow[key] = value;
48019} catch {
48020}
48021}
48022function registerRuntime(bootWindow, runtime2, isRealRuntime) {
48023const { app, kind: runtimeKind } = runtime2;
48024if (isRealRuntime) {
48025setBootWindowValue(bootWindow, "__yomuRealApp", app);
48026dispatchWindowEvent(createWindowCustomEvent("yomu-extension-loaded"));
48027return;
48028}
48029if (runtimeKind === "dev") return;
48030const onExtensionLoaded = () => {
48031if (activeRuntime === runtime2) releaseActiveRuntime(runtime2);
48032};
48033if (!addWindowEventListener("yomu-extension-loaded", onExtensionLoaded)) return;
48034const releaseClaims = runtime2.release;
48035runtime2.release = () => {
48036removeWindowEventListener("yomu-extension-loaded", onExtensionLoaded);
48037releaseClaims?.();
48038};
48039}
48040function startRuntime(runtime2, embeddedFrame) {
48041const { app, ownerId, kind: runtimeKind, startupSettings, settingsSurface } = runtime2;
48042const initOptions = {
48043embeddedFrame,
48044showWelcome: runtimeKind === "userscript" || runtimeKind === "extension",
48045...startupSettings ? { startupSettings } : {},
48046...settingsSurface ? { settingsSurface } : {}
48047};
48048return app.init(initOptions).then(() => {
48049if (activeRuntime !== runtime2) return false;
48050const health = publishReaderRuntimeHealth(ownerId);
48051if (health) return true;
48052releaseActiveRuntime(runtime2);
48053return false;
48054}).catch((error) => {
48055const ownedFailure = activeRuntime === runtime2;
48056if (ownedFailure) releaseActiveRuntime(runtime2);
48057console.error("[Yomu Reader] Failed to initialize", error);
48058if (ownedFailure && isReaderSettingsUnavailable(error)) offerContentPageSettingsRecovery(runtimeKind, embeddedFrame);
48059return false;
48060});
48061}
48062function offerSettingsRecoveryAfterStorageGateFailure() {
48063offerContentPageSettingsRecovery(detectRuntimeKind(), isEmbeddedFrameWindow());
48064}
48065function offerContentPageSettingsRecovery(runtimeKind, embeddedFrame) {
48066if (contentPageOwnsSettingsRecovery(runtimeKind, embeddedFrame)) offerSettingsRecovery();
48067}
48068function contentPageOwnsSettingsRecovery(runtimeKind, embeddedFrame) {
48069if (embeddedFrame) return false;
48070return isInstalledRuntime(runtimeKind) && !isYomuHostedAppUrl(location.href);
48071}
48072function isEmbeddedFrameWindow() {
48073try {
48074return window.self !== window.top;
48075} catch {
48076return true;
48077}
48078}
48079function embeddedFrameHasImmediateMediaSignal() {
48080return isYouTubeMediaFrame() || embeddedFrameHasVideo();
48081}
48082function embeddedFrameHasVideo() {
48083return Boolean(document.querySelector("video"));
48084}
48085function embeddedFrameHasTargetText() {
48086const root = document.body || document.documentElement;
48087const text2 = root ? root.textContent : "";
48088return isTargetLanguageText(String(text2 ?? "").slice(0, 2e5));
48089}
48090let embeddedFrameEligibilityObserver;
48091let embeddedFrameTargetTextEligible = false;
48092let embeddedFrameTargetPolicyInFlight;
48093let embeddedFrameSettingsUnsubscribe;
48094let embeddedFrameLearningTargetChosen = false;
48095let embeddedFrameTargetPolicyRevision = 0;
48096function ensureEmbeddedFrameTargetPolicySubscription() {
48097if (embeddedFrameSettingsUnsubscribe) return;
48098embeddedFrameSettingsUnsubscribe = subscribeToSettingsStorageChanges((settings2) => {
48099embeddedFrameTargetPolicyRevision += 1;
48100applyEmbeddedFrameTargetPolicy(settings2, true);
48101});
48102}
48103function prepareEmbeddedFrameTargetTextEligibility() {
48104if (embeddedFrameTargetPolicyInFlight) return;
48105const revision = embeddedFrameTargetPolicyRevision;
48106embeddedFrameTargetPolicyInFlight = loadSettings().then((settings2) => {
48107if (revision === embeddedFrameTargetPolicyRevision) {
48108applyEmbeddedFrameTargetPolicy(settings2, false);
48109}
48110}).catch((error) => console.error("[Yomu Reader] Failed to resolve embedded-frame learning target", error)).finally(() => {
48111embeddedFrameTargetPolicyInFlight = void 0;
48112});
48113}
48114function applyEmbeddedFrameTargetPolicy(settings2, persistedChange) {
48115if (!embeddedFramePolicyContextIsLive()) return;
48116const previouslyChosen = embeddedFrameLearningTargetChosen;
48117embeddedFrameLearningTargetChosen = settings2.learningTargetChosen;
48118if (!settings2.learningTargetChosen) {
48119embeddedFrameTargetTextEligible = false;
48120return;
48121}
48122adoptLearningTargetFromSettings(settings2);
48123embeddedFrameTargetTextEligible = true;
48124if (reconcileActiveEmbeddedFrameRuntime(persistedChange, previouslyChosen)) return;
48125bootEmbeddedFrameIfEligible();
48126}
48127function embeddedFramePolicyContextIsLive() {
48128if (isEmbeddedFrameWindow()) return true;
48129disposeEmbeddedFrameTargetPolicySubscription();
48130return false;
48131}
48132function reconcileActiveEmbeddedFrameRuntime(persistedChange, previouslyChosen) {
48133if (!activeRuntime) return false;
48134if (persistedChange && !previouslyChosen) {
48135releaseActiveRuntime(activeRuntime);
48136return false;
48137}
48138disposeEmbeddedFrameTargetPolicySubscription();
48139return true;
48140}
48141function bootEmbeddedFrameIfEligible() {
48142if (embeddedFrameHasImmediateMediaSignal() || embeddedFrameHasTargetText()) bootPreparedEmbeddedFrame();
48143}
48144function disposeEmbeddedFrameTargetPolicySubscription() {
48145embeddedFrameSettingsUnsubscribe?.();
48146embeddedFrameSettingsUnsubscribe = void 0;
48147}
48148function watchEmbeddedFrameForEligibleContent() {
48149if (embeddedFrameEligibilityObserver) return;
48150const observer = new MutationObserver((mutations) => {
48151if (!mutations.some(mutationContainsEmbeddedFrameEligibilitySignal)) return;
48152observer.disconnect();
48153embeddedFrameEligibilityObserver = void 0;
48154embeddedFrameTargetTextEligible = false;
48155bootPreparedEmbeddedFrame();
48156});
48157embeddedFrameEligibilityObserver = observer;
48158const observe = () => observer.observe(document.documentElement, {
48159characterData: true,
48160childList: true,
48161subtree: true
48162});
48163if (document.documentElement) observe();
48164else document.addEventListener("DOMContentLoaded", observe, { once: true });
48165}
48166function mutationContainsEmbeddedFrameEligibilitySignal(mutation) {
48167if (mutationAddsVideo(mutation)) return true;
48168if (!embeddedFrameTargetTextEligible) return false;
48169return mutationContainsTargetText(mutation);
48170}
48171function mutationAddsVideo(mutation) {
48172if (mutation.type !== "childList") return false;
48173return [...mutation.addedNodes].some(nodeAddsVideo);
48174}
48175function nodeAddsVideo(node) {
48176if (!(node instanceof Element)) return false;
48177return node.matches("video") || Boolean(node.querySelector("video"));
48178}
48179function mutationContainsTargetText(mutation) {
48180if (mutation.type === "characterData") {
48181return isTargetLanguageText((mutation.target.textContent ?? "").slice(0, 2e5));
48182}
48183return [...mutation.addedNodes].some(nodeContainsTargetText);
48184}
48185function nodeContainsTargetText(node) {
48186return isTargetLanguageText((node.textContent ?? "").slice(0, 2e5));
48187}
48188function bootPreparedEmbeddedFrame() {
48189if (!isEmbeddedFrameWindow()) return;
48190reconcileActiveRuntimeMarker();
48191const context = claimableBootContext(true);
48192if (!context) return;
48193embeddedFrameEligibilityObserver?.disconnect();
48194embeddedFrameEligibilityObserver = void 0;
48195void bootResolvedContext(context);
48196disposeEmbeddedFrameTargetPolicyAfterChosenBoot();
48197}
48198function disposeEmbeddedFrameTargetPolicyAfterChosenBoot() {
48199if (activeRuntime && embeddedFrameLearningTargetChosen) disposeEmbeddedFrameTargetPolicySubscription();
48200}
48201function isYouTubeMediaFrame() {
48202return YOUTUBE_PLAYBACK_HOST_RE.test(location.hostname) && YOUTUBE_PLAYBACK_PATH_RE.test(location.pathname);
48203}
48204function detectRuntimeKind() {
48205if (globalThis.__yomuDevRuntime === true) return "dev";
48206return detectInstalledReaderRuntime() ?? "page";
48207}
48208function priority(kind) {
48209if (kind === "dev") return 4;
48210if (kind === "extension") return 3;
48211if (kind === "userscript") return 2;
48212return 1;
48213}
48214function claimRuntime(kind) {
48215const ownerId = `${kind}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
48216const existing = liveOrClearedRuntimeMarker();
48217if (existing && !canClaimOverExistingRuntime(existing.dataset.yomuRuntimeKind, kind)) {
48218return null;
48219}
48220dispatchWindowEvent(createWindowCustomEvent("yomu-reader-runtime-claim", { ownerId, kind, priority: priority(kind) }));
48221withdrawSettingsRecovery();
48222const marker2 = existing ?? document.createElement("meta");
48223marker2.id = RUNTIME_MARKER_ID;
48224clearReaderRuntimeHealth(marker2);
48225marker2.dataset.yomuRuntimeKind = kind;
48226marker2.dataset.yomuRuntimeOwner = ownerId;
48227if (!marker2.isConnected) appendToDocumentHead(marker2);
48228return ownerId;
48229}
48230function canClaimOverExistingRuntime(existingKind, nextKind) {
48231const existingPriority = priority(existingKind);
48232const nextPriority = priority(nextKind);
48233if (existingPriority < nextPriority) return true;
48234return existingKind === "dev" && nextKind === "dev";
48235}
48236function liveOrClearedRuntimeMarker() {
48237const existing = document.getElementById(RUNTIME_MARKER_ID);
48238if (!existing || !isStaleRuntimeMarker(existing)) return existing;
48239existing.remove();
48240return null;
48241}
48242function isStaleRuntimeMarker(marker2) {
48243const bootWindow = window;
48244if (activeRuntime) return false;
48245if (bootWindow.__yomuReaderAppInitialized || bootWindow.__yomuRealApp) return false;
48246if (marker2.dataset.yomuRuntimeKind === "dev") return true;
48247return Boolean(bootWindow.__yomuRuntimeOwnerId && marker2.dataset.yomuRuntimeOwner === bootWindow.__yomuRuntimeOwnerId);
48248}
48249function bindClaims(runtime2) {
48250const { app, ownerId, kind } = runtime2;
48251let released = false;
48252let markerObserver;
48253const onRuntimeClaim = (event) => {
48254const detail = event.detail;
48255if (!detail || detail.ownerId === ownerId) return;
48256if (priority(detail.kind) < priority(kind)) return;
48257releaseActiveRuntime(runtime2);
48258};
48259const release = () => {
48260if (released) return;
48261released = true;
48262markerObserver?.disconnect();
48263removeWindowEventListener("yomu-reader-runtime-claim", onRuntimeClaim);
48264app.destroy({ preservePageWords: true });
48265releaseRuntime(ownerId);
48266clearActiveRuntime(app, ownerId);
48267clearBootWindowOwner(app, ownerId);
48268};
48269markerObserver = observeRuntimeMarker(ownerId, kind, () => releaseActiveRuntime(runtime2));
48270addWindowEventListener("yomu-reader-runtime-claim", onRuntimeClaim);
48271return release;
48272}
48273function observeRuntimeMarker(ownerId, kind, release) {
48274if (typeof MutationObserver === "undefined") return void 0;
48275const marker2 = document.getElementById(RUNTIME_MARKER_ID);
48276if (!marker2) return void 0;
48277const observer = new MutationObserver(() => {
48278if (marker2.dataset.yomuRuntimeOwner === ownerId) return;
48279if (priority(marker2.dataset.yomuRuntimeKind) < priority(kind)) return;
48280release();
48281removeOwnerlessDisplacedMarker(marker2, kind);
48282});
48283observer.observe(marker2, RUNTIME_MARKER_OBSERVER_OPTIONS);
48284return observer;
48285}
48286function clearBootWindowOwner(app, ownerId) {
48287const bootWindow = window;
48288clearActiveRuntime(app, ownerId);
48289if (bootWindow.__yomuRuntimeOwnerId !== ownerId) return;
48290setBootWindowValue(bootWindow, "__yomuReaderAppInitialized", false);
48291delete bootWindow.__yomuRuntimeOwnerId;
48292delete bootWindow.__yomuRuntimeKind;
48293if (bootWindow.__yomuRealApp === app) delete bootWindow.__yomuRealApp;
48294}
48295function clearActiveRuntime(app, ownerId) {
48296if (activeRuntime?.app === app && (!ownerId || activeRuntime.ownerId === ownerId)) activeRuntime = void 0;
48297}
48298function releaseRuntime(ownerId) {
48299const marker2 = document.getElementById(RUNTIME_MARKER_ID);
48300if (marker2?.dataset.yomuRuntimeOwner === ownerId) marker2.remove();
48301}
48302const TARGET_OWNED_DOCUMENT_START_SLOT = Symbol.for("yomu.target-owned-document-start.v1");
48303function activateTargetOwnedDocumentStartCompanions() {
48304const activate = globalThis[TARGET_OWNED_DOCUMENT_START_SLOT];
48305if (typeof activate === "function") activate();
48306}
48307function objectRecord(value) {
48308return value && typeof value === "object" ? value : null;
48309}
48310const SETTINGS_INTENT_LEDGER_STORAGE_KEY = "yomu:settings-intent:v2";
48311const TRANSACTION_FIELD = "__yomuSettingsPersistenceTransactionV1";
48312const COMMIT_FIELD = "__yomuSettingsPersistenceCommitV1";
48313function committedSettingsStoragePair(storedSettings, storedIntentLedger) {
48314const marker2 = transactionMarker(storedSettings);
48315const { settings: settings2, intentLedger } = marker2 ? { settings: snapshotValue(marker2.settings), intentLedger: snapshotValue(marker2.intentLedger) } : { settings: storedSettings, intentLedger: storedIntentLedger };
48316return matchingCommittedPair(settings2, intentLedger);
48317}
48318function matchingCommittedPair(settings2, intentLedger) {
48319const settingsId = commitId(settings2);
48320return settingsId !== null && settingsId === commitId(intentLedger) ? { settings: withoutCommit(settings2), intentLedger: withoutCommit(intentLedger) } : null;
48321}
48322function commitId(value) {
48323const record2 = objectRecord(value);
48324if (!record2) return void 0;
48325return recordCommitId(record2);
48326}
48327function recordCommitId(record2) {
48328if (!Object.hasOwn(record2, COMMIT_FIELD)) return void 0;
48329const id = record2[COMMIT_FIELD];
48330return typeof id === "string" && id ? id : null;
48331}
48332function withoutCommit(value) {
48333const record2 = objectRecord(value);
48334if (!record2 || !Object.hasOwn(record2, COMMIT_FIELD)) return value;
48335const clean = { ...record2 };
48336delete clean[COMMIT_FIELD];
48337return clean;
48338}
48339function transactionMarker(value) {
48340const owner = objectRecord(value);
48341const marker2 = owner && objectRecord(owner[TRANSACTION_FIELD]);
48342if (!marker2) return null;
48343return validatedTransactionMarker(marker2);
48344}
48345function validatedTransactionMarker(marker2) {
48346if (marker2.version !== 1) return null;
48347const settings2 = serializedSnapshot(marker2.settings);
48348const intentLedger = serializedSnapshot(marker2.intentLedger);
48349return settings2 && intentLedger ? { version: 1, settings: settings2, intentLedger } : null;
48350}
48351function serializedSnapshot(value) {
48352const record2 = objectRecord(value);
48353return record2 && typeof record2.existed === "boolean" && typeof record2.localFallbackExisted === "boolean" ? {
48354existed: record2.existed,
48355previousValue: record2.previousValue,
48356localFallbackExisted: record2.localFallbackExisted,
48357localFallbackValue: record2.localFallbackValue
48358} : null;
48359}
48360function snapshotValue(snapshot) {
48361return snapshot.existed ? snapshot.previousValue : null;
48362}
48363function installDocumentStartTargetPolicy(pageOwnsTarget, activate) {
48364if (pageOwnsTarget) {
48365activate();
48366return;
48367}
48368if (activateFromStoredSettings(readCommittedSettingsSync(), activate)) return;
48369installTargetChoiceHints(activate);
48370}
48371function readCommittedSettingsSync() {
48372return committedSettingsStoragePair(
48373gmStorageGetSharedSync(SETTINGS_STORAGE_KEY, null),
48374gmStorageGetSharedSync(SETTINGS_INTENT_LEDGER_STORAGE_KEY, null)
48375)?.settings;
48376}
48377async function readCommittedSettings() {
48378const [settings2, intentLedger] = await Promise.all([
48379gmStorageGetShared(SETTINGS_STORAGE_KEY, null),
48380gmStorageGetShared(SETTINGS_INTENT_LEDGER_STORAGE_KEY, null)
48381]);
48382return committedSettingsStoragePair(settings2, intentLedger)?.settings;
48383}
48384function installTargetChoiceHints(activate) {
48385const hostedBridge = isHostedYomuOrigin();
48386const reconciliation2 = createAsyncReconciliation(async () => {
48387if (!activateFromStoredSettings(await readCommittedSettings(), activate)) return;
48388removeListeners();
48389});
48390const onHint = () => reconciliation2.request();
48391const removeListeners = () => {
48392removeWindowEventListener(SETTINGS_CHANGE_EVENT, onHint);
48393if (hostedBridge) removeWindowEventListener(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, onHint);
48394reconciliation2.stop();
48395};
48396addWindowEventListener(SETTINGS_CHANGE_EVENT, onHint);
48397if (hostedBridge) addWindowEventListener(USERSCRIPT_STORAGE_BRIDGE_READY_EVENT, onHint);
48398reconciliation2.request();
48399}
48400function activateFromStoredSettings(value, activate) {
48401if (!storedSettingsChooseLearningTarget(value)) return false;
48402activate();
48403return true;
48404}
48405function storedSettingsChooseLearningTarget(value) {
48406if (!isRecord$2(value)) return false;
48407try {
48408return normalizeReaderSettings(value).learningTargetChosen;
48409} catch {
48410return false;
48411}
48412}
48413let targetOwnedDocumentStartActivated = false;
48414const installedRuntime = announceInstalledReaderRuntime();
48415const yomuNewTab = isYomuNewTabUrl(location.href);
48416const pageOwnedLearningTarget = isYomuHostedPassivePage(location.href) || isYomuHostedAcademyPage(location.href);
48417const docEl = document.documentElement;
48418if (installedRuntime && !yomuNewTab) delete docEl?.dataset.yomuHosted;
48419if (installedRuntime || docEl && shouldInstallHostedReaderRuntime()) {
48420if (!installedRuntime) docEl.dataset.yomuHosted = "";
48421if (!yomuNewTab) {
48422void installPreferredJapaneseSiteLanguageFromStoredSettings().catch((error) => {
48423console.error("[Yomu Reader] Failed to initialize site-language preference", error);
48424});
48425}
48426installUserscriptGmStorageBridgeWhenReady();
48427installDocumentStartTargetPolicy(pageOwnedLearningTarget, activateTargetOwnedDocumentStart);
48428if (!yomuNewTab) {
48429bootWhenDocumentIsReady();
48430}
48431}
48432function activateTargetOwnedDocumentStart() {
48433if (targetOwnedDocumentStartActivated) return;
48434targetOwnedDocumentStartActivated = true;
48435installUserscriptHttpBridgeWhenReady();
48436applyMokuroReaderOcrDefault();
48437activateTargetOwnedDocumentStartCompanions();
48438installMokuroToggleNoteWhenReady();
48439}
48440function installMokuroToggleNoteWhenReady() {
48441if (document.readyState === "loading") {
48442document.addEventListener("DOMContentLoaded", installMokuroOcrToggleNote, { once: true });
48443return;
48444}
48445installMokuroOcrToggleNote();
48446}
48447function bootWhenDocumentIsReady() {
48448if (document.readyState === "loading") {
48449document.addEventListener("DOMContentLoaded", onDocumentReady, { once: true });
48450return;
48451}
48452onDocumentReady();
48453}
48454function onDocumentReady() {
48455bootReaderApp();
48456}
48457
48458})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.