mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
3631 lines
141 KiB
JavaScript
3631 lines
141 KiB
JavaScript
/*
|
||
* 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 Firefox’s 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();
|