Files
SubMiner/vendor/hachidori/extension/settings.js
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

3631 lines
141 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Settings page: dictionary import, load order, and lookup options.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { extensionApi as chrome, IS_FIREFOX } from "./browser-api.js";
import "./reader-options.js";
import { createAudioSettingsController } from "./audio-settings.js";
import { createKeybindSettingsController } from "./keybind-settings.js";
import { createAnkiTemplateSettingsController } from "./anki-settings.js";
import { createLocalAudioSetup } from "./local-audio-setup.js";
import { createBackupSettingsController } from "./backup-settings.js";
import { createExperimentalSettings } from "./experimental-settings.js";
import { downloadBlob } from "./blob-download.js";
import { createSharingSettingsController } from "./sharing-settings.js";
import { ANKI_ADDON_FILE_NAME, fetchAnkiAddon } from "./anki-addon.js";
import { createLocalFileAccessController } from "./local-file-access.js";
import { createSettingsSearch } from "./settings-search.js";
import { applyPageTheme, setStatusOutput } from "./settings-dom.js";
import { HOST_BROWSER, HOST_CAPABILITIES, MINING_CAPABILITIES, OVERLAY_MODE } from "./overlay-mode.js";
import { createRecommendedInstallClient } from "./recommended-install-client.js";
import { createCustomButtonSettings } from "./custom-button-settings.js";
import { createDictionaryNameDrafts, renameWithBaseline } from "./dictionary-name-drafts.js";
import {
createDictionaryProgressList,
installEntryState,
formatSeconds,
} from "./dictionary-progress.js";
import {
createDictionaryGroupController,
normaliseDictionaryGroups,
} from "./dictionary-groups.js";
import {
effectiveDictionarySchedule,
managedDictionarySource,
nextDictionaryUpdateCheck,
normaliseUpdateSettings,
recommendedDictionaryInstalled,
} from "./managed-dictionary-source.js";
import { RECOMMENDED_DICTIONARIES, describeRecommendedCatalogue } from "./recommended-dictionaries.js";
import {
CUSTOM_DICTIONARY_ID,
CUSTOM_DICTIONARY_SOURCE_KEY,
normaliseCustomDictionaryDocument,
parseCustomDictionary,
} from "./custom-dictionary.js";
import { SETUP_STATE_KEY, normaliseSetupState, setupIncomplete } from "./setup-state.js";
import { readDictionaryArchiveIdentity } from "./dictionary-import-archive.js";
import {
describeRevisionComparison,
dictionaryImportMatches,
dictionaryImportTarget,
} from "./dictionary-import.js";
const TARGET = "hoshidicts-offscreen";
const WORKER_TARGET = "hoshidicts-worker";
const UPDATE_TARGET = "hachidori-updates";
const AUDIO_TARGET = "hachidori-audio";
const CAPTURE_TARGET = "hachidori-capture";
const SHARING_TARGET = "hachidori-sharing";
const BACKUP_LIFECYCLE_PORT = "hachidori-backup-settings";
const OPTION_SECTIONS = {
lookup: "Reading",
design: "Design",
audio: "Audio",
...(!IS_FIREFOX ? { media: "Media capture" } : {}),
anki: "Anki",
keybinds: "Keybinds",
advanced: "Advanced",
};
const LIBRARY_SECTIONS = new Set(["dictionaries", "add-dictionaries", "updates", "dictionary-groups", "custom-dictionary"]);
const {
DEFAULT_OPTIONS, LOOKUP_MODES, ACTIVATION_KEYS, FREQUENCY_ORDERS,
POPUP_THEME_GROUPS, DESIGN_OPTION_KEYS, DEFINITION_BLUR_DIRECTIONS, DEFINITION_BLUR_REVEALS,
DEFINITION_BLUR_FREQUENCY_ORDERS, EXPERIMENTAL_FEATURES,
clampOption, normaliseCustomButtons, normaliseKanjiSelection, normaliseOptions, normaliseTexthookerUrl,
} = globalThis.HDReaderOptions;
const STATUS_POLL_MS = 1000;
// Slower than the boot poll: a failing poll may be failing for a while, and the
// settings page can be left open.
const STATUS_RETRY_MS = 5000;
const NUMBER_FIELDS = [
{ key: "scanLength", id: "opt-scan-length" },
{ key: "maxResults", id: "opt-max-results" },
{ key: "popupHideDelayMs", id: "opt-hide-delay" },
{ key: "popupNestingMaxDepth", id: "opt-popup-nesting-depth" },
{ key: "popupColumns", id: "opt-popup-columns" },
{ key: "compactDefinitionSummaryCount", id: "opt-summary-count" },
{ key: "definitionBlurThreshold", id: "opt-blur-threshold" },
{ key: "definitionBlurFrequencyThreshold", id: "opt-blur-frequency-threshold" },
{ key: "popupWidthPx", id: "opt-popup-width", live: true },
{ key: "popupHeightPx", id: "opt-popup-height", live: true },
{ key: "popupScalePercent", id: "opt-popup-scale", live: true },
{ key: "popupOpacityPercent", id: "opt-popup-opacity", live: true },
{ key: "automaticBackupDays", id: "opt-automatic-backup-days" },
];
const METADATA_FIELDS = [
{ key: "showLookupCounts", id: "opt-lookup-counts" },
{ key: "showFrequencyDictionaryNames", id: "opt-frequency-names" },
{ key: "averageFrequency", id: "opt-average-frequency" },
{ key: "showPitchAccentFurigana", id: "opt-pitch-furigana" },
{ key: "showPitchAccentBadge", id: "opt-pitch-badge" },
{ key: "hidePopupGrammarTags", id: "opt-grammar-tags", inverted: true },
];
const APPEARANCE_CHOICES = [
{ key: "popupTheme", id: "opt-popup-theme" },
{ key: "popupToolbarPosition", id: "opt-popup-toolbar" },
{ key: "definitionBlurDirection", id: "opt-blur-direction", values: DEFINITION_BLUR_DIRECTIONS },
{ key: "definitionBlurFrequencyOrder", id: "opt-blur-frequency-order", values: DEFINITION_BLUR_FREQUENCY_ORDERS },
{ key: "definitionBlurReveal", id: "opt-blur-reveal", values: DEFINITION_BLUR_REVEALS },
];
const numberFormat = new Intl.NumberFormat();
let dictionaryState = { schemaVersion: 1, revision: -1, dictionaries: [], groups: [] };
let dictionaries = dictionaryState.dictionaries;
let options = normaliseOptions({});
let savedOptions = normaliseOptions({});
let optionsRevision = -1;
let pendingOptions = {};
let pendingOptionsRevision = 0;
let savingOptions = null;
let optionsSaveCompletion = Promise.resolve();
let optionsTimer = null;
let optionsSaveFailed = false;
let optionsEditRevision = null;
const OPTIONS_SAVE_DELAY_MS = 150;
const nameDrafts = createDictionaryNameDrafts({
delayMs: OPTIONS_SAVE_DELAY_MS,
afterSave: () => renderChangedDictionaryState(),
});
let updateSettings = { revision: -1, schedule: "off", lastCheckedAt: null };
let pendingSchedule = null, savingSchedule = null, scheduleTimer = null;
let scheduleSaveFailed = false;
let customDocument = null;
let customBaseDocument = null;
let customBaseEditorText = "";
let customValidationTimer = null;
let customEditorLoaded = false;
let customLoading = false;
let customSaving = false;
let customDraftStale = false;
let customDraftNewline = "\n";
let importing = false;
let installingRecommended = false;
let renderedInstallRun = null;
const recommendedInstallation = createRecommendedInstallClient({
send: sourceIds => send("hd_setup_install", { sourceIds }, "hachidori-setup"),
onChange: renderRecommendedInstallation,
onError(error) { setImportState(`Could not observe dictionary installation: ${describe(error)}`, "error"); },
});
let updating = false;
let removing = false;
let committing = false;
let pendingDictionaryCommits = 0;
let dictionaryCommitTail = Promise.resolve();
let dictionaryCommitFailed = false;
let dictionaryRenderDeferred = false;
// A pending reorder can reuse the existing rows: only their order and the
// index-dependent controls change, not the package set or per-package metadata.
// Any other queued change clears this so a coalesced render rebuilds instead.
let reorderReuseHint = false;
let pendingManagementFocus = null;
let managementPointerDown = false;
let dictionarySearch = "";
const selectedDictionaryIds = new Set();
const expandedDictionaryIds = new Set();
let draggedDictionaryId = null;
let statusTimer = null;
let lastEngineStatus = null;
let requestCounter = 0;
let audioController;
let keybindController;
let ankiController;
let localAudioSetup;
let sharingController;
// The address of the Hachidori this install is linked to, or null.
let sharingLinkedAddress = null;
let backupController;
let backupLifecyclePort = null;
let backupLifecycleReconnectTimer = null;
const backupLifecycleTokens = new Set();
let customButtonController;
let experimentalController;
let backingUp = false;
let mediaStatusEpoch = 0;
let mediaRuntimeState = "unavailable";
let settingsSearch;
let importProgress;
let importDragDepth = 0;
const SECTION_STATUSES = {
"import-state": { section: "add-dictionaries", label: "Import" },
"update-state": { section: "updates", label: "Updates" },
"custom-dictionary-status": { section: "custom-dictionary", label: "Personal dictionary" },
"options-status": { section: "lookup", label: "Reading" },
"dict-group-error": { section: "dictionary-groups", label: "Groups" },
"backup-status": { section: "backup", label: "Backup" },
"sharing-status": { section: "sharing", label: "Sharing" },
};
let activeSection = "dictionaries";
const unseenSectionCompletions = new Set();
function element(id) {
return document.getElementById(id);
}
function configureBrowserUi() {
if (!HOST_CAPABILITIES.customJavaScript) {
const customJavascript = element("custom-javascript");
customJavascript.dataset.settingsUnavailable = "true";
customJavascript.hidden = true;
}
if (!IS_FIREFOX) return;
const media = element("media");
media.dataset.settingsUnavailable = "true";
media.hidden = true;
const mediaOption = element("settings-section").querySelector('option[value="media"]');
mediaOption.hidden = true;
mediaOption.disabled = true;
document.querySelector('.settings-nav a[href="#media"]').closest(".nav-item").hidden = true;
element("audio-mining-help").textContent =
"Firefox can play browser speech, but Hachidori does not record it into Anki. Add a downloadable pronunciation source to fill {audio} fields.";
const shortcutHelp = element("browser-shortcuts").querySelector(".field-hint");
shortcutHelp.textContent =
"Firefox runs these on any page. Popup actions need an open popup. Change them in Firefoxs Manage Extension Shortcuts page.";
element("browser-shortcuts-open").textContent = "Change in Firefox";
}
function sectionHasPendingWork(id) {
switch (id) {
case "import-state": return importing || installingRecommended;
case "update-state": return updating || savingSchedule !== null || pendingSchedule !== null;
case "custom-dictionary-status": return customLoading || customSaving || customDictionaryDirty();
case "backup-status": return backingUp;
case "options-status": return savingOptions !== null || Object.keys(pendingOptions).length > 0;
default: return false;
}
}
function primaryNavigationSection(section) {
return LIBRARY_SECTIONS.has(section) ? "dictionaries" : section;
}
function renderNavigationStatuses() {
const messages = new Map();
for (const [id, { section, label }] of Object.entries(SECTION_STATUSES)) {
const source = element(id);
const attention = source.classList.contains("is-error") || unseenSectionCompletions.has(id) || sectionHasPendingWork(id);
if (section === activeSection || !attention || !source.textContent) continue;
const navigationSection = primaryNavigationSection(section);
const status = messages.get(navigationSection) ?? { messages: [], error: false, ready: true };
status.messages.push(`${label}: ${source.textContent}`);
status.error ||= source.classList.contains("is-error");
status.ready &&= source.classList.contains("is-ready");
messages.set(navigationSection, status);
}
for (const notice of document.querySelectorAll(".nav-status")) {
const status = messages.get(notice.id.slice("nav-status-".length));
const message = status?.messages.join(" ") ?? "";
if (notice.textContent !== message) notice.textContent = message;
notice.classList.toggle("is-error", status?.error === true);
notice.classList.toggle("is-ready", status?.ready === true && status?.error !== true);
}
const compact = element("settings-navigation-status");
const message = [...document.querySelectorAll(".nav-status")].map(output => output.textContent).filter(Boolean).join(" ");
if (compact.textContent !== message) compact.textContent = message;
}
function syncNavigationStatus(id) {
const { section } = SECTION_STATUSES[id];
if (section === activeSection) unseenSectionCompletions.delete(id);
renderNavigationStatuses();
}
function setSectionStatus(id, message, tone, completed = false) {
const output = element(id);
setStatusOutput(output, message, tone);
if (completed && SECTION_STATUSES[id].section !== activeSection) unseenSectionCompletions.add(id);
syncNavigationStatus(id);
}
// Whether an experimental flag that is off hides `section`.
function sectionGated(section) {
return EXPERIMENTAL_FEATURES.some(feature => feature.section === section && !options.experimental[feature.id]);
}
function requestedSection() {
const fragment = window.location.hash.slice(1);
return fragment === "settings-content" ? activeSection : fragment;
}
// Sections the host browser cannot offer are marked by configureBrowserUi().
function availableSections() {
return [...document.querySelectorAll("main > section:not([data-settings-unavailable='true'])")];
}
function sectionAvailable(id) {
return element(id)?.dataset.settingsUnavailable !== "true";
}
function resolveSection(requested) {
if (!availableSections().some((section) => section.id === requested)) return "dictionaries";
// A gated section leads to the switch that reveals it.
return sectionGated(requested) ? "advanced" : requested;
}
function showSettingsSection(focus = false) {
settingsSearch?.clear();
const sections = availableSections();
activeSection = resolveSection(requestedSection());
pendingManagementFocus = null;
for (const section of sections) section.hidden = section.id !== activeSection;
element("settings-section").value = activeSection;
const libraryActive = LIBRARY_SECTIONS.has(activeSection);
element("library-navigation").hidden = !libraryActive;
const primarySection = primaryNavigationSection(activeSection);
for (const link of document.querySelectorAll(".settings-nav a")) {
if (link.hash === `#${primarySection}`) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
for (const link of document.querySelectorAll("#library-navigation a")) {
if (libraryActive && link.hash === `#${activeSection}`) link.setAttribute("aria-current", "page");
else link.removeAttribute("aria-current");
}
if (Object.hasOwn(OPTION_SECTIONS, activeSection)) {
SECTION_STATUSES["options-status"] = { section: activeSection, label: OPTION_SECTIONS[activeSection] };
const slot = element(activeSection).querySelector(".options-feedback-slot");
if (element("options-feedback").parentElement !== slot) slot.append(element("options-feedback"));
}
for (const [id, { section }] of Object.entries(SECTION_STATUSES)) {
if (section === activeSection) unseenSectionCompletions.delete(id);
}
renderNavigationStatuses();
renderThemeChoices();
updateDesignPreview();
updateAudioSettings();
updateMediaSettings();
updateAnkiSettings();
updateKeybindSettings();
updateBackupSettings();
updateSharingSettings();
if (activeSection === "design") {
customButtonController ??= createCustomButtonSettings({ document,
readButtons: () => options.customButtons,
readTemplates: () => options.anki.templates,
saveButtons: buttons => {
options.customButtons = normaliseCustomButtons(buttons);
options.customLinks = options.customButtons.filter(button => button.type === "link")
.map(({ label, url }) => ({ label, url }));
writeOptions();
},
});
customButtonController.render();
}
if (activeSection === "custom-dictionary" && !customEditorLoaded) void loadCustomDictionarySource();
if (window.location.hash === "#settings-content") element("settings-content").focus();
else if (focus) element(activeSection).querySelector("h1").focus();
}
function updateAudioSettings() {
if (activeSection !== "audio") { audioController?.stop(); return; }
audioController ??= createAudioSettingsController({
document,
readSources: () => options.audioSources,
editSources: sources => {
options.audioSources = sources;
writeOptions();
},
send: (type, fields) => send(type, fields, AUDIO_TARGET),
});
audioController.render();
}
function updateKeybindSettings() {
if (activeSection !== "keybinds" || optionsRevision < 0) return;
keybindController ??= createKeybindSettingsController({ document,
readKeybinds: () => options.keybinds,
editKeybinds: keybinds => { options.keybinds = keybinds; writeOptions(); },
readAudioSources: () => options.audioSources,
getBrowserCommands: () => chrome.commands.getAll(),
// Firefox refuses tabs.create for privileged about: URLs, so it exposes
// the Manage Extension Shortcuts view through commands instead.
openBrowserShortcuts: () => (IS_FIREFOX
? chrome.commands.openShortcutSettings()
: chrome.tabs.create({ url: "chrome://extensions/shortcuts" })),
browserShortcutsAvailable: HOST_CAPABILITIES.browserShortcuts,
});
keybindController.render();
}
function updateAnkiSettings() {
if (activeSection !== "anki" || optionsRevision < 0) return;
ankiController ??= createAnkiTemplateSettingsController({ document, readAnki: () => options.anki,
capabilities: MINING_CAPABILITIES,
readButtons: () => options.customButtons,
editAnki: anki => {
options.anki = anki;
customButtonController?.render();
writeOptions();
},
send: async (type, fields) => {
// Linked checks cannot forward draft endpoint credentials or mappings.
// Commit them to the host first, then let the host read its saved copy.
if (["hd_anki_discover", "hd_anki_setup"].includes(type) && sharingLinkedAddress !== null) {
await flushOptionsUntilIdle();
}
return send(type, fields, WORKER_TARGET);
},
});
ankiController.render();
localAudioSetup ??= createLocalAudioSetup({ document, readSources: () => options.audioSources,
isLinked: () => sharingLinkedAddress !== null && !OVERLAY_MODE,
editSources: sources => { options.audioSources = sources; writeOptions(); },
});
localAudioSetup.render();
}
// While linked, archives and backups belong to the host; the notices say so.
function renderSharingLink(value) {
const wasLinked = sharingLinkedAddress !== null;
sharingLinkedAddress = typeof value?.client?.address === "string" ? value.client.address : null;
const linked = sharingLinkedAddress !== null;
localAudioSetup?.render();
element("sharing-overlay-preferences").hidden = !linked || !OVERLAY_MODE;
element("sharing-import-notice").hidden = !linked;
element("sharing-backup-notice").hidden = !linked;
element("import-drop-zone").hidden = linked;
for (const node of document.querySelectorAll("#backup > .backup-action, #backup > .section-note")) node.hidden = linked;
element("automatic-backups").hidden = linked;
if (wasLinked && !linked) {
void backupController?.refreshAutomaticBackups();
}
}
// Save the pinned release through a blob download, including in Electron hosts.
async function downloadAnkiAddon() {
const archive = await fetchAnkiAddon();
downloadBlob(document, archive, ANKI_ADDON_FILE_NAME);
}
function updateSharingSettings() {
if (activeSection !== "sharing") { sharingController?.stop(); return; }
sharingController ??= createSharingSettingsController({ document,
send: (type, fields) => send(type, fields, SHARING_TARGET),
setStatus: (message, tone) => setSectionStatus("sharing-status", message, tone),
downloadAddon: downloadAnkiAddon,
});
sharingController.start();
}
function renderMediaSettings() {
const capture = options.mediaCapture;
element("media-overlay-help").hidden = HOST_CAPABILITIES.mediaCapture;
element("media-heading-help").textContent = HOST_CAPABILITIES.mediaCapture
? "Optional local recording for Anki notes. Changes save automatically."
: "Saved Chrome recorder settings, inactive in this overlay.";
element("media-browser-help").hidden = !HOST_CAPABILITIES.mediaCapture;
element("media-template-help").hidden = !HOST_CAPABILITIES.mediaCapture;
const values = {
"opt-media-enabled": HOST_CAPABILITIES.mediaCapture && capture.enabled,
"opt-media-animation": capture.includeAnimation,
"opt-media-audio": capture.includeCapturedAudio,
"opt-media-native-cues": capture.page.nativeCues,
"opt-media-dom-text": capture.page.domText,
"opt-media-auto-area": capture.page.autoLearnArea,
"opt-media-texthooker": capture.texthooker.enabled,
};
for (const [id, checked] of Object.entries(values)) element(id).checked = checked;
const choices = {
"opt-media-history": capture.historySeconds,
"opt-media-clip": capture.clipSeconds,
"opt-media-preset": capture.videoPreset,
"opt-media-timing": capture.timingMode,
"opt-media-offset": capture.estimatedOffsetMs,
"opt-media-texthooker-url": capture.texthooker.url,
"opt-media-texthooker-format": capture.texthooker.format,
};
for (const [id, value] of Object.entries(choices)) {
const input = element(id);
if (input !== document.activeElement) input.value = String(value);
}
if (!HOST_CAPABILITIES.mediaCapture) {
for (const control of document.querySelectorAll("#media button, #media input, #media select")) {
control.disabled = true;
}
} else {
element("opt-media-auto-area").disabled = !capture.page.domText;
element("opt-media-texthooker-format").disabled = !capture.texthooker.enabled;
}
}
async function updateMediaSettings() {
if (activeSection !== "media") return;
renderMediaSettings();
const epoch = ++mediaStatusEpoch;
if (!HOST_CAPABILITIES.mediaCapture) {
mediaRuntimeState = "unavailable";
setStatusOutput(element("media-runtime-status"), "Media capture is unavailable in this overlay.");
return;
}
try {
const reply = await send("hd_capture_status", {}, CAPTURE_TARGET);
if (epoch !== mediaStatusEpoch) return;
if (!reply.ok) throw new Error(reply.error || "Capture page unavailable.");
const state = { recording: "Recording", disabled: "Disabled" }[reply.state] ?? "Stopped";
mediaRuntimeState = reply.state;
const source = reply.mediaSource?.name ? ` · ${reply.mediaSource.name}` : "";
const linked = reply.linkedPage?.title ? ` · linked to ${reply.linkedPage.title}` : "";
setStatusOutput(element("media-runtime-status"), `${state}${source}${linked}`,
reply.state === "recording" ? "ready" : undefined);
} catch {
if (epoch === mediaStatusEpoch) {
mediaRuntimeState = "unavailable";
setStatusOutput(element("media-runtime-status"),
"Capture page closed. Open it before starting a reading session.");
}
}
}
async function editMediaCapture(mutator, { immediate = false } = {}) {
if (!HOST_CAPABILITIES.mediaCapture) {
renderMediaSettings();
return false;
}
let recording = mediaRuntimeState === "recording";
if (!immediate) {
try {
const status = await send("hd_capture_status", {}, CAPTURE_TARGET);
recording = status.ok && status.state === "recording";
mediaRuntimeState = status.ok ? status.state : "unavailable";
} catch {
recording = false;
mediaRuntimeState = "unavailable";
}
}
const next = {
...options.mediaCapture,
texthooker: { ...options.mediaCapture.texthooker },
page: { ...options.mediaCapture.page },
};
mutator(next);
if (!immediate && recording
&& !window.confirm("Changing media capture settings stops the current capture and clears unsubmitted clips. Apply this change?")) {
renderMediaSettings();
return false;
}
options.mediaCapture = next;
renderMediaSettings();
writeOptions();
return true;
}
async function toggleExperimental(id, enabled) {
// Media capture keeps its own switch and settings; only the recorder itself
// must not stay active behind a hidden section.
if (id === "mediaMining" && !enabled && HOST_CAPABILITIES.mediaCapture && options.mediaCapture.enabled
&& !await editMediaCapture(capture => { capture.enabled = false; })) {
renderExperimentalSettings();
return;
}
options.experimental = { ...options.experimental, [id]: enabled };
renderExperimentalSettings();
writeOptions();
}
function renderExperimentalSettings() {
// A flag whose section this browser cannot offer (Firefox has no media
// capture) is not listed, and a stored value cannot reveal that section.
const features = EXPERIMENTAL_FEATURES.filter(feature => !feature.section || sectionAvailable(feature.section));
experimentalController ??= createExperimentalSettings({
document, features, onToggle: (id, enabled) => { void toggleExperimental(id, enabled); },
});
experimentalController.render(options.experimental);
for (const feature of EXPERIMENTAL_FEATURES) {
if (!feature.section) continue;
const hidden = !options.experimental[feature.id] || !sectionAvailable(feature.section);
document.querySelector(`.settings-nav a[href="#${feature.section}"]`).parentElement.hidden = hidden;
element("settings-section").querySelector(`option[value="${feature.section}"]`).hidden = hidden;
}
renderImportPicker();
// A flag that changed elsewhere can hide the visible section, or reveal the
// one this page was opened on before the stored options arrived.
if (resolveSection(requestedSection()) !== activeSection) showSettingsSection();
}
// With the MDX dictionaries flag on, the picker and drop zone also take .mdx
// and .mdd files; off, they take Yomitan ZIP files as before.
function renderImportPicker() {
const mdx = options.experimental.mdxImport === true;
element("import-file").accept = mdx ? ".zip,application/zip,.mdx,.mdd" : ".zip,application/zip";
element("import-file-label").textContent = mdx ? "Choose dictionary files" : "Choose ZIP files";
element("import-drop-hint").textContent = mdx
? "Or drag and drop Yomitan ZIP files, or an MDX dictionary with its MDD files, here."
: "Or drag and drop Yomitan ZIP files here.";
}
function updateBackupSettings() {
if (activeSection !== "backup") return;
backupController ??= createBackupSettingsController({
document, send,
download: typeof chrome.downloads?.download === "function"
? () => send("hd_backup_download", {}, WORKER_TARGET) : null,
browserName: HOST_BROWSER === "firefox" ? "Firefox" : "Chrome",
listAutomatic: () => send("hd_backup_auto_list", {}, WORKER_TARGET),
trackPreparation: trackBackupPreparation,
cancelPreparation(token) {
if (backupLifecycleTokens.has(token)) postBackupLifecycle({ type: "cancel", token });
},
checkReady() {
if (importing || installingRecommended || updating || removing || committing || customLoading || customSaving || pendingDictionaryCommits > 0) {
throw new Error("Wait for the current dictionary operation to finish, then try again.");
}
if (customDictionaryDirty() || customButtonController?.dirty() || ankiController?.dirty()
|| savingOptions !== null || optionsEditRevision !== null
|| Object.keys(pendingOptions).length > 0 || savingSchedule !== null || pendingSchedule !== null
|| nameDrafts.hasPendingChanges()) {
throw new Error("Save or discard your pending changes before working with a backup.");
}
},
setBusy(value) { backingUp = value; setControlsDisabled(importing); },
status: (message, tone, completed) => setSectionStatus("backup-status", message, tone, completed),
async refresh() {
const stored = await chrome.storage.local.get(["options", "dictionaryUpdates", CUSTOM_DICTIONARY_SOURCE_KEY]);
adoptOptions(stored.options);
adoptUpdateSettings(stored.dictionaryUpdates);
adoptCustomDictionaryDocument(stored[CUSTOM_DICTIONARY_SOURCE_KEY]);
await reloadDictionaries();
await refreshStatus();
},
});
}
function connectBackupLifecycle() {
const port = chrome.runtime.connect({ name: BACKUP_LIFECYCLE_PORT });
backupLifecyclePort = port;
port.onDisconnect.addListener(() => {
if (backupLifecyclePort !== port) return;
backupLifecyclePort = null;
if (backupLifecycleTokens.size === 0 || backupLifecycleReconnectTimer !== null) return;
backupLifecycleReconnectTimer = window.setTimeout(() => {
backupLifecycleReconnectTimer = null;
if (backupLifecyclePort !== null || backupLifecycleTokens.size === 0) return;
try { connectBackupLifecycle(); } catch { /* A later ownership change retries. */ }
}, 250);
});
try {
for (const token of backupLifecycleTokens) {
port.postMessage({ type: "track", token, active: true });
}
} catch (error) {
if (backupLifecyclePort === port) backupLifecyclePort = null;
throw error;
}
return port;
}
function postBackupLifecycle(message) {
for (let attempt = 0; attempt < 2; attempt += 1) {
let port = backupLifecyclePort;
try {
if (port === null) port = connectBackupLifecycle();
port.postMessage(message);
return true;
} catch {
if (backupLifecyclePort === port) backupLifecyclePort = null;
}
}
return false;
}
function trackBackupPreparation(token, active) {
if (active) {
backupLifecycleTokens.add(token);
postBackupLifecycle({ type: "track", token, active: true });
return;
}
backupLifecycleTokens.delete(token);
if (backupLifecycleReconnectTimer !== null && backupLifecycleTokens.size === 0) {
window.clearTimeout(backupLifecycleReconnectTimer);
backupLifecycleReconnectTimer = null;
}
if (backupLifecyclePort !== null) {
postBackupLifecycle({ type: "track", token, active: false });
}
}
function updateDesignPreview() {
if (activeSection !== "design") return;
let frame = element("design-preview");
if (!frame) {
frame = document.createElement("iframe");
frame.id = "design-preview";
frame.title = "Live dictionary popup preview";
frame.addEventListener("load", updateDesignPreview);
frame.src = "design-preview.html";
element("preview-canvas").append(frame);
resizeDesignPreview();
element("preview-size").addEventListener("change", resizeDesignPreview);
if (typeof ResizeObserver === "function") {
new ResizeObserver(resizeDesignPreview).observe(element("preview-viewport"));
}
}
if (frame.style.width !== `${options.popupWidthPx * options.popupScalePercent / 100 + 96}px`
|| frame.style.height !== `${options.popupHeightPx * options.popupScalePercent / 100 + 216}px`) resizeDesignPreview();
frame.contentWindow.HDDesignPreview?.update(options, dictionaryState);
}
function resizeDesignPreview() {
const viewport = element("preview-viewport");
const frame = element("design-preview");
const width = options.popupWidthPx * options.popupScalePercent / 100 + 96;
const height = options.popupHeightPx * options.popupScalePercent / 100 + 216;
const scale = element("preview-size").value === "actual" ? 1 : Math.min(1, viewport.clientWidth / width);
frame.style.width = `${width}px`;
frame.style.height = `${height}px`;
frame.style.transform = `scale(${scale})`;
element("preview-canvas").style.width = `${width * scale}px`;
element("preview-canvas").style.height = `${height * scale}px`;
}
function attachSettingsNavigation() {
settingsSearch = createSettingsSearch({ document, navigate(section) {
if (section && window.location.hash !== `#${section}`) window.history.pushState(null, "", `#${section}`);
showSettingsSection();
} });
const picker = element("settings-section");
picker.addEventListener("change", (event) => {
const fragment = `#${event.target.value}`;
// Native fragment navigation moves focus off the select before hashchange.
// Preserve arrow-key selection while adding the section to browser history.
if (window.location.hash !== fragment) window.history.pushState(null, "", fragment);
showSettingsSection();
});
window.addEventListener("hashchange", () => showSettingsSection(document.activeElement !== picker));
document.querySelector(".skip-link").addEventListener("click", (event) => {
event.preventDefault();
element("settings-content").focus();
});
for (const link of document.querySelectorAll(".settings-nav a, #library-navigation a, .section-action")) {
link.addEventListener("click", (event) => {
if (link.hash === window.location.hash
&& event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
// The native same-fragment action would move focus back to the section
// after our heading focus. Modified clicks retain their browser action.
event.preventDefault();
showSettingsSection(true);
}
});
}
showSettingsSection();
}
function describe(error) {
if (error instanceof Error) {
return error.message || String(error);
}
return typeof error === "string" ? error : JSON.stringify(error);
}
async function send(type, fields = {}, target = TARGET) {
requestCounter += 1;
const reply = await chrome.runtime.sendMessage({
target,
type,
requestId: `${type.replace(/^hd_/, "")}-${requestCounter}`,
...fields,
});
if (!reply) {
throw new Error("the extension's service worker did not reply");
}
return reply;
}
function nonnegativeCount(value) {
const count = Math.trunc(Number(value));
return Number.isFinite(count) && count > 0 ? count : 0;
}
function stringValue(value, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function nonemptyString(value) {
return typeof value === "string" && value !== "" ? value : null;
}
function displayName(value) {
const name = stringValue(value).trim();
return name === "" ? null : name;
}
function normaliseDictionary(row) {
const title = stringValue(row?.title);
if (title === "") {
return null;
}
const sourceId = nonemptyString(row?.sourceId);
return {
...(row && typeof row === "object" && !Array.isArray(row) ? row : {}),
id: stringValue(row?.id),
title,
displayName: displayName(row?.displayName),
path: nonemptyString(row?.path) ?? `/dicts/${title}`,
enabled: row?.enabled !== false,
favorite: row?.favorite === true,
revision: stringValue(row?.revision),
isUpdatable: row?.isUpdatable === true,
indexUrl: nonemptyString(row?.indexUrl),
downloadUrl: nonemptyString(row?.downloadUrl),
language: nonemptyString(row?.language),
frequencyMode: nonemptyString(row?.frequencyMode),
termCount: nonnegativeCount(row?.termCount),
frequencyCount: nonnegativeCount(row?.frequencyCount),
pitchCount: nonnegativeCount(row?.pitchCount),
kanjiCount: nonnegativeCount(row?.kanjiCount),
mediaCount: nonnegativeCount(row?.mediaCount),
installedAt: stringValue(row?.installedAt),
lastUpdateCheck: row?.lastUpdateCheck ?? null,
...(row?.updateScheduleOverride === undefined ? {} : { updateScheduleOverride: row.updateScheduleOverride }),
...(sourceId === null ? {} : { sourceId }),
};
}
function normaliseDictionaries(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map(normaliseDictionary).filter((entry) => entry !== null);
}
function normaliseDictionaryState(value) {
if (value?.schemaVersion !== 1) {
throw new Error(`Unsupported dictionary state schema ${String(value?.schemaVersion)}`);
}
const revision = Number.isInteger(value?.revision) && value.revision >= 0 ? value.revision : 0;
const dictionaries = normaliseDictionaries(value?.dictionaries);
return {
schemaVersion: 1,
revision,
dictionaries,
groups: normaliseDictionaryGroups(value?.groups, dictionaries),
};
}
function adoptDictionaryState(value) {
const next = normaliseDictionaryState(value);
if (next.revision <= dictionaryState.revision) {
return false;
}
dictionaryState = next;
dictionaries = dictionaryState.dictionaries;
pruneDictionarySelection();
return true;
}
function adoptUpdateSettings(value) {
const next = normaliseUpdateSettings(value);
if (next.revision <= updateSettings.revision) return false;
const changedSchedule = next.schedule !== updateSettings.schedule;
updateSettings = next;
if (changedSchedule) refreshDictionarySchedules();
return true;
}
function pruneDictionarySelection() {
const installedIds = new Set(dictionaries.map((dictionary) => dictionary.id));
for (const id of selectedDictionaryIds) {
if (!installedIds.has(id)) {
selectedDictionaryIds.delete(id);
}
}
}
function normaliseDictionarySearch(value) {
return stringValue(value).normalize("NFKC").trim().toLowerCase();
}
function visibleDictionaries() {
const search = normaliseDictionarySearch(dictionarySearch);
if (search === "") {
return dictionaries;
}
return dictionaries.filter((dictionary) =>
[dictionary.title, dictionary.displayName].some((name) =>
normaliseDictionarySearch(name).includes(search)));
}
function hasCapability(dictionary, kind) {
if (kind === "freq") return dictionary.frequencyCount > 0;
if (kind === "pitch") return dictionary.pitchCount > 0;
if (kind === "kanji") return dictionary.kanjiCount > 0;
if (dictionary.termCount > 0) return true;
return dictionary.frequencyCount === 0 && dictionary.pitchCount === 0 && dictionary.kanjiCount === 0;
}
function dictionaryLabel(dictionary) {
return dictionary.displayName || dictionary.title;
}
function isManagedCustomDictionary(dictionary) {
return dictionary?.id === CUSTOM_DICTIONARY_ID;
}
function selectionParts(value) {
if (value && typeof value === "object") {
return value;
}
return typeof value === "string" && value !== ""
? { title: value, kind: "" }
: null;
}
function selectionValue(selection) {
return selection ? JSON.stringify(selection) : "";
}
function selectionFromValue(value) {
if (!value) {
return "";
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") {
return normaliseKanjiSelection(parsed);
}
} catch {
// Legacy title-only values are not JSON.
}
return normaliseKanjiSelection(value);
}
function isAvailableFrequencyDictionary(dictionary) {
return dictionary.enabled !== false && hasCapability(dictionary, "freq");
}
function selectedFrequencyDictionary(title = options.frequencyDictionary) {
return dictionaries.find((dictionary) => dictionary.title === title
&& isAvailableFrequencyDictionary(dictionary));
}
function selectedDefinitionBlurFrequencyDictionary(title = options.definitionBlurFrequencyDictionary) {
return dictionaries.find((dictionary) => dictionary.title === title
&& isAvailableFrequencyDictionary(dictionary));
}
function normaliseDictionarySelections() {
let changed = false;
const kanjiSelection = selectionParts(options.kanjiClickDictionary);
if (kanjiSelection) {
const selected = dictionaries.find((entry) => entry.title === kanjiSelection.title);
const requestedKind = kanjiSelection.kind || (selected && hasCapability(selected, "kanji") ? "kanji" : "term");
if (!selected || selected.enabled === false || !hasCapability(selected, requestedKind)) {
options.kanjiClickDictionary = "";
changed = true;
} else if (kanjiSelection.kind === "") {
options.kanjiClickDictionary = { title: kanjiSelection.title, kind: requestedKind };
changed = true;
}
}
return changed;
}
function setStatus(message, tone) {
const status = element("engine-status");
status.textContent = message;
status.classList.toggle("is-error", tone === "error");
status.classList.toggle("is-ready", tone === "ready");
}
function setImportState(message, tone) {
setSectionStatus("import-state", message, tone, tone === "ready");
}
function setUpdateState(message, tone = "") {
setSectionStatus("update-state", message, tone, tone === "ready");
}
function setCustomDictionaryStatus(message, tone = "", completed = false) {
setSectionStatus("custom-dictionary-status", message, tone, completed);
}
function renderCustomDictionaryErrors(errors) {
const list = element("custom-dictionary-errors");
const messages = (Array.isArray(errors) ? errors : []).map((error) =>
`Line ${String(error?.lineNumber)}: ${stringValue(error?.reason, "invalid entry")}`);
if (list.childElementCount === messages.length
&& messages.every((message, index) => list.children[index].textContent === message)) {
return;
}
const items = document.createDocumentFragment();
for (const message of messages) {
const item = document.createElement("li");
item.textContent = message;
items.appendChild(item);
}
list.replaceChildren(items);
list.hidden = list.childElementCount === 0;
}
function customDictionaryDirty() {
return customEditorLoaded
&& customBaseDocument !== null
&& element("custom-dictionary-source").value !== customBaseEditorText;
}
function customDictionaryDraftSource() {
// Textareas expose LF-normalized text; restore the document's newline only on save.
const source = element("custom-dictionary-source").value;
return customDraftNewline === "\r\n" ? source.replaceAll("\n", "\r\n") : source;
}
function renderCustomDictionaryControls() {
const busy = importing || installingRecommended || updating || removing || committing || customLoading || customSaving || backingUp;
const source = element("custom-dictionary-source");
source.disabled = busy || !customEditorLoaded;
element("custom-dictionary-save").disabled = busy
|| !customDictionaryDirty()
|| customDraftStale;
element("custom-dictionary-reload").disabled = busy;
}
function cancelCustomDictionaryValidation() {
clearTimeout(customValidationTimer);
customValidationTimer = null;
}
function renderCustomDictionaryValidation(source = element("custom-dictionary-source").value) {
cancelCustomDictionaryValidation();
const parsed = parseCustomDictionary(source);
renderCustomDictionaryErrors(parsed.errors);
return parsed;
}
function resetCustomDictionaryDraft(documentValue) {
customBaseDocument = documentValue;
customDraftStale = false;
customDraftNewline = documentValue.text.includes("\r\n") ? "\r\n" : "\n";
element("custom-dictionary-source").value = documentValue.text;
customBaseEditorText = element("custom-dictionary-source").value;
renderCustomDictionaryValidation();
renderCustomDictionaryControls();
}
function markCustomDictionaryStale() {
customDraftStale = true;
setCustomDictionaryStatus(
"The custom dictionary source changed elsewhere. Reload the saved source before saving.",
"error",
);
renderCustomDictionaryControls();
}
function adoptCustomDictionaryDocument(value) {
const next = normaliseCustomDictionaryDocument(value);
if (customDocument !== null && next.revision <= customDocument.revision) {
return false;
}
const preserveDraft = customEditorLoaded
&& (customDictionaryDirty() || customDraftStale || customSaving);
customDocument = next;
if (!customEditorLoaded) {
return true;
}
if (preserveDraft) {
if (customBaseDocument === null || next.revision > customBaseDocument.revision) {
markCustomDictionaryStale();
}
} else {
resetCustomDictionaryDraft(next);
setCustomDictionaryStatus("Loaded the newest saved source.", "ready");
}
return true;
}
function adoptCustomDictionaryState(value) {
if (value === null || value === undefined) return;
if (adoptDictionaryState(value)) {
renderChangedDictionaryState();
}
}
async function loadCustomDictionarySource() {
if (customLoading || customSaving) return;
cancelCustomDictionaryValidation();
customLoading = true;
setCustomDictionaryStatus("Loading the saved custom dictionary source…");
renderCustomDictionaryControls();
try {
const reply = await send("hd_custom_read", {}, WORKER_TARGET);
if (!reply.ok || reply.document === undefined) {
throw new Error(reply.error || "the custom dictionary source could not be read");
}
adoptCustomDictionaryDocument(reply.document);
adoptCustomDictionaryState(reply.state);
if (customDocument === null) {
throw new Error("the custom dictionary source reply was empty");
}
customEditorLoaded = true;
resetCustomDictionaryDraft(customDocument);
setCustomDictionaryStatus(`Loaded source revision ${customDocument.revision}.`, "ready", true);
} catch (error) {
setCustomDictionaryStatus(`Could not load the custom dictionary source: ${describe(error)}`, "error");
} finally {
customLoading = false;
syncNavigationStatus("custom-dictionary-status");
renderCustomDictionaryControls();
}
}
function customDictionarySavedMessage(reply, validCount, errorCount) {
let message;
if (reply.removed === true) {
message = "Saved the source and removed the custom dictionary because it has no valid entries.";
} else if (reply.rebuilt === false) {
message = `Saved ${validCount} valid ${validCount === 1 ? "entry" : "entries"} without rebuilding.`;
} else {
message = `Saved ${validCount} valid ${validCount === 1 ? "entry" : "entries"} and rebuilt the custom dictionary.`;
}
if (errorCount > 0) {
message += ` Skipped ${errorCount} malformed ${errorCount === 1 ? "line" : "lines"}.`;
}
return message;
}
async function saveCustomDictionarySource(event) {
event.preventDefault();
if (!customEditorLoaded || customLoading || customSaving || !customDictionaryDirty()) {
return;
}
if (customDraftStale) {
markCustomDictionaryStale();
return;
}
const source = customDictionaryDraftSource();
const parsed = renderCustomDictionaryValidation(source);
const pending = {
baseRevision: customBaseDocument.revision,
source,
parsed,
editorText: element("custom-dictionary-source").value,
};
customSaving = true;
setCustomDictionaryStatus("Saving and compiling the custom dictionary…");
setControlsDisabled(importing);
try {
const reply = await send("hd_custom_save", {
baseDocumentRevision: pending.baseRevision,
text: pending.source,
});
if (reply.document !== undefined) {
adoptCustomDictionaryDocument(reply.document);
}
adoptCustomDictionaryState(reply.state);
renderCustomDictionaryErrors(reply.errors ?? pending.parsed.errors);
if (!reply.ok) {
if (reply.stale === true
|| (customDocument !== null && customDocument.revision > pending.baseRevision)) {
markCustomDictionaryStale();
}
setCustomDictionaryStatus(
`Could not save the custom dictionary: ${reply.error || "the source changed elsewhere"}`,
"error",
);
return;
}
const saved = normaliseCustomDictionaryDocument(reply.document);
if (saved.text !== pending.source) {
throw new Error("the saved custom dictionary source did not match the submitted draft");
}
customBaseDocument = saved;
customBaseEditorText = pending.editorText;
const newerDocumentExists = customDocument !== null
&& (customDocument.revision > saved.revision
|| customDocument.text !== saved.text
|| customDocument.semanticRevision !== saved.semanticRevision);
customDraftStale = newerDocumentExists;
if (newerDocumentExists) {
setCustomDictionaryStatus(
"Saved this draft, but the source changed again elsewhere. Reload before saving.",
"error",
);
} else {
setCustomDictionaryStatus(
customDictionarySavedMessage(reply, pending.parsed.entries.length, pending.parsed.errors.length),
"ready",
true,
);
}
} catch (error) {
setCustomDictionaryStatus(`Could not save the custom dictionary: ${describe(error)}`, "error");
} finally {
customSaving = false;
syncNavigationStatus("custom-dictionary-status");
setControlsDisabled(importing);
}
}
function isUpdateCheckable(dictionary) {
return managedDictionarySource(dictionary) !== null;
}
function availableUpdates() {
return dictionaries.filter((dictionary) =>
isUpdateCheckable(dictionary) && dictionary.lastUpdateCheck?.status === "update-available");
}
function renderUpdateControls() {
const schedule = element("update-schedule");
const value = pendingSchedule?.schedule ?? savingSchedule?.schedule ?? updateSettings.schedule;
if (schedule.value !== value) schedule.value = value;
element("update-schedule-conflict-actions").hidden = !scheduleSaveFailed;
const checked = updateSettings.lastCheckedAt === null
? null
: new Date(updateSettings.lastCheckedAt);
element("update-last-checked").textContent = checked !== null && !Number.isNaN(checked.getTime())
? `Last checked ${checked.toLocaleString()}.`
: "Never checked.";
const busy = updating || importing || installingRecommended || removing || committing || customSaving || backingUp;
element("update-all").disabled = busy || availableUpdates().length === 0;
element("update-check-now").disabled = busy;
schedule.disabled = busy || updateSettings.revision < 0;
}
function refreshDictionarySchedules() {
const byId = new Map(dictionaries.map(dictionary => [dictionary.id, dictionary]));
for (const row of document.querySelectorAll(".dict-row")) {
const entry = byId.get(row.dataset.dictionaryId);
if (entry) renderDictionarySchedule(row, entry);
}
}
function clearImportResults() {
importProgressView().clear();
}
function importProgressView() {
if (importProgress) return importProgress;
importProgress = createDictionaryProgressList({
document,
ariaLabel: "Dictionary import progress",
idPrefix: "settings-import",
});
element("import-progress").appendChild(importProgress.element);
return importProgress;
}
function setImportEntries(entries) {
importProgressView().setEntries(entries);
}
function updateImportResult(index, state) {
importProgressView().update(String(index), state);
}
function importDuration(started) {
return formatSeconds(Math.max(0, (Date.now() - started) / 1000));
}
function renderRecommendedCatalogue() {
const { count, topics } = describeRecommendedCatalogue();
element("recommended-dictionaries-hint").textContent =
`${count[0].toUpperCase()}${count.slice(1)} trusted sources for ${topics}. Already installed sources are skipped.`;
const list = element("recommended-dictionary-list");
for (const entry of RECOMMENDED_DICTIONARIES) {
const item = document.createElement("li");
const link = document.createElement("a");
link.className = "recommended-dictionary-link";
link.href = entry.publisherUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = entry.name;
const description = document.createElement("span");
description.textContent = entry.description;
item.append(link, description);
list.appendChild(item);
}
}
function missingRecommendedDictionaries() {
return RECOMMENDED_DICTIONARIES.filter((entry) => !recommendedDictionaryInstalled(entry, dictionaries));
}
function renderRecommendedActions() {
const missing = missingRecommendedDictionaries();
element("recommended-starter").hidden = missing.length === 0;
element("install-recommended").hidden = missing.length < RECOMMENDED_DICTIONARIES.length;
element("recommended-retry").hidden =
missing.length === 0 || missing.length === RECOMMENDED_DICTIONARIES.length;
}
function setControlsDisabled(disabled) {
const blocked = disabled || installingRecommended || removing || updating || customSaving || backingUp;
const importBlocked = blocked || committing;
element("import-file").disabled = importBlocked;
element("import-drop-zone").setAttribute("aria-disabled", String(importBlocked));
if (importBlocked) {
importDragDepth = 0;
element("import-drop-zone").classList.remove("is-dragging");
}
element("install-recommended").disabled = blocked || committing;
element("retry-recommended").disabled = blocked || committing;
element("empty-install-recommended").disabled = blocked || committing;
element("empty-import-dictionaries").disabled = blocked || committing;
for (const control of document.querySelectorAll(".dict-row select, .dict-row input, .dict-row button")) {
control.disabled = blocked || control.dataset.pinnedDisabled === "true"
|| (committing && control.classList.contains("dict-update-schedule"));
}
for (const drag of document.querySelectorAll(".dict-drag")) {
drag.draggable = !blocked && drag.dataset.pinnedDisabled !== "true";
}
for (const control of document.querySelectorAll(
"#dict-group-create-form input, #dict-group-create-form button, #dict-group-list input, #dict-group-list select, #dict-group-list button",
)) {
control.disabled = blocked || control.dataset.pinnedDisabled === "true";
}
element("dict-select-visible").disabled = blocked || visibleDictionaries().length === 0;
for (const control of element("dict-controls").querySelectorAll(".dict-bulk-actions button")) {
control.disabled = blocked || selectedDictionaryIds.size === 0;
}
element("dict-bulk-remove").disabled = blocked || !selectedRemovableDictionaries().length;
renderUpdateControls();
renderCustomDictionaryControls();
}
function elapsedSince(started) {
const seconds = Math.round((Date.now() - started) / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
}
function scheduleStatusPoll(delay = STATUS_POLL_MS) {
if (statusTimer !== null) {
return;
}
statusTimer = setTimeout(() => {
statusTimer = null;
refreshStatus();
}, delay);
}
async function refreshStatus() {
let reply;
try {
reply = await send("hd_status");
} catch (error) {
lastEngineStatus = null;
// A poll can fail transiently: the service worker can be torn down mid-relay,
// or the offscreen document can be recreated faster than background.js's
// retries. Keep polling, or one blip freezes this line on a stale error while
// the engine finishes booting and every lookup works.
setStatus(`Cannot reach the engine: ${describe(error)}`, "error");
scheduleStatusPoll(STATUS_RETRY_MS);
return;
}
if (!reply.ok) {
lastEngineStatus = null;
// Either a boot failure or background.js's relay giving up, and the two are
// not distinguishable from here, so retry both: a boot error survives the
// retry and keeps saying so.
setStatus(`Engine error: ${reply.error ?? "unknown"}`, "error");
scheduleStatusPoll(STATUS_RETRY_MS);
return;
}
lastEngineStatus = reply;
renderEngineStatus();
if (!reply.ready || reply.loading) {
scheduleStatusPoll();
}
}
function renderEngineStatus() {
if (lastEngineStatus === null) return;
const count = dictionaries.filter((entry) => entry.enabled !== false).length;
const failed = Array.isArray(lastEngineStatus.failedDictionaries) ? lastEngineStatus.failedDictionaries : [];
if (lastEngineStatus.ready && failed.length > 0) {
const subject = failed.length === 1 ? "1 dictionary" : `${numberFormat.format(failed.length)} dictionaries`;
const detail = failed.map(({ title, error }) => `${title} (${error})`).join("; ");
setStatus(`Could not load ${subject}: ${detail}. Re-import or remove it; the other dictionaries still work.`, "error");
return;
}
if (lastEngineStatus.ready) {
if (count === 0 && !lastEngineStatus.loading) {
setStatus(dictionaries.length === 0
? "Ready to add your first dictionary."
: "Ready. Enable a dictionary in Library to start reading.");
return;
}
const enabled = count === 1 ? "1 dictionary enabled" : `${numberFormat.format(count)} dictionaries enabled`;
setStatus(lastEngineStatus.loading ? `Ready, ${enabled}, working…` : `Ready, ${enabled}.`, "ready");
} else {
setStatus("Starting the engine and loading dictionaries…");
}
}
function renderFrequencyChoices() {
const select = element("opt-frequency-dictionary");
if (select === document.activeElement) return;
const previous = options.frequencyDictionary;
select.textContent = "";
const automatic = document.createElement("option");
automatic.value = "";
automatic.textContent = "Any — automatic across all dictionaries";
select.appendChild(automatic);
const enabled = dictionaries.filter(isAvailableFrequencyDictionary);
const withFrequencies = new Set(
enabled.map((entry) => entry.title),
);
const groups = [{ label: "Frequency dictionaries", titles: [...withFrequencies] }];
for (const group of groups) {
if (group.titles.length === 0) {
continue;
}
const optgroup = document.createElement("optgroup");
optgroup.label = group.label;
for (const title of group.titles) {
const dictionary = enabled.find((entry) => entry.title === title);
const option = document.createElement("option");
option.value = title;
option.textContent = dictionary ? dictionaryLabel(dictionary) : title;
optgroup.appendChild(option);
}
select.appendChild(optgroup);
}
// Keep a removed selection visible rather than silently rewriting the option.
if (previous !== "" && !withFrequencies.has(previous)) {
const stale = document.createElement("option");
stale.value = previous;
stale.textContent = `${previous} (unavailable)`;
stale.disabled = true;
select.appendChild(stale);
}
select.value = previous;
}
function renderDefinitionBlurFrequencyChoices() {
const select = element("opt-blur-frequency-dictionary");
if (select === document.activeElement) return;
const previous = options.definitionBlurFrequencyDictionary;
select.disabled = !options.definitionBlurFrequencyEnabled;
select.replaceChildren(new Option("Choose an enabled frequency dictionary", ""));
const available = dictionaries.filter(isAvailableFrequencyDictionary);
for (const dictionary of available) select.add(new Option(dictionaryLabel(dictionary), dictionary.title));
if (previous !== "" && !available.some(dictionary => dictionary.title === previous)) {
const known = dictionaries.find(dictionary => dictionary.title === previous);
const status = known?.enabled === false ? "disabled" : "unavailable";
const stale = new Option(`${known ? dictionaryLabel(known) : previous} (${status})`, previous);
stale.disabled = true;
select.add(stale);
}
select.value = previous;
}
function renderCompactSummaryControls() {
const enabled = options.showCompactDefinitionSummary;
element("opt-compact-summary").checked = enabled;
const count = element("opt-summary-count");
// Disabling Chrome's focused select emits blur before its pending change.
// Keep that draft's captured revision until the existing focusout boundary.
if (count !== document.activeElement) count.disabled = !enabled;
renderPreferredDictionary("opt-summary-dictionary", options.compactDefinitionSummaryDictionary,
"term", "Automatic — first available definition", enabled);
}
// All blur rules use the shared reveal controls. The delay field shows
// seconds, fractions allowed, for the stored milliseconds.
function renderDefinitionBlurControls() {
const countEnabled = options.definitionBlurEnabled;
const ankiEnabled = options.definitionBlurAnkiMature;
const frequencyEnabled = options.definitionBlurFrequencyEnabled;
const enabled = countEnabled || ankiEnabled || frequencyEnabled;
for (const [id, checked] of [["opt-blur-count", countEnabled], ["opt-blur-anki", ankiEnabled],
["opt-blur-frequency", frequencyEnabled]]) element(id).checked = checked;
// Hiding a focused native control can emit blur before its pending change.
// Defer hiding until focusout so the change keeps its captured revision.
for (const [id, hidden] of [["definition-blur-count-controls", !countEnabled],
["definition-blur-frequency-controls", !frequencyEnabled], ["definition-blur-reveal-controls", !enabled],
["definition-blur-delay-control", options.definitionBlurReveal !== "timed"]]) {
const group = element(id);
if (!hidden || !group.contains(document.activeElement)) group.hidden = hidden;
}
element("definition-blur-count-paused").hidden = !countEnabled || options.showLookupCounts;
element("definition-blur-anki-help").hidden = !ankiEnabled;
element("definition-blur-any-help").hidden = [countEnabled, ankiEnabled, frequencyEnabled].filter(Boolean).length < 2;
element("definition-blur-help").hidden = !enabled;
renderDefinitionBlurFrequencyChoices();
for (const [id, key, controlEnabled] of [["opt-blur-direction", "definitionBlurDirection", countEnabled],
["opt-blur-frequency-order", "definitionBlurFrequencyOrder", frequencyEnabled],
["opt-blur-frequency-threshold", "definitionBlurFrequencyThreshold", frequencyEnabled],
["opt-blur-reveal", "definitionBlurReveal", enabled], ["opt-blur-threshold", "definitionBlurThreshold", countEnabled]]) {
const control = element(id);
if (control === document.activeElement) continue;
control.value = String(options[key]);
control.disabled = !controlEnabled;
}
const delay = element("opt-blur-delay");
if (delay !== document.activeElement) {
delay.value = String(options.definitionBlurDelayMs / 1000);
delay.disabled = !enabled || options.definitionBlurReveal !== "timed";
}
const frequencyHelp = element("definition-blur-frequency-help");
frequencyHelp.hidden = !frequencyEnabled;
if (frequencyEnabled) {
const selected = selectedDefinitionBlurFrequencyDictionary();
if (!options.definitionBlurFrequencyDictionary) {
frequencyHelp.textContent = "Choose one enabled frequency dictionary. Missing frequency data leaves this condition unqualified.";
} else if (!selected) {
frequencyHelp.textContent = "The saved frequency dictionary is unavailable. This condition fails open until it is enabled or reinstalled.";
} else {
const automatic = options.definitionBlurFrequencyOrder === "auto";
const order = automatic && selected.frequencyMode === "rank-based"
? "ascending" : automatic ? "descending" : options.definitionBlurFrequencyOrder;
const mode = automatic
? selected.frequencyMode === "rank-based" ? "rank-based metadata"
: selected.frequencyMode === "occurrence-based" ? "occurrence-based metadata" : "undeclared metadata"
: "your manual order";
frequencyHelp.textContent = order === "ascending"
? `Using ${mode}: values at or below the threshold qualify.`
: `Using ${mode}: values at or above the threshold qualify.`;
}
}
}
function renderPreferredDictionary(id, preferred, kind, automaticLabel, enabled) {
const select = element(id);
if (select === document.activeElement) return;
select.disabled = !enabled;
select.replaceChildren(new Option(automaticLabel, ""));
let available = preferred === "";
for (const dictionary of dictionaries) {
if (!hasCapability(dictionary, kind)) continue;
const label = dictionaryLabel(dictionary) + (dictionary.enabled === false ? " (disabled)" : "");
select.add(new Option(label, dictionary.title));
available ||= dictionary.title === preferred;
}
// Already-missing sources remain a soft preference, not a lookup filter.
if (!available) select.add(new Option(`${preferred} (unavailable)`, preferred));
select.value = preferred;
}
function renderMetadataControls() {
for (const field of METADATA_FIELDS) {
element(field.id).checked = field.inverted ? !options[field.key] : options[field.key];
}
renderDefinitionBlurControls();
renderPreferredDictionary("opt-pitch-dictionary", options.pitchAccentFuriganaDictionary,
"pitch", "Automatic — first available pitch", options.showPitchAccentFurigana);
}
function renderPopupImageSources() {
const select = element("opt-image-source");
if (select === document.activeElement) return;
const source = options.popupImageSource;
const previous = selectionValue(source);
select.replaceChildren(new Option("Automatic — current tab", ""));
let available = source === null;
function addSource(value, label) {
const encoded = selectionValue(value);
select.add(new Option(label, encoded));
available ||= encoded === previous;
}
for (const dictionary of dictionaries) {
addSource({ kind: "dictionary", title: dictionary.title },
`Dictionary: ${dictionaryLabel(dictionary)}${dictionary.enabled === false ? " (disabled)" : ""}`);
}
for (const group of dictionaryState.groups) {
addSource({ kind: "tabGroup", id: group.id }, `Group: ${group.name}`);
}
if (!available) addSource(source, `${source.title || source.id} (unavailable)`);
select.value = previous;
}
function renderFrequencyOrder() {
const order = element("opt-frequency-order");
if (order !== document.activeElement) order.value = options.frequencyOrder;
const selected = selectedFrequencyDictionary();
for (const choice of order.options) {
choice.disabled = !selected && (choice.value === "ascending" || choice.value === "descending");
}
element("opt-frequency-auto").disabled = !selected;
let hint;
if (options.frequencyOrder === "auto") hint = "Automatic compares all enabled frequency dictionaries in their listed order.";
else if (options.frequencyOrder === "disabled") hint = "Frequency sorting is off. Your dictionary choice is remembered.";
else if (!selected) hint = "Choose an available frequency dictionary to use this direction.";
else if (selected.frequencyMode === "rank-based") hint = "Rank-based: Auto puts the lowest numbers first.";
else if (selected.frequencyMode === "occurrence-based") hint = "Occurrence-based: Auto puts the highest numbers first.";
else hint = "No mode declared: Auto uses highest numbers first.";
const hintElement = element("frequency-order-hint");
if (hintElement.textContent !== hint) hintElement.textContent = hint;
}
function applyFrequencyDirection() {
const direction = selectedFrequencyDictionary()?.frequencyMode === "rank-based" ? "ascending" : "descending";
options.frequencyOrder = options.frequencyDictionary === "" ? "auto" : direction;
renderFrequencyOrder();
writeOptions();
}
function appendKanjiGroup(select, enabled, group, availableValues) {
if (group.titles.length === 0) {
return;
}
const optgroup = document.createElement("optgroup");
optgroup.label = group.label;
for (const title of group.titles) {
const dictionary = enabled.find((entry) => entry.title === title);
const option = document.createElement("option");
option.value = selectionValue({ title, kind: group.kind });
option.textContent = dictionary ? dictionaryLabel(dictionary) : title;
availableValues.add(option.value);
optgroup.appendChild(option);
}
select.appendChild(optgroup);
}
function selectedKanjiValue(previousSelection, withKanji, withTerms) {
if (previousSelection?.kind !== "") {
return selectionValue(previousSelection);
}
let kind = "";
if (withKanji.has(previousSelection.title)) {
kind = "kanji";
} else if (withTerms.has(previousSelection.title)) {
kind = "term";
}
return kind === ""
? previousSelection.title
: selectionValue({ title: previousSelection.title, kind });
}
function appendStaleKanjiChoice(select, previousSelection, selectedValue, availableValues) {
if (!previousSelection || availableValues.has(selectedValue)) {
return;
}
const stale = document.createElement("option");
stale.value = selectedValue;
stale.textContent = `${previousSelection.title} (not available)`;
select.appendChild(stale);
}
function renderKanjiChoices() {
const select = element("opt-kanji-dictionary");
const previousSelection = selectionParts(options.kanjiClickDictionary);
select.textContent = "";
const automatic = document.createElement("option");
automatic.value = "";
automatic.textContent = "Automatic — use every kanji dictionary";
select.appendChild(automatic);
const enabled = dictionaries.filter((entry) => entry.enabled !== false);
const withKanji = new Set(
enabled.filter((entry) => hasCapability(entry, "kanji")).map((entry) => entry.title),
);
const withTerms = new Set(
enabled.filter((entry) => hasCapability(entry, "term")).map((entry) => entry.title),
);
const groups = [
{ kind: "kanji", label: "Kanji dictionaries", titles: [...withKanji] },
{
kind: "term",
label: "Term dictionaries — requires a matching single-kanji entry",
titles: [...withTerms],
},
];
const availableValues = new Set();
for (const group of groups) {
appendKanjiGroup(select, enabled, group, availableValues);
}
const selectedValue = selectedKanjiValue(previousSelection, withKanji, withTerms);
appendStaleKanjiChoice(select, previousSelection, selectedValue, availableValues);
select.value = selectedValue;
}
function renderThemeChoices() {
if (activeSection !== "design") return;
const theme = element("opt-popup-theme");
if (theme.options.length === 0) {
for (const group of POPUP_THEME_GROUPS) {
const optgroup = document.createElement("optgroup");
optgroup.label = group.label;
for (const entry of group.themes) optgroup.append(new Option(entry.label, entry.id));
theme.append(optgroup);
}
}
if (theme !== document.activeElement) theme.value = options.popupTheme;
}
function renderCustomCss(force = false) {
const editor = element("opt-custom-popup-css");
if ((force || editor !== document.activeElement) && editor.value !== options.customPopupCss) {
editor.value = options.customPopupCss;
}
element("custom-css-count").textContent = `${numberFormat.format(editor.value.length)} characters`;
}
function renderCustomJavascript(force = false) {
const editor = element("opt-custom-popup-javascript");
if ((force || editor !== document.activeElement) && editor.value !== options.customPopupJavascript) {
editor.value = options.customPopupJavascript;
}
element("custom-javascript-count").textContent = `${numberFormat.format(editor.value.length)} characters`;
}
function renderOptions() {
applyPageTheme(document, options);
for (const field of NUMBER_FIELDS) {
const input = element(field.id);
if (input !== document.activeElement) {
input.value = String(options[field.key]);
}
}
element("opt-hover-enabled").checked = options.hoverEnabled;
element("opt-japanese-only").checked = options.onlyScanJapaneseText;
element("opt-source-highlight").checked = options.sourceHighlightEnabled;
element("opt-popup-audio-button").checked = options.showPopupAudioButton;
element("opt-audio-autoplay").checked = options.audioAutoplay;
renderThemeChoices();
renderCustomCss();
renderCustomJavascript();
customButtonController?.render();
const toolbar = element("opt-popup-toolbar");
if (toolbar !== document.activeElement) toolbar.value = options.popupToolbarPosition;
const mode = element("opt-lookup-mode");
if (mode !== document.activeElement) mode.value = options.lookupMode;
const activation = element("opt-activation-key");
if (activation.options.length === 0) {
for (const key of ACTIVATION_KEYS) activation.add(new Option(key, key));
}
if (activation !== document.activeElement) activation.value = options.activationKey;
renderFrequencyOrder();
renderKanjiChoices();
renderFrequencyChoices();
renderCompactSummaryControls();
renderPopupImageSources();
renderMetadataControls();
renderExperimentalSettings();
updateDesignPreview();
updateAudioSettings();
updateMediaSettings();
updateAnkiSettings();
updateKeybindSettings();
}
function addCountBadge(container, label, count) {
const badge = document.createElement("span");
badge.className = "dict-badge";
badge.dataset.capability = label.toLowerCase();
badge.classList.toggle("is-empty", count === 0);
badge.textContent = `${label} ${numberFormat.format(count)}`;
container.appendChild(badge);
}
function dictionaryMetadata(entry) {
const details = [];
if (entry.revision) {
details.push(`Revision ${entry.revision}`);
}
if (entry.language) {
details.push(entry.language);
}
if (entry.installedAt) {
const installed = new Date(entry.installedAt);
if (!Number.isNaN(installed.getTime())) {
details.push(`Imported ${installed.toLocaleString()}`);
}
}
details.push(`Package ID ${entry.id}`, isUpdateCheckable(entry) ? "Update source available" : "Local archive");
return details.join(" · ");
}
function dictionaryUpdateStatus(entry) {
if (!isUpdateCheckable(entry)) {
return { text: "Not update-checkable", tone: "" };
}
const check = entry.lastUpdateCheck;
if (check?.status === "up-to-date") {
return { text: "Up to date", tone: "ready" };
}
if (check?.status === "update-available") {
const revision = check.remoteRevision ? `: ${check.remoteRevision}` : "";
const failure = check.error ? ` · Update failed: ${check.error}` : "";
return { text: `Update available${revision}${failure}`, tone: "available" };
}
if (check?.status === "check-failed") {
return { text: `Check failed: ${check.error || "unknown error"}`, tone: "error" };
}
return { text: "Not checked", tone: "" };
}
function bindDictionaryUpdate(row, entry) {
const status = dictionaryUpdateStatus(entry);
const output = row.querySelector(".dict-update-status");
output.textContent = status.text;
output.hidden = !entry.lastUpdateCheck;
output.classList.toggle("is-ready", status.tone === "ready");
output.classList.toggle("is-available", status.tone === "available");
output.classList.toggle("is-error", status.tone === "error");
const update = row.querySelector(".dict-update");
update.hidden = entry.lastUpdateCheck?.status !== "update-available" || !isUpdateCheckable(entry);
update.setAttribute("aria-label", `Update ${dictionaryLabel(entry)}`);
update.title = `Update ${dictionaryLabel(entry)}`;
update.addEventListener("click", () => {
void runManagedUpdate("hd_updates_install", [entry.id]);
});
renderDictionarySchedule(row, entry);
const schedule = row.querySelector(".dict-update-schedule");
schedule.value = entry.updateScheduleOverride ?? "inherit";
schedule.setAttribute("aria-label", `Automatic updates for ${dictionaryLabel(entry)}`);
schedule.addEventListener("change", async () => {
const value = schedule.value === "inherit" ? null : schedule.value;
let stale = false;
await commitDictionaries(updateDictionary(entry.id, current => {
if ((current.updateScheduleOverride ?? null) !== (entry.updateScheduleOverride ?? null)
|| !isUpdateCheckable(current)) {
stale = true;
return current;
}
return value === (current.updateScheduleOverride ?? null) ? current : { ...current, updateScheduleOverride: value };
}), false);
if (stale) {
const current = dictionaries.find(dictionary => dictionary.id === entry.id);
if (current) {
schedule.value = current.updateScheduleOverride ?? "inherit";
renderDictionarySchedule(row, current);
}
setStatus("The dictionary schedule changed elsewhere. Review its current value before choosing again.", "error");
}
});
}
function renderDictionarySchedule(row, entry) {
row.querySelector(".dict-schedule").hidden = !isUpdateCheckable(entry);
const schedule = row.querySelector(".dict-update-schedule");
const effective = effectiveDictionarySchedule(entry, updateSettings.schedule);
const inherit = schedule.querySelector('[value="inherit"]');
const label = `Use default (${updateSettings.schedule})`;
if (inherit.textContent !== label) inherit.textContent = label;
const now = Date.now();
const due = nextDictionaryUpdateCheck(entry, updateSettings.schedule, now);
const output = row.querySelector(".dict-next-check");
let text = "Automatic updates off";
if (due !== null) {
const next = due <= now ? "Due now" : `Next check ${new Date(due).toLocaleString()}`;
text = `${effective.charAt(0).toUpperCase()}${effective.slice(1)} · ${next}`;
}
if (output.textContent !== text) output.textContent = text;
}
function updateItemById(current, id, update) {
const index = current.findIndex((entry) => entry.id === id);
if (index < 0) {
return null;
}
const replacement = update(current[index]);
if (replacement === current[index]) {
return null;
}
const next = [...current];
next[index] = replacement;
return next;
}
function updateDictionary(id, update) {
return (current) => updateItemById(current, id, update);
}
function moveListItem(values, index, target) {
if (index < 0 || target < 0 || target >= values.length || index === target) {
return null;
}
const next = [...values];
const [entry] = next.splice(index, 1);
next.splice(target, 0, entry);
return next;
}
function updateSelectedDictionaries(field, value, reloadEngine) {
const ids = new Set(selectedDictionaryIds);
void commitDictionaries((current) => {
let changed = false;
const next = current.map((dictionary) => {
if ((field === "enabled" && isManagedCustomDictionary(dictionary))
|| !ids.has(dictionary.id)
|| dictionary[field] === value) {
return dictionary;
}
changed = true;
return { ...dictionary, [field]: value };
});
return changed ? next : null;
}, reloadEngine);
}
function focusedManagementControl() {
const active = document.activeElement;
const dictionaryRow = active?.closest?.(".dict-row");
if (dictionaryRow?.dataset.dictionaryId) {
const controlClass = [
"dict-selected",
"dict-details-toggle",
"dict-display-name",
"dict-enabled",
"dict-up",
"dict-down",
"dict-position-input",
"dict-move",
"dict-update",
"dict-update-schedule",
"dict-remove",
].find((name) => active.classList.contains(name));
return controlClass
? { kind: "dictionary", id: dictionaryRow.dataset.dictionaryId, controlClass }
: null;
}
const groupRow = active?.closest?.(".dict-group");
if (!groupRow?.dataset.groupId) return null;
const memberRow = active.closest(".dict-group-member");
const controlClasses = memberRow
? ["dict-group-member-up", "dict-group-member-down", "dict-group-member-remove"]
: ["dict-group-name", "dict-group-up", "dict-group-down", "dict-group-delete", "dict-group-add-select", "dict-group-add"];
const controlClass = controlClasses.find((name) => active.classList.contains(name));
if (!controlClass) return null;
const groupRows = [...groupRow.parentElement.children];
const focus = {
kind: memberRow ? "group-member" : "group",
groupId: groupRow.dataset.groupId,
groupIndex: groupRows.indexOf(groupRow),
controlClass,
};
if (memberRow) {
focus.dictionaryId = memberRow.dataset.dictionaryId;
focus.memberIndex = [...memberRow.parentElement.children].indexOf(memberRow);
}
return focus;
}
function renderDictionarySelection(visible) {
const visibleSelected = visible.filter((dictionary) => selectedDictionaryIds.has(dictionary.id)).length;
const selectVisible = element("dict-select-visible");
selectVisible.checked = visible.length > 0 && visibleSelected === visible.length;
selectVisible.indeterminate = visibleSelected > 0 && visibleSelected < visible.length;
element("dict-selection-count").textContent = `${selectedDictionaryIds.size} selected`;
element("dict-bulk-actions").hidden = selectedDictionaryIds.size === 0;
element("dict-match-count").textContent = `${visible.length} of ${dictionaries.length}`;
}
function clearDictionaryDropTargets() {
for (const row of document.querySelectorAll("#dict-list .is-drop-target")) {
row.classList.remove("is-drop-target");
}
}
function bindDictionarySelection(row, entry) {
const selected = row.querySelector(".dict-selected");
selected.checked = selectedDictionaryIds.has(entry.id);
selected.setAttribute("aria-label", `Select ${dictionaryLabel(entry)}`);
selected.addEventListener("change", () => {
if (selected.checked) {
selectedDictionaryIds.add(entry.id);
} else {
selectedDictionaryIds.delete(entry.id);
}
renderDictionarySelection(visibleDictionaries());
setControlsDisabled(importing);
});
}
function bindDictionaryDrag(row, entry) {
const drag = row.querySelector(".dict-drag");
drag.title = `Drag ${dictionaryLabel(entry)} to reorder`;
if (isManagedCustomDictionary(entry)) {
drag.dataset.pinnedDisabled = "true";
drag.draggable = false;
return;
}
drag.addEventListener("dragstart", (event) => {
draggedDictionaryId = entry.id;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", entry.id);
}
});
drag.addEventListener("dragend", () => {
draggedDictionaryId = null;
clearDictionaryDropTargets();
});
row.addEventListener("dragover", (event) => {
if (draggedDictionaryId && draggedDictionaryId !== entry.id) {
event.preventDefault();
clearDictionaryDropTargets();
row.classList.add("is-drop-target");
}
});
row.addEventListener("dragleave", () => {
row.classList.remove("is-drop-target");
});
row.addEventListener("drop", (event) => {
event.preventDefault();
clearDictionaryDropTargets();
if (draggedDictionaryId && draggedDictionaryId !== entry.id) {
moveDictionary(draggedDictionaryId, { targetId: entry.id });
}
draggedDictionaryId = null;
});
}
function renderDeferredAfterBlur(control) {
control.addEventListener("blur", () => {
if (!dictionaryRenderDeferred) {
return;
}
setTimeout(() => {
if (dictionaryRenderDeferred) renderChangedDictionaryState();
}, 0);
});
}
function bindDictionaryAlias(row, entry) {
const input = row.querySelector(".dict-display-name");
input.placeholder = entry.title;
input.setAttribute("aria-label", `Display name for ${entry.title}`);
input.title = `Display name for ${entry.title}`;
bindNameDraft(input, "dictionaries", entry.id, "displayName", entry.displayName ?? "", value => value.trim());
renderDeferredAfterBlur(input);
}
function bindDictionaryEnabled(row, entry) {
const enabled = row.querySelector(".dict-enabled");
enabled.checked = entry.enabled;
enabled.setAttribute(
"aria-label",
isManagedCustomDictionary(entry)
? `Enabled for ${entry.title} (managed; always enabled)`
: `Enabled for ${entry.title}`,
);
enabled.title = `Enabled for ${entry.title}`;
if (isManagedCustomDictionary(entry)) {
enabled.checked = true;
enabled.dataset.pinnedDisabled = "true";
enabled.disabled = true;
return;
}
enabled.addEventListener("change", () => {
const value = enabled.checked;
void commitDictionaries(updateDictionary(entry.id, (dictionary) =>
dictionary.enabled === value ? dictionary : { ...dictionary, enabled: value }), true);
});
}
// The rank badge, up/down enablement, and position input all depend on where a
// package sits in the list, so a reorder must refresh them. Everything here is
// idempotent value-setting with no listeners, so it is also what a reused row
// needs after a reorder instead of a full rebuild.
function refreshDictionaryOrder(row, entry, index) {
const fixed = isManagedCustomDictionary(entry);
const minimumIndex = isManagedCustomDictionary(dictionaries[0]) ? 1 : 0;
row.querySelector(".dict-rank").textContent = String(index + 1);
const up = row.querySelector(".dict-up");
const down = row.querySelector(".dict-down");
up.title = `Move ${entry.title} up`;
down.title = `Move ${entry.title} down`;
up.dataset.pinnedDisabled = String(fixed || index <= minimumIndex);
down.dataset.pinnedDisabled = String(fixed || index === dictionaries.length - 1);
const position = row.querySelector(".dict-position-input");
const move = row.querySelector(".dict-move");
position.value = String(index + 1);
position.min = String(minimumIndex + 1);
position.max = String(dictionaries.length);
position.dataset.pinnedDisabled = String(fixed);
move.dataset.pinnedDisabled = String(fixed);
move.title = `Move ${dictionaryLabel(entry)} to position`;
if (fixed) {
up.setAttribute("aria-label", `Move ${entry.title} up (managed; fixed first)`);
down.setAttribute("aria-label", `Move ${entry.title} down (managed; fixed first)`);
position.setAttribute("aria-label", `Position for ${dictionaryLabel(entry)} (managed; fixed first)`);
move.setAttribute("aria-label", `Move ${dictionaryLabel(entry)} (managed; fixed first)`);
} else {
up.setAttribute("aria-label", `Move ${entry.title} up`);
down.setAttribute("aria-label", `Move ${entry.title} down`);
position.setAttribute("aria-label", `Position for ${dictionaryLabel(entry)}`);
move.setAttribute("aria-label", `Move ${dictionaryLabel(entry)} to position`);
}
}
function bindDictionaryOrder(row, entry, index) {
refreshDictionaryOrder(row, entry, index);
const up = row.querySelector(".dict-up");
const down = row.querySelector(".dict-down");
up.addEventListener("click", () => {
moveDictionary(entry.id, { step: -1 });
});
down.addEventListener("click", () => {
moveDictionary(entry.id, { step: 1 });
});
const position = row.querySelector(".dict-position-input");
const move = row.querySelector(".dict-move");
// Read the live index and bounds so a reused row keeps working after the
// package moves; only the entry id is stable across reorders. An
// out-of-range integer clamps to the nearest movable slot: with the managed
// dictionary pinned first, typing 1 means "as high as possible", so it lands
// on position 2 instead of being silently discarded.
const moveToPosition = () => {
if (isManagedCustomDictionary(entry)) return;
const currentIndex = dictionaries.findIndex((candidate) => candidate.id === entry.id);
const minimumIndex = isManagedCustomDictionary(dictionaries[0]) ? 1 : 0;
const requested = Number(position.value);
if (position.value.trim() === "" || !Number.isInteger(requested)) {
position.value = String(currentIndex + 1);
return;
}
const target = Math.min(dictionaries.length, Math.max(minimumIndex + 1, requested));
position.value = String(target);
moveDictionary(entry.id, { position: target });
};
position.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
moveToPosition();
}
});
move.addEventListener("click", moveToPosition);
}
function renderDictionaryRow(template, entry, index) {
const row = template.content.firstElementChild.cloneNode(true);
row.dataset.dictionaryId = entry.id;
row.querySelector(".dict-details").open = expandedDictionaryIds.has(entry.id);
row.querySelector(".dict-details-toggle").setAttribute("aria-label", `Details for ${entry.title}`);
row.querySelector(".dict-pinned").hidden = !isManagedCustomDictionary(entry);
row.classList.toggle("is-off", !entry.enabled);
bindDictionarySelection(row, entry);
bindDictionaryDrag(row, entry);
const title = row.querySelector(".dict-title");
title.textContent = dictionaryLabel(entry);
title.title = entry.path;
const canonical = row.querySelector(".dict-canonical");
canonical.textContent = entry.displayName ? entry.title : "";
canonical.hidden = !entry.displayName;
row.querySelector(".dict-favorite").hidden = !entry.favorite;
const badges = row.querySelector(".dict-badges");
addCountBadge(badges, "Terms", entry.termCount);
addCountBadge(badges, "Frequency", entry.frequencyCount);
addCountBadge(badges, "Pitch", entry.pitchCount);
addCountBadge(badges, "Kanji", entry.kanjiCount);
addCountBadge(badges, "Media", entry.mediaCount);
const metadata = dictionaryMetadata(entry);
row.querySelector(".dict-metadata").textContent = isManagedCustomDictionary(entry)
? `Managed · always enabled and first · ${metadata}`
: metadata;
bindDictionaryUpdate(row, entry);
bindDictionaryAlias(row, entry);
bindDictionaryEnabled(row, entry);
bindDictionaryOrder(row, entry, index);
const remove = row.querySelector(".dict-remove");
remove.setAttribute("aria-label", `Remove ${entry.title}`);
remove.title = `Remove ${entry.title}`;
if (isManagedCustomDictionary(entry)) {
remove.dataset.pinnedDisabled = "true";
remove.disabled = true;
remove.hidden = true;
} else {
remove.addEventListener("click", () => {
removeDictionary(entry.id, entry.title);
});
}
return row;
}
function dictionaryRowsMatch(list, visible) {
const domIds = new Set([...list.children].map((row) => row.dataset.dictionaryId));
return domIds.size === visible.length && visible.every((entry) => domIds.has(entry.id));
}
function renderDictionaries(reuseRows = false) {
// A queued reorder changes only the order and the index-dependent controls,
// so its rows can be reappended in the new order and refreshed instead of
// rebuilt from the template. The hint is single-use per render.
const reorderReuse = reorderReuseHint;
reorderReuseHint = false;
const list = element("dict-list");
const visible = visibleDictionaries();
// A failed or conflicting commit can restore a different set than the one
// being reordered, so only reuse when the rows on screen still match the
// packages about to be shown (the same visible set, only reordered).
const reorderReuseSafe = reorderReuse && dictionaryRowsMatch(list, visible);
reuseRows = reuseRows || reorderReuseSafe;
const reusableRows = new Map();
// Retain disclosure state by package identity, including temporarily filtered rows.
for (const row of list.children) {
if (row.querySelector(".dict-details").open) expandedDictionaryIds.add(row.dataset.dictionaryId);
else expandedDictionaryIds.delete(row.dataset.dictionaryId);
// Filtering can retain unchanged controls, but an adopted state awaiting
// blur has newer metadata and listener inputs than the displayed rows.
if (reuseRows && !dictionaryRenderDeferred) reusableRows.set(row.dataset.dictionaryId, row);
}
const installedIds = new Set(dictionaries.map((entry) => entry.id));
for (const id of expandedDictionaryIds) {
if (!installedIds.has(id)) expandedDictionaryIds.delete(id);
}
const template = element("dict-row-template");
const visibleIds = new Set(visible.map((dictionary) => dictionary.id));
draggedDictionaryId = null;
if (reusableRows.size > 0) clearDictionaryDropTargets();
list.textContent = "";
dictionaries.forEach((entry, index) => {
if (!visibleIds.has(entry.id)) {
return;
}
const reused = reusableRows.get(entry.id);
if (reused) {
// The package set and metadata are unchanged; only its position moved.
if (reorderReuseSafe) refreshDictionaryOrder(reused, entry, index);
list.appendChild(reused);
} else {
list.appendChild(renderDictionaryRow(template, entry, index));
}
});
element("dict-controls").hidden = dictionaries.length === 0;
const empty = element("dict-empty");
const isEmpty = dictionaries.length === 0;
element("dict-empty-heading").textContent = isEmpty ? "Your Japanese library starts here" : "No dictionaries found";
element("dict-empty-description").textContent = isEmpty
? "Install the recommended set, or bring your own Yomitan ZIP files."
: "Try a different title or display name.";
element("dict-empty-actions").hidden = !isEmpty;
element("empty-clear-search").hidden = isEmpty;
element("dict-reorder-help").hidden = isEmpty;
empty.hidden = visible.length > 0;
if (!element("engine-status").classList.contains("is-error")) renderEngineStatus();
renderDictionarySelection(visible);
setControlsDisabled(importing);
}
function dictionaryMoveTarget(current, index, move) {
if (move.targetId) {
return current.findIndex((entry) => entry.id === move.targetId);
}
if (move.step) {
return index + move.step;
}
return move.position - 1;
}
function moveDictionary(id, move) {
void commitDictionaries((current) => {
const index = current.findIndex((entry) => entry.id === id);
if (index < 0 || isManagedCustomDictionary(current[index])) return null;
const minimumIndex = isManagedCustomDictionary(current[0]) ? 1 : 0;
const target = Math.max(minimumIndex, dictionaryMoveTarget(current, index, move));
return moveListItem(current, index, target);
}, true, { reorder: true });
}
async function restoreAuthoritativeState(reply) {
if (reply?.state) {
adoptDictionaryState(reply.state);
return;
}
const fresh = await send("hd_state_read", {}, WORKER_TARGET);
if (!fresh.ok || !fresh.state) {
throw new Error(fresh.error || "the dictionary state could not be read");
}
adoptDictionaryState(fresh.state);
}
function directionalFocus(row, controlClass, upClass, downClass) {
let control = row?.querySelector(`.${controlClass}`);
if (control?.disabled && controlClass === upClass) {
control = row.querySelector(`.${downClass}`);
} else if (control?.disabled && controlClass === downClass) {
control = row.querySelector(`.${upClass}`);
}
return control?.disabled ? null : control;
}
function restoreManagementFocus(focus) {
const section = focus.kind === "dictionary" ? "dictionaries" : "dictionary-groups";
if (element(section).hidden) return;
if (focus.kind === "dictionary") {
const row = [...element("dict-list").children]
.find((candidate) => candidate.dataset.dictionaryId === focus.id);
const control = directionalFocus(row, focus.controlClass, "dict-up", "dict-down")
?? row?.querySelector(".dict-details-toggle");
control?.focus();
return;
}
const groupRows = [...element("dict-group-list").children];
const groupRow = groupRows.find((candidate) => candidate.dataset.groupId === focus.groupId)
?? groupRows[Math.min(focus.groupIndex, groupRows.length - 1)];
if (!groupRow) {
element("dict-group-name-new").focus();
return;
}
if (focus.kind === "group") {
const control = directionalFocus(groupRow, focus.controlClass, "dict-group-up", "dict-group-down")
?? groupRow.querySelector(".dict-group-name");
control?.focus();
return;
}
const memberRows = [...groupRow.querySelectorAll(".dict-group-member")];
const memberRow = memberRows.find((candidate) => candidate.dataset.dictionaryId === focus.dictionaryId)
?? memberRows[Math.min(focus.memberIndex, memberRows.length - 1)];
const control = directionalFocus(
memberRow,
focus.controlClass,
"dict-group-member-up",
"dict-group-member-down",
) ?? groupRow.querySelector(".dict-group-add-select:not(:disabled), .dict-group-name");
control?.focus();
}
function renderDictionaryState() {
const focus = focusedManagementControl()
?? (document.activeElement === document.body ? pendingManagementFocus : null);
pendingManagementFocus = null;
dictionaries = dictionaryState.dictionaries;
nameDrafts.retain(new Set([
...dictionaries.map(entry => `dictionaries:${entry.id}`),
...dictionaryState.groups.map(group => `groups:${group.id}`),
]));
dictionaryRenderDeferred = false;
renderDictionaries();
dictionaryGroupController.render();
renderRecommendedActions();
setControlsDisabled(importing);
normaliseDictionarySelections();
renderOptions();
if (focus) restoreManagementFocus(focus);
}
async function commitDictionaryStateChange(update, reloadEngine) {
const next = update(dictionaryState);
if (next === null) {
return { ok: true, state: dictionaryState };
}
const baseRevision = dictionaryState.revision;
try {
const target = reloadEngine ? TARGET : WORKER_TARGET;
const type = reloadEngine ? "hd_apply_state" : "hd_state_cas";
const fields = {
baseRevision,
dictionaries: next.dictionaries,
};
if (!reloadEngine) {
fields.groups = next.groups;
}
const reply = await send(type, fields, target);
if (!reply.ok) {
await restoreAuthoritativeState(reply);
reorderReuseHint = false;
dictionaryCommitFailed = true;
setStatus(`Dictionary change was not saved: ${reply.error ?? "the state changed elsewhere"}`, "error");
return reply;
}
adoptDictionaryState(reply.state);
return reply;
} catch (error) {
try {
await restoreAuthoritativeState();
} catch {
// Keep the visible error from the failed write; a later storage event or
// page reload will supply the authoritative state.
}
reorderReuseHint = false;
dictionaryCommitFailed = true;
setStatus(`Dictionary change was not saved: ${describe(error)}`, "error");
return { ok: false, error: describe(error) };
}
}
function queueDictionaryStateChange(update, reloadEngine, { reorder = false } = {}) {
if (pendingDictionaryCommits === 0) {
dictionaryCommitFailed = false;
}
// The next render can reuse the existing rows only if every change coalesced
// into it was a reorder: reorders touch just the order and index-dependent
// controls, while any other change can alter per-package metadata.
reorderReuseHint = reorder && (pendingDictionaryCommits === 0 || reorderReuseHint);
pendingDictionaryCommits += 1;
committing = true;
pendingManagementFocus = focusedManagementControl() ?? pendingManagementFocus;
setControlsDisabled(importing);
const run = dictionaryCommitTail.then(
() => commitDictionaryStateChange(update, reloadEngine),
() => commitDictionaryStateChange(update, reloadEngine),
);
const settled = run.finally(async () => {
pendingDictionaryCommits -= 1;
if (pendingDictionaryCommits > 0) {
return;
}
committing = false;
renderChangedDictionaryState();
if (!dictionaryCommitFailed) {
await refreshStatus();
}
});
dictionaryCommitTail = settled.then(
() => undefined,
() => undefined,
);
return settled;
}
function commitDictionaries(update, reloadEngine, options) {
return queueDictionaryStateChange((current) => {
const dictionaries = update(current.dictionaries);
return dictionaries === null ? null : { ...current, dictionaries };
}, reloadEngine, options);
}
function commitGroups(update) {
return queueDictionaryStateChange((current) => {
const groups = update(current.groups);
return groups === null ? null : { ...current, groups };
}, false);
}
function bindNameDraft(input, collection, id, field, value, normalise, validate) {
nameDrafts.bind(`${collection}:${id}`, input, {
value, normalise,
readName: () => {
const entry = dictionaryState[collection].find(item => item.id === id);
return entry ? entry[field] ?? "" : undefined;
},
async save(baseName, name) {
let renamed;
const reply = await queueDictionaryStateChange(current => {
renamed = renameWithBaseline(current[collection], id, field, baseName, name, validate);
return renamed.error || renamed.items === current[collection] ? null : { ...current, [collection]: renamed.items };
}, false);
return renamed?.error ? { ok: false, ...renamed } : reply;
},
});
}
const dictionaryGroupController = createDictionaryGroupController({
setError: (message) => setSectionStatus("dict-group-error", message, "error"),
readState: () => dictionaryState,
readDictionaries: () => dictionaries,
commitGroups,
dictionaryLabel,
moveListItem,
updateItemById,
renderDeferredAfterBlur,
bindNameDraft,
});
async function removeDictionary(id, title) {
if (!window.confirm(`Remove ${title}? Its imported data is deleted and has to be imported again.`)) {
return;
}
await removeDictionaries([{ id, title }]);
}
function selectedRemovableDictionaries() {
return dictionaries.filter((entry) => selectedDictionaryIds.has(entry.id)
&& !isManagedCustomDictionary(entry));
}
async function removeSelectedDictionaries() {
const selected = selectedRemovableDictionaries();
if (!selected.length || !window.confirm(`Remove ${selected.length} selected dictionaries? Their imported data is deleted and has to be imported again. The personal dictionary is kept.`)) {
return;
}
await removeDictionaries(selected);
}
async function removeDictionaries(entries) {
removing = true;
setControlsDisabled(true);
const failures = [];
try {
await dictionaryCommitTail;
for (const { id, title } of entries) {
try {
const reply = await send("hd_remove", { id, title });
if (!reply.ok) throw new Error(reply.error ?? "unknown error");
selectedDictionaryIds.delete(id);
} catch (error) {
failures.push(`${title}: ${describe(error)}`);
}
}
if (await reloadDictionaries()) {
await refreshStatus();
}
if (failures.length) setStatus(`Could not remove ${failures.join("; ")}`, "error");
} finally {
removing = false;
setControlsDisabled(importing);
}
}
async function reloadDictionaries() {
try {
let reply = await send("hd_state_read", {}, WORKER_TARGET);
if (!reply.ok) {
throw new Error(reply.error || "the dictionary state could not be read");
}
if (!reply.state) {
const reloaded = await send("hd_reload");
if (!reloaded.ok) {
throw new Error(reloaded.error || "the dictionary state could not be migrated");
}
reply = await send("hd_state_read", {}, WORKER_TARGET);
}
if (!reply.ok || !reply.state) {
throw new Error(reply.error || "the dictionary state could not be read");
}
adoptDictionaryState(reply.state);
} catch (error) {
setStatus(`Could not read the dictionary list: ${describe(error)}`, "error");
return false;
}
renderDictionaryState();
return true;
}
function summariseReport(report) {
const counts = [
[report.termCount, "term", "terms"],
[report.frequencyCount, "frequency entry", "frequency entries"],
[report.pitchCount, "pitch entry", "pitch entries"],
[report.kanjiCount, "kanji", "kanji"],
[report.mediaCount, "media file", "media files"],
]
.filter(([value]) => Number(value) > 0)
.map(([value, singular, plural]) => `${numberFormat.format(value)} ${value === 1 ? singular : plural}`);
return counts.length === 0 ? "no entries" : counts.join(", ");
}
function revisionLabel(value) {
return value === null || value === "" ? "(missing)" : value;
}
function chooseDictionaryImport(identity, matches) {
const dialog = element("import-decision-dialog");
const target = element("import-decision-target");
const imported = element("import-decision-imported");
const installed = element("import-decision-installed");
const description = element("import-decision-description");
target.replaceChildren(...matches.map(({ dictionary }, index) => {
const option = document.createElement("option");
option.value = String(index);
const name = dictionary.displayName
? `${dictionary.displayName} (${dictionary.title})`
: dictionary.title;
option.textContent = `${name} — revision ${revisionLabel(dictionary.revision)}`
+ ` · ID ${dictionary.id.slice(0, 8)}`;
return option;
}));
element("import-decision-target-row").hidden = matches.length === 1;
imported.textContent = `${identity.title} — revision ${revisionLabel(identity.revision)}`;
const renderTarget = () => {
const match = matches[Number(target.value) || 0];
installed.textContent = `${match.dictionary.displayName || match.dictionary.title}`
+ ` — revision ${revisionLabel(match.dictionary.revision)}`;
description.textContent = describeRevisionComparison(
identity.revision,
match.dictionary.revision,
);
};
target.value = "0";
target.onchange = renderTarget;
renderTarget();
dialog.returnValue = "";
return new Promise((resolve) => {
const finish = () => {
dialog.removeEventListener("cancel", cancel);
const action = ["replace", "separate"].includes(dialog.returnValue)
? dialog.returnValue
: "cancel";
const match = matches[Number(target.value) || 0];
target.onchange = null;
resolve(action === "cancel" ? null : {
action,
identity,
matchKind: match.kind,
target: dictionaryImportTarget(match.dictionary),
});
};
const cancel = (event) => {
event.preventDefault();
dialog.close("cancel");
};
dialog.addEventListener("close", finish, { once: true });
dialog.addEventListener("cancel", cancel, { once: true });
dialog.showModal();
});
}
async function importFile(file, index, total, request = {}, label = file.name) {
updateImportResult(index, { text: "Reading dictionary metadata…", progress: { value: null } });
let identity;
try {
identity = await readDictionaryArchiveIdentity(file);
} catch (error) {
updateImportResult(index, {
text: `Failed before import: ${describe(error)}`,
tone: "error",
});
return "failed";
}
const matches = dictionaryImportMatches(identity, dictionaries);
let importDecision = {
action: "install",
identity,
matchKind: null,
target: null,
};
if (matches.length > 0) {
importDecision = await chooseDictionaryImport(identity, matches);
if (importDecision === null) {
updateImportResult(index, {
text: "Cancelled before import. Existing dictionary unchanged.",
});
return "cancelled";
}
}
// The decision happens before this URL exists, so Cancel cannot start a
// native import, create a generation, or mutate persistent storage.
const started = Date.now();
const blobUrl = URL.createObjectURL(file);
try {
return await importArchive({
blobUrl,
fileName: file.name,
...request,
importDecision,
}, index, total, label, started);
} finally {
// The offscreen document has read the bytes by now; holding the URL any
// longer just pins the file.
URL.revokeObjectURL(blobUrl);
}
}
async function importArchive(request, index, total, label, started) {
const tick = () => {
const elapsed = elapsedSince(started);
setImportState(
`Importing ${label} (${index + 1} of ${total}) — ${index} of ${total} complete — ${elapsed} elapsed`,
"busy",
);
updateImportResult(index, { text: `Importing… ${elapsed} elapsed`, progress: { value: null } });
};
tick();
const ticker = setInterval(tick, 1000);
try {
const reply = await send("hd_import", request);
const report = reply.report ?? {};
if (reply.ok && report.success) {
updateImportResult(index, {
text: `Imported ${report.title} in ${importDuration(started)}: ${summariseReport(report)}.`,
tone: "ok",
});
return "imported";
}
const reason = reply.error ?? report.error ?? "The engine gave no reason.";
updateImportResult(index, {
text: `Failed after ${importDuration(started)}: ${reason}`,
tone: "error",
});
} catch (error) {
updateImportResult(index, {
text: `Failed after ${importDuration(started)}: ${describe(error)}`,
tone: "error",
});
} finally {
clearInterval(ticker);
}
return "failed";
}
function renderRecommendedInstallation() {
const { run, failed, pending } = recommendedInstallation;
const wasInstalling = installingRecommended;
installingRecommended = !failed && (run?.finished === false || pending?.installing === true);
setControlsDisabled(importing);
if (failed || importing) return;
if (!run?.runId) {
if (wasInstalling) setImportState("Installation was interrupted. Retry missing dictionaries.", "error");
return;
}
if (renderedInstallRun !== run.runId) {
renderedInstallRun = run.runId;
clearImportResults();
setImportEntries(run.entries.map(entry => {
const source = RECOMMENDED_DICTIONARIES.find(source => source.sourceId === entry.sourceId);
return { id: entry.sourceId, name: source?.name ?? entry.sourceId, purpose: source?.description ?? "" };
}));
}
for (const entry of run.entries) importProgressView().update(entry.sourceId, installEntryState(entry));
const failedCount = run.entries.filter(entry => entry.phase === "failed").length;
const complete = run.entries.filter(entry => ["installed", "already-installed", "failed"].includes(entry.phase)).length;
const total = run.entries.length;
const label = total === 1 ? "recommended dictionary" : "recommended dictionaries";
if (run.finished) {
setImportState(`Finished ${total} of ${total} ${label}${total - failedCount} imported, ${failedCount} failed.`,
failedCount ? "error" : "ready");
if (wasInstalling) void reloadDictionaries().then(refreshStatus);
} else {
setImportState(`Installing recommended dictionaries — ${complete} of ${total} complete. You can close this page.`, "busy");
}
renderRecommendedActions();
}
async function runImportBatch(items, importOne, singular, plural, describeItem) {
if (importing || installingRecommended) {
return;
}
importing = true;
setControlsDisabled(true);
clearImportResults();
setImportEntries(items.map((item, index) => ({
id: String(index),
...describeItem(item),
})));
let imported = 0;
let cancelled = 0;
try {
for (const [index, item] of items.entries()) {
const outcome = await importOne(item, index, items.length);
if (outcome === "imported") {
imported += 1;
// A later archive in the same batch must decide against the state the
// previous archive actually committed, not a delayed storage event.
await reloadDictionaries();
} else if (outcome === "cancelled") cancelled += 1;
}
const failed = items.length - imported - cancelled;
const itemLabel = items.length === 1 ? singular : plural;
const outcomes = [
`${imported} imported`,
...(cancelled === 0 ? [] : [`${cancelled} cancelled`]),
`${failed} failed`,
].join(", ");
setImportState(
`Finished ${items.length} of ${items.length} ${itemLabel}${outcomes}.`,
failed === 0 ? "ready" : "error",
);
await reloadDictionaries();
await refreshStatus();
} finally {
importing = false;
syncNavigationStatus("import-state");
setControlsDisabled(false);
}
}
// An MDX dictionary is one .mdx plus the .mdd resource files named after its
// stem (`Dict.mdd`, `Dict.1.mdd`, ...; case does not matter), which the engine
// discovers as siblings. Every other file imports as a Yomitan ZIP, and a .mdd
// without its .mdx is reported rather than imported on its own.
function isMddResourceOf(mdxName, name) {
const stem = mdxName.slice(0, -".mdx".length).toLowerCase();
const lower = name.toLowerCase();
return lower.startsWith(`${stem}.`) && /^(\d+\.)?mdd$/u.test(lower.slice(stem.length + 1));
}
function groupImportFiles(files) {
if (options.experimental.mdxImport !== true) {
return files.map((file) => ({ kind: "zip", file }));
}
const items = [];
const resourceFiles = files.filter((file) => /\.mdd$/iu.test(file.name));
const claimed = new Set();
for (const file of files) {
if (/\.mdd$/iu.test(file.name)) continue;
if (!/\.mdx$/iu.test(file.name)) {
items.push({ kind: "zip", file });
continue;
}
const resources = resourceFiles.filter((resource) => !claimed.has(resource) && isMddResourceOf(file.name, resource.name));
for (const resource of resources) claimed.add(resource);
items.push({ kind: "mdx", file, resources });
}
for (const resource of resourceFiles) {
if (!claimed.has(resource)) items.push({ kind: "orphan-mdd", file: resource });
}
return items;
}
async function importMdx(item, index, total) {
const { file, resources } = item;
const started = Date.now();
const urls = [file, ...resources].map((entry) => URL.createObjectURL(entry));
try {
return await importArchive({
blobUrl: urls[0],
fileName: file.name,
resources: resources.map((resource, position) => ({ fileName: resource.name, blobUrl: urls[position + 1] })),
}, index, total, file.name, started);
} finally {
for (const url of urls) URL.revokeObjectURL(url);
}
}
async function importGroupedItem(item, index, total) {
if (item.kind === "mdx") return importMdx(item, index, total);
if (item.kind === "orphan-mdd") {
updateImportResult(index, {
text: "Not imported: choose this .mdd together with the .mdx it belongs to.",
tone: "error",
});
return "failed";
}
return importFile(item.file, index, total);
}
function importItemPurpose(item) {
if (item.kind === "orphan-mdd") return "MDD resource file";
if (item.kind !== "mdx") return "Yomitan ZIP file";
const count = item.resources.length;
if (count === 0) return "MDX dictionary";
return `MDX dictionary with ${count} MDD ${count === 1 ? "file" : "files"}`;
}
function describeImportItem(item) {
return { name: item.file.name, purpose: importItemPurpose(item) };
}
function runImports(files) {
const items = groupImportFiles(files);
const onlyArchives = items.every((item) => item.kind === "zip");
return runImportBatch(items, importGroupedItem, onlyArchives ? "archive" : "file",
onlyArchives ? "archives" : "files", describeImportItem);
}
function hasDroppedFiles(event) {
const transfer = event.dataTransfer;
return (transfer?.files?.length ?? 0) > 0 || Array.from(transfer?.types ?? []).includes("Files");
}
function clearImportDropState() {
importDragDepth = 0;
element("import-drop-zone").classList.remove("is-dragging");
}
function bindImportDropZone(file) {
const zone = element("import-drop-zone");
zone.setAttribute("aria-disabled", String(file.disabled));
zone.addEventListener("dragenter", (event) => {
if (!hasDroppedFiles(event)) return;
event.preventDefault();
if (file.disabled || importing) return;
importDragDepth += 1;
zone.classList.add("is-dragging");
});
zone.addEventListener("dragover", (event) => {
if (!hasDroppedFiles(event)) return;
event.preventDefault();
if (file.disabled || importing) return;
event.dataTransfer.dropEffect = "copy";
zone.classList.add("is-dragging");
});
zone.addEventListener("dragleave", () => {
if (importDragDepth === 0) return;
importDragDepth -= 1;
if (importDragDepth === 0) zone.classList.remove("is-dragging");
});
zone.addEventListener("drop", (event) => {
if (!hasDroppedFiles(event)) return;
event.preventDefault();
const dropped = [...(event.dataTransfer?.files ?? [])];
clearImportDropState();
if (!file.disabled && !importing && dropped.length > 0) {
void runImports(dropped);
}
});
}
function installMissingRecommendedDictionaries() {
const missing = missingRecommendedDictionaries();
if (missing.length > 0 && !importing && !installingRecommended) {
installingRecommended = true;
setControlsDisabled(importing);
void recommendedInstallation.request(missing.map(entry => entry.sourceId));
}
}
function updateOutcomeSummary(type, outcomes) {
const failed = outcomes.filter((outcome) => outcome.status === "check-failed" || outcome.error).length;
if (type === "hd_updates_check") {
const available = outcomes.filter((outcome) => outcome.status === "update-available").length;
const dictionariesLabel = outcomes.length === 1 ? "managed dictionary" : "managed dictionaries";
const updatesLabel = available === 1 ? "update" : "updates";
return {
message: `Checked ${outcomes.length} ${dictionariesLabel}${available} ${updatesLabel} available, ${failed} failed.`,
tone: failed === 0 ? "ready" : "error",
};
}
const updated = outcomes.filter((outcome) => outcome.status === "updated").length;
const updatesLabel = outcomes.length === 1 ? "dictionary update" : "dictionary updates";
return {
message: `Finished ${outcomes.length} ${updatesLabel}${updated} updated, ${failed} failed.`,
tone: failed === 0 ? "ready" : "error",
};
}
async function runManagedUpdate(type, dictionaryIds = null) {
if (updating) {
return;
}
updating = true;
setControlsDisabled(true);
setUpdateState(type === "hd_updates_check" ? "Checking managed dictionaries…" : "Updating dictionaries…");
try {
const fields = dictionaryIds === null ? {} : { dictionaryIds };
const reply = await send(type, fields, UPDATE_TARGET);
if (!reply.ok) {
throw new Error(reply.error || "the dictionary update operation failed");
}
adoptUpdateSettings(reply.settings);
await reloadDictionaries();
const summary = updateOutcomeSummary(type, reply.outcomes ?? []);
setUpdateState(summary.message, summary.tone);
} catch (error) {
setUpdateState(`Dictionary updates failed: ${describe(error)}`, "error");
} finally {
updating = false;
syncNavigationStatus("update-state");
setControlsDisabled(importing);
}
}
function writeUpdateSchedule(schedule) {
pendingSchedule = { schedule, baseRevision: pendingSchedule?.baseRevision ?? savingSchedule?.baseRevision ?? updateSettings.revision };
window.clearTimeout(scheduleTimer);
scheduleTimer = null;
if (scheduleSaveFailed) return;
setUpdateState("Unsaved schedule…", "");
scheduleTimer = window.setTimeout(() => { void flushUpdateSchedule(); }, OPTIONS_SAVE_DELAY_MS);
}
async function flushUpdateSchedule() {
window.clearTimeout(scheduleTimer);
scheduleTimer = null;
if (savingSchedule || scheduleSaveFailed || !pendingSchedule) return;
const sent = pendingSchedule;
pendingSchedule = null;
savingSchedule = sent;
renderUpdateControls();
setUpdateState("Saving schedule…", "");
try {
const reply = await send("hd_updates_schedule", sent, UPDATE_TARGET);
if (reply.settings) adoptUpdateSettings(reply.settings);
if (!reply.ok) throw new Error(reply.error || "the dictionary update schedule could not be saved");
// Advance a queued draft only through our own commit, never through an
// unrelated newer event that happened to arrive before this reply.
if (pendingSchedule) pendingSchedule.baseRevision = Math.max(pendingSchedule.baseRevision, reply.settings.revision);
setUpdateState(pendingSchedule ? "Unsaved schedule…" : "Schedule saved.", pendingSchedule ? "" : "ready");
} catch (error) {
pendingSchedule ??= sent;
scheduleSaveFailed = true;
try {
const stored = await chrome.storage.local.get("dictionaryUpdates");
adoptUpdateSettings(stored.dictionaryUpdates);
} catch { /* Keep the draft even if the committed state cannot be read. */ }
setUpdateState(`Could not save the schedule: ${describe(error)} Current schedule: ${updateSettings.schedule}. Your draft is retained.`, "error");
} finally {
savingSchedule = null;
renderUpdateControls();
syncNavigationStatus("update-state");
if (!scheduleSaveFailed && scheduleTimer === null && pendingSchedule) void flushUpdateSchedule();
}
}
function attachHandlers() {
element("custom-dictionary-form").addEventListener("submit", (event) => {
void saveCustomDictionarySource(event);
});
element("custom-dictionary-reload").addEventListener("click", () => {
void loadCustomDictionarySource();
});
element("custom-dictionary-source").addEventListener("input", () => {
cancelCustomDictionaryValidation();
if (customDraftStale) {
markCustomDictionaryStale();
} else {
setCustomDictionaryStatus(customDictionaryDirty() ? "Unsaved changes." : "No unsaved changes.");
}
renderCustomDictionaryControls();
// Keep full-document parsing and diagnostics off the typing path. Saving
// cancels this preview and validates the exact submitted source immediately.
customValidationTimer = setTimeout(() => {
const parsed = renderCustomDictionaryValidation();
if (customDraftStale || !customDictionaryDirty()) return;
setCustomDictionaryStatus(
`${parsed.entries.length} valid ${parsed.entries.length === 1 ? "entry" : "entries"} ready to save.`,
);
}, 150);
});
const file = element("import-file");
file.addEventListener("change", () => {
const picked = [...(file.files ?? [])];
// Snapshot before clearing so picking the same batch again fires a change event.
file.value = "";
if (picked.length > 0) {
void runImports(picked);
}
});
bindImportDropZone(file);
element("dict-search").addEventListener("input", (event) => {
dictionarySearch = event.target.value;
renderDictionaries(true);
});
element("dict-select-visible").addEventListener("change", (event) => {
const visible = visibleDictionaries();
for (const dictionary of visible) {
if (event.target.checked) {
selectedDictionaryIds.add(dictionary.id);
} else {
selectedDictionaryIds.delete(dictionary.id);
}
}
if (dictionaryRenderDeferred) {
renderDictionaries();
} else {
for (const row of element("dict-list").children) {
row.querySelector(".dict-selected").checked = selectedDictionaryIds.has(row.dataset.dictionaryId);
}
renderDictionarySelection(visible);
setControlsDisabled(importing);
}
});
element("dict-bulk-enable").addEventListener("click", () => {
updateSelectedDictionaries("enabled", true, true);
});
element("dict-bulk-disable").addEventListener("click", () => {
updateSelectedDictionaries("enabled", false, true);
});
element("dict-bulk-favorite").addEventListener("click", () => {
updateSelectedDictionaries("favorite", true, false);
});
element("dict-bulk-unfavorite").addEventListener("click", () => {
updateSelectedDictionaries("favorite", false, false);
});
element("dict-bulk-remove").addEventListener("click", removeSelectedDictionaries);
element("dict-group-create-form").addEventListener("submit", (event) => {
event.preventDefault();
dictionaryGroupController.create();
});
document.querySelector("main").addEventListener("pointerdown", (event) => {
if (event.target.closest("#dict-list, #dict-group-list")) managementPointerDown = true;
});
const finishManagementPointer = () => {
managementPointerDown = false;
setTimeout(() => {
if (dictionaryRenderDeferred) renderChangedDictionaryState();
}, 0);
};
window.addEventListener("pointerup", finishManagementPointer, true);
window.addEventListener("pointercancel", finishManagementPointer, true);
element("install-recommended").addEventListener("click", installMissingRecommendedDictionaries);
element("empty-install-recommended").addEventListener("click", () => {
window.location.hash = "add-dictionaries";
installMissingRecommendedDictionaries();
});
element("empty-import-dictionaries").addEventListener("click", () => {
window.location.hash = "add-dictionaries";
element("import-file").click();
});
element("empty-clear-search").addEventListener("click", () => {
const search = element("dict-search");
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
search.focus();
});
element("retry-recommended").addEventListener("click", installMissingRecommendedDictionaries);
element("update-check-now").addEventListener("click", () => {
void runManagedUpdate("hd_updates_check");
});
element("update-all").addEventListener("click", () => {
void runManagedUpdate("hd_updates_install", availableUpdates().map((dictionary) => dictionary.id));
});
element("update-schedule").addEventListener("change", (event) => {
writeUpdateSchedule(event.target.value);
});
element("update-schedule-retry").addEventListener("click", () => {
if (!pendingSchedule) return;
pendingSchedule.baseRevision = updateSettings.revision;
scheduleSaveFailed = false;
void flushUpdateSchedule();
});
element("update-schedule-discard").addEventListener("click", () => {
window.clearTimeout(scheduleTimer);
scheduleTimer = pendingSchedule = null;
scheduleSaveFailed = false;
renderUpdateControls();
setUpdateState("Current schedule restored.", "ready");
});
for (const field of NUMBER_FIELDS) {
const input = element(field.id);
input.addEventListener("change", () => {
options[field.key] = clampOption(field.key, input.value);
input.value = String(options[field.key]);
writeOptions();
});
}
element("opt-hover-enabled").addEventListener("change", (event) => {
options.hoverEnabled = event.target.checked;
writeOptions();
});
for (const field of APPEARANCE_CHOICES) {
element(field.id).addEventListener("change", (event) => {
options[field.key] = field.values && !field.values.includes(event.target.value)
? DEFAULT_OPTIONS[field.key] : event.target.value;
if (field.key === "definitionBlurReveal") renderDefinitionBlurControls();
writeOptions();
});
}
for (const [id, key] of [["opt-blur-count", "definitionBlurEnabled"],
["opt-blur-anki", "definitionBlurAnkiMature"],
["opt-blur-frequency", "definitionBlurFrequencyEnabled"]]) {
element(id).addEventListener("change", (event) => {
options[key] = event.target.checked;
renderDefinitionBlurControls();
writeOptions();
});
}
element("opt-blur-frequency-dictionary").addEventListener("change", (event) => {
if (event.target.value && !selectedDefinitionBlurFrequencyDictionary(event.target.value)) {
event.target.value = options.definitionBlurFrequencyDictionary;
setOptionsStatus("That frequency dictionary is no longer available.");
return;
}
options.definitionBlurFrequencyDictionary = event.target.value;
renderDefinitionBlurControls();
writeOptions();
});
element("opt-blur-delay").addEventListener("change", (event) => {
options.definitionBlurDelayMs = clampOption("definitionBlurDelayMs", Math.round(Number(event.target.value) * 1000));
event.target.value = String(options.definitionBlurDelayMs / 1000);
writeOptions();
});
element("opt-source-highlight").addEventListener("change", (event) => {
options.sourceHighlightEnabled = event.target.checked;
writeOptions();
});
element("opt-popup-audio-button").addEventListener("change", (event) => {
options.showPopupAudioButton = event.target.checked;
writeOptions();
});
element("reset-design").addEventListener("click", () => {
for (const key of DESIGN_OPTION_KEYS) options[key] = DEFAULT_OPTIONS[key];
customButtonController?.reset();
renderCustomCss(true);
renderCustomJavascript(true);
renderOptions();
writeOptions();
});
element("opt-custom-popup-css").addEventListener("input", event => {
// This target listener runs before the section's bubbling draft listener.
optionsEditRevision ??= Math.max(0, optionsRevision);
options.customPopupCss = event.target.value;
renderCustomCss();
writeOptions();
});
element("reset-custom-css").addEventListener("click", () => {
options.customPopupCss = DEFAULT_OPTIONS.customPopupCss;
renderCustomCss(true);
writeOptions();
});
element("opt-custom-popup-javascript").addEventListener("input", event => {
optionsEditRevision ??= Math.max(0, optionsRevision);
options.customPopupJavascript = event.target.value;
renderCustomJavascript();
writeOptions();
});
element("reset-custom-javascript").addEventListener("click", () => {
options.customPopupJavascript = DEFAULT_OPTIONS.customPopupJavascript;
renderCustomJavascript(true);
writeOptions();
});
for (const field of METADATA_FIELDS) {
element(field.id).addEventListener("change", (event) => {
options[field.key] = field.inverted ? !event.target.checked : event.target.checked;
renderMetadataControls();
writeOptions();
});
}
element("opt-pitch-dictionary").addEventListener("change", (event) => {
options.pitchAccentFuriganaDictionary = event.target.value;
writeOptions();
});
element("opt-japanese-only").addEventListener("change", (event) => {
options.onlyScanJapaneseText = event.target.checked;
writeOptions();
});
element("opt-audio-autoplay").addEventListener("change", (event) => {
options.audioAutoplay = event.target.checked;
writeOptions();
});
element("opt-compact-summary").addEventListener("change", (event) => {
options.showCompactDefinitionSummary = event.target.checked;
renderCompactSummaryControls();
writeOptions();
});
element("opt-summary-dictionary").addEventListener("change", (event) => {
options.compactDefinitionSummaryDictionary = event.target.value;
writeOptions();
});
element("opt-image-source").addEventListener("change", (event) => {
// Values come from the canonical descriptors rendered above, not labels.
options.popupImageSource = event.target.value ? JSON.parse(event.target.value) : null;
writeOptions();
});
element("opt-lookup-mode").addEventListener("change", (event) => {
options.lookupMode = LOOKUP_MODES.includes(event.target.value) ? event.target.value : "hover";
writeOptions();
});
element("opt-activation-key").addEventListener("change", (event) => {
options.activationKey = event.target.value;
writeOptions();
});
element("opt-frequency-order").addEventListener("change", (event) => {
options.frequencyOrder = FREQUENCY_ORDERS.includes(event.target.value) ? event.target.value : "auto";
renderFrequencyOrder();
writeOptions();
});
element("opt-frequency-dictionary").addEventListener("change", (event) => {
// A focused native chooser can outlive a dictionary capability change.
if (event.target.value && !selectedFrequencyDictionary(event.target.value)) {
event.target.value = options.frequencyDictionary;
setOptionsStatus("That frequency dictionary is no longer available.");
return;
}
options.frequencyDictionary = event.target.value;
applyFrequencyDirection();
});
element("opt-frequency-auto").addEventListener("click", applyFrequencyDirection);
element("opt-kanji-dictionary").addEventListener("change", (event) => {
options.kanjiClickDictionary = selectionFromValue(event.target.value);
writeOptions();
});
element("media-open-capture").addEventListener("click", async () => {
if (!HOST_CAPABILITIES.mediaCapture) return;
try {
const reply = await send("hd_capture_open", {}, CAPTURE_TARGET);
if (!reply.ok) throw new Error(reply.error || "The capture page could not be opened.");
setStatusOutput(element("media-runtime-status"), "Capture controls opened in a separate tab.");
} catch (error) {
setStatusOutput(element("media-runtime-status"),
`Could not open capture controls: ${describe(error)}`, "error");
}
});
element("opt-media-enabled").addEventListener("change", event => {
void editMediaCapture(capture => { capture.enabled = event.target.checked; }, {
immediate: !event.target.checked,
});
});
for (const [id, key] of [["opt-media-animation", "includeAnimation"], ["opt-media-audio", "includeCapturedAudio"]]) {
element(id).addEventListener("change", event => {
const other = key === "includeAnimation" ? options.mediaCapture.includeCapturedAudio : options.mediaCapture.includeAnimation;
if (!event.target.checked && !other) {
event.target.checked = true;
setOptionsStatus("Keep at least one captured-media output enabled.");
return;
}
void editMediaCapture(capture => { capture[key] = event.target.checked; });
});
}
for (const [id, key] of [["opt-media-history", "historySeconds"], ["opt-media-clip", "clipSeconds"]]) {
element(id).addEventListener("change", event => {
void editMediaCapture(capture => { capture[key] = Number(event.target.value); });
});
}
for (const [id, key] of [["opt-media-preset", "videoPreset"], ["opt-media-timing", "timingMode"]]) {
element(id).addEventListener("change", event => {
void editMediaCapture(capture => { capture[key] = event.target.value; });
});
}
element("opt-media-offset").addEventListener("change", event => {
const value = Math.max(-2000, Math.min(2000, Math.trunc(Number(event.target.value))));
void editMediaCapture(capture => { capture.estimatedOffsetMs = Number.isFinite(value) ? value : -500; });
});
for (const [id, key] of [["opt-media-native-cues", "nativeCues"], ["opt-media-dom-text", "domText"],
["opt-media-auto-area", "autoLearnArea"]]) {
element(id).addEventListener("change", event => {
void editMediaCapture(capture => { capture.page[key] = event.target.checked; });
});
}
element("opt-media-texthooker").addEventListener("change", event => {
if (event.target.checked && !options.mediaCapture.texthooker.url) {
event.target.checked = false;
setOptionsStatus("Enter a loopback WebSocket URL before enabling texthooker timing.");
return;
}
void editMediaCapture(capture => { capture.texthooker.enabled = event.target.checked; });
});
element("opt-media-texthooker-url").addEventListener("change", event => {
const value = normaliseTexthookerUrl(event.target.value);
if (value === null || (options.mediaCapture.texthooker.enabled && value === "")) {
event.target.value = options.mediaCapture.texthooker.url;
setOptionsStatus("Texthooker must use a loopback ws or wss URL without credentials or fragments.");
return;
}
void editMediaCapture(capture => { capture.texthooker.url = value; });
});
element("opt-media-texthooker-format").addEventListener("change", event => {
void editMediaCapture(capture => { capture.texthooker.format = event.target.value; });
});
const optionSections = Object.keys(OPTION_SECTIONS).map(element);
for (const section of optionSections) {
section.addEventListener("input", (event) => {
if (!event.target.id.startsWith("opt-")) return;
optionsEditRevision ??= Math.max(0, optionsRevision);
const field = NUMBER_FIELDS.find(({ id, live }) => live && id === event.target.id);
if (field && event.target.value !== "" && event.target.validity.valid) {
options[field.key] = Number(event.target.value);
writeOptions();
}
});
section.addEventListener("change", () => {
optionsEditRevision = null;
});
section.addEventListener("focusout", (event) => {
optionsEditRevision = null;
if (event.target.id === "opt-custom-popup-css") renderCustomCss(true);
if (event.target.id === "opt-custom-popup-javascript") renderCustomJavascript(true);
if (event.target.id === "opt-frequency-dictionary") renderFrequencyChoices();
if (event.target.id === "opt-blur-frequency-dictionary") renderDefinitionBlurFrequencyChoices();
if (event.target.id === "opt-image-source") renderPopupImageSources();
if (event.target.id === "opt-pitch-dictionary") renderMetadataControls();
if (event.target.closest("#definition-blur-settings")) {
renderDefinitionBlurControls();
}
if (event.target.id === "opt-summary-dictionary" || event.target.id === "opt-summary-count") renderCompactSummaryControls();
const choice = APPEARANCE_CHOICES.find(({ id }) => id === event.target.id);
if (choice) event.target.value = options[choice.key];
const field = NUMBER_FIELDS.find(({ id }) => id === event.target.id);
if (field) event.target.value = String(options[field.key]);
});
}
element("options-retry").addEventListener("click", () => {
optionsSaveFailed = false;
pendingOptionsRevision = optionsRevision;
void flushOptions();
});
element("options-use-saved").addEventListener("click", () => {
window.clearTimeout(optionsTimer);
optionsTimer = null;
pendingOptions = {};
optionsEditRevision = null;
optionsSaveFailed = false;
renderCurrentOptions();
renderCustomCss(true);
renderCustomJavascript(true);
setOptionsStatus("Using saved settings.");
});
window.addEventListener("beforeunload", (event) => {
if (!importing && !backingUp && savingOptions === null && optionsEditRevision === null
&& Object.keys(pendingOptions).length === 0 && savingSchedule === null && pendingSchedule === null
&& !nameDrafts.hasPendingChanges() && !customButtonController?.dirty() && !ankiController?.dirty()) {
return;
}
// Leaving can revoke an import's blob URL or discard a queued settings draft.
event.preventDefault();
event.returnValue = "";
});
chrome.storage.onChanged.addListener(handleStorageChange);
chrome.runtime.onMessage?.addListener(recommendedInstallation.receive);
window.addEventListener("pagehide", recommendedInstallation.stop);
window.addEventListener("pageshow", event => { if (event.persisted) void recommendedInstallation.request(); });
}
function dictionaryNameIsBeingEdited() {
const active = document.activeElement;
return active instanceof HTMLInputElement
&& (active.classList.contains("dict-display-name") || active.classList.contains("dict-group-name"));
}
function renderChangedDictionaryState() {
if (committing || nameDrafts.hasInFlightSave() || managementPointerDown || dictionaryNameIsBeingEdited()) {
dictionaryRenderDeferred = true;
return;
}
renderDictionaryState();
}
function handleDictionaryStateChange(change) {
let adopted;
try {
adopted = adoptDictionaryState(change.newValue);
} catch (error) {
setStatus(describe(error), "error");
return false;
}
if (adopted) {
renderChangedDictionaryState();
}
return true;
}
function renderCurrentOptions() {
options = { ...savedOptions, ...savingOptions?.patch, ...pendingOptions };
renderOptions();
}
function adoptOptions(value) {
const revision = Number.isInteger(value?.revision) && value.revision >= 0 ? value.revision : 0;
if (revision <= optionsRevision) return false;
optionsRevision = revision;
savedOptions = normaliseOptions(value);
renderCurrentOptions();
return true;
}
function handleOptionsChange(change) {
adoptOptions(change.newValue);
}
function handleCustomDictionarySourceChange(change) {
try {
adoptCustomDictionaryDocument(change.newValue);
} catch (error) {
setCustomDictionaryStatus(`Could not read the changed custom dictionary source: ${describe(error)}`, "error");
}
}
function renderSetupResume(value) {
let incomplete = false;
try {
incomplete = setupIncomplete(normaliseSetupState(value));
} catch {
// An unreadable setup record hides the resume link; the startup page reports it.
}
element("setup-resume").hidden = !incomplete;
}
function handleStorageChange(changes, area) {
if (area !== "local") {
return;
}
if (changes[SETUP_STATE_KEY]) {
renderSetupResume(changes[SETUP_STATE_KEY].newValue);
}
if (changes.sharing) {
renderSharingLink(changes.sharing.newValue);
}
if (changes[CUSTOM_DICTIONARY_SOURCE_KEY]) {
handleCustomDictionarySourceChange(changes[CUSTOM_DICTIONARY_SOURCE_KEY]);
}
if (changes.dictionaryState && !handleDictionaryStateChange(changes.dictionaryState)) {
return;
}
if (changes.options) {
handleOptionsChange(changes.options);
}
if (changes.dictionaryUpdates) {
if (adoptUpdateSettings(changes.dictionaryUpdates.newValue)) renderUpdateControls();
}
if (changes.automaticBackups) {
void backupController?.refreshAutomaticBackups();
}
}
function setOptionsStatus(message, completed = false) {
let tone = message === "Saved." ? "ready" : "";
if (optionsSaveFailed) tone = "error";
setSectionStatus("options-status", message, tone, completed);
element("options-status").classList.toggle("is-quiet", !optionsSaveFailed
&& ["Saved.", "Saving…", "Unsaved changes…", "Using saved settings."].includes(message));
element("options-conflict-actions").hidden = !optionsSaveFailed;
}
// Keep only edited fields. A storage event can update the committed snapshot,
// but cannot replace a local draft or authorize a stale draft's write.
function writeOptions() {
applyPageTheme(document, options);
updateDesignPreview();
const previous = { ...savedOptions, ...savingOptions?.patch };
const changes = Object.fromEntries(Object.entries(options).filter(([key, value]) =>
JSON.stringify(value) !== JSON.stringify(previous[key])));
if (Object.keys(pendingOptions).length === 0) {
pendingOptionsRevision = optionsEditRevision ?? Math.max(0, optionsRevision);
}
pendingOptions = changes;
window.clearTimeout(optionsTimer);
optionsTimer = null;
if (optionsSaveFailed) return;
setOptionsStatus("Unsaved changes…");
optionsTimer = window.setTimeout(() => { void flushOptions(); }, OPTIONS_SAVE_DELAY_MS);
}
async function flushOptions() {
window.clearTimeout(optionsTimer);
optionsTimer = null;
if (savingOptions !== null) return optionsSaveCompletion;
if (optionsSaveFailed) return;
if (Object.keys(pendingOptions).length === 0) {
setOptionsStatus("Saved.");
return;
}
let finishSave;
optionsSaveCompletion = new Promise(resolve => { finishSave = resolve; });
const sent = { patch: pendingOptions, baseRevision: pendingOptionsRevision };
savingOptions = sent;
pendingOptions = {};
setOptionsStatus("Saving…");
try {
const reply = await send("hd_options_write", {
baseRevision: sent.baseRevision,
options: sent.patch,
}, WORKER_TARGET);
if (reply.options) adoptOptions(reply.options);
if (!reply.ok) {
throw new Error(reply.error || "the options could not be saved");
}
// A newer external event may already have arrived; keep that state, while
// binding queued edits to the reply we actually committed, not that event.
pendingOptionsRevision = Math.max(pendingOptionsRevision, reply.options.revision);
if (optionsEditRevision !== null) {
optionsEditRevision = Math.max(optionsEditRevision, reply.options.revision);
}
setOptionsStatus(Object.keys(pendingOptions).length > 0 ? "Unsaved changes…" : "Saved.", true);
} catch (error) {
pendingOptions = { ...sent.patch, ...pendingOptions };
optionsSaveFailed = true;
// A reply can be lost after storage commits. Read the current revision for
// explicit retry; do not silently overwrite it or drop the retained draft.
try {
const stored = await chrome.storage.local.get("options");
adoptOptions(stored.options);
} catch { /* The draft stays available even while storage is unreachable. */ }
setOptionsStatus(`Could not save settings: ${describe(error)}`);
} finally {
savingOptions = null;
syncNavigationStatus("options-status");
renderCurrentOptions();
if (!optionsSaveFailed && optionsTimer === null && Object.keys(pendingOptions).length > 0) {
void flushOptions();
}
finishSave();
}
}
async function flushOptionsUntilIdle() {
window.clearTimeout(optionsTimer);
optionsTimer = null;
for (;;) {
if (optionsSaveFailed) {
throw new Error("Save the pending settings before checking AnkiConnect.");
}
if (savingOptions === null && Object.keys(pendingOptions).length === 0) return;
await flushOptions();
}
}
function renderMiningCapabilityHelp() {
element("audio-mining-help").hidden = MINING_CAPABILITIES.browserSpeech;
element("audio-speech-capture-help").hidden = !MINING_CAPABILITIES.browserSpeech;
element("media-overlay-help").hidden = HOST_CAPABILITIES.mediaCapture;
}
async function start() {
configureBrowserUi();
renderMiningCapabilityHelp();
element("custom-buttons-settings").disabled = false;
element("custom-buttons-overlay-help").hidden = !HOST_CAPABILITIES.externalLinkHost;
if (HOST_CAPABILITIES.localFileAccessPrompt) {
createLocalFileAccessController({ document, container: element("settings-local-file-access") });
}
attachSettingsNavigation();
renderRecommendedCatalogue();
attachHandlers();
const stored = await chrome.storage.local.get(["options", "dictionaryUpdates", SETUP_STATE_KEY, "sharing"]);
adoptOptions(stored.options);
adoptUpdateSettings(stored.dictionaryUpdates);
renderSetupResume(stored[SETUP_STATE_KEY]);
renderSharingLink(stored.sharing);
renderCustomDictionaryControls();
if (await reloadDictionaries()) {
writeOptions();
}
renderOptions();
renderUpdateControls();
await refreshStatus();
void recommendedInstallation.request();
}
start();