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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); 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-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-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 = /^(?:[\dï¼-ï¼][\dï¼-ï¼,.ï¼]*\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.