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

1024 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-3.0-or-later
import { ankiAvailability } from "./anki.js";
import { ankiSetupFamily } from "./anki-setup.js";
import { ANKI_TEMPLATE_MARKER_OPTIONS, ankiFieldNames, ankiTemplateErrors, applyAnkiPreset, resolveAnkiTemplates } from "./anki-templates.js";
import { reorderSettingsRows, setStatusOutput } from "./settings-dom.js";
function setAttributeIfChanged(element, name, value) {
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
}
function intersectingMarkerSelection(value, selectionStart, selectionEnd) {
const collapsed = selectionStart === selectionEnd;
for (const match of value.matchAll(/\{([^{}]*)\}/gu)) {
const start = match.index;
const end = start + match[0].length;
const intersects = collapsed
? selectionStart > start && selectionStart < end
: selectionStart < end && selectionEnd > start;
if (!intersects) continue;
return {
start: collapsed ? start : Math.min(selectionStart, start),
end: collapsed ? end : Math.max(selectionEnd, end),
query: match[1],
};
}
return null;
}
function partialMarkerSelection(value, selectionStart, selectionEnd) {
const before = value.slice(0, selectionStart);
const open = before.lastIndexOf("{");
if (open <= before.lastIndexOf("}")) return null;
const close = value.indexOf("}", selectionEnd);
return {
start: open,
end: close < 0 ? selectionEnd : close + 1,
query: value.slice(open + 1, selectionStart),
};
}
function markerLikeTokenQuery(token) {
let start = token.startsWith("{") ? 1 : 0;
let end = token.endsWith("}") ? token.length - 1 : token.length;
if (end < start) end = start;
const query = token.slice(start, end);
for (const character of query) {
if (character === "{" || character === "}" || character.trim() === "") return null;
}
return query;
}
function singleTokenSelection(value, selectionStart, selectionEnd) {
const trimmedStart = value.length - value.trimStart().length;
const trimmedEnd = value.trimEnd().length;
if (trimmedStart >= trimmedEnd || trimmedStart > selectionStart || selectionEnd > trimmedEnd) return null;
const token = value.slice(trimmedStart, trimmedEnd);
const collapsed = selectionStart === selectionEnd;
const completeMarkerBoundary = collapsed && token.startsWith("{") && token.endsWith("}")
&& (selectionStart === trimmedStart || selectionStart === trimmedEnd);
if (completeMarkerBoundary) return null;
const query = markerLikeTokenQuery(token);
return query === null ? null : { start: trimmedStart, end: trimmedEnd, query };
}
function markerSelection(value, selectionStart, selectionEnd) {
const selection = intersectingMarkerSelection(value, selectionStart, selectionEnd)
?? partialMarkerSelection(value, selectionStart, selectionEnd)
?? singleTokenSelection(value, selectionStart, selectionEnd);
if (selection) return selection;
return { start: selectionStart, end: selectionEnd, query: "" };
}
function createMarkerCombobox(document, id, labelText, onValue) {
const root = document.createElement("div");
root.className = "anki-marker-combobox";
root.innerHTML = `<div class="anki-marker-combobox-editor">
<textarea rows="2" spellcheck="false" autocomplete="off" autocapitalize="off"
placeholder="Blank disables this field"
role="combobox" aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false"></textarea>
<button type="button" class="anki-marker-combobox-toggle" tabindex="-1"
aria-haspopup="listbox" aria-expanded="false"><span aria-hidden="true">▾</span></button>
<div class="anki-marker-listbox" role="listbox" hidden></div>
</div>
<span class="anki-marker-combobox-status" role="status" aria-live="polite"></span>
<output class="anki-template-error field-hint is-error" aria-live="polite"></output>`;
const editor = root.querySelector("textarea");
const toggle = root.querySelector("button");
const listbox = root.querySelector('[role="listbox"]');
const status = root.querySelector('[role="status"]');
const error = root.querySelector("output");
editor.id = id;
listbox.id = `${id}-listbox`;
status.id = `${id}-status`;
error.id = `${id}-error`;
editor.setAttribute("aria-controls", listbox.id);
editor.setAttribute("aria-describedby", `${status.id} ${error.id}`);
toggle.setAttribute("aria-controls", listbox.id);
const empty = document.createElement("div");
empty.className = "anki-marker-empty";
empty.setAttribute("role", "presentation");
empty.setAttribute("aria-hidden", "true");
empty.textContent = "No markers match. Keep typing to use this text as-is.";
empty.hidden = true;
const options = ANKI_TEMPLATE_MARKER_OPTIONS.map((marker, index) => {
const option = document.createElement("div");
option.id = `${id}-option-${index + 1}`;
option.className = "anki-marker-option";
option.dataset.marker = marker.value;
option.setAttribute("role", "option");
option.setAttribute("aria-selected", "false");
option.setAttribute("aria-label", `${marker.value}: ${marker.description}`);
const value = document.createElement("code");
value.textContent = marker.value;
const description = document.createElement("span");
description.textContent = marker.description;
option.append(value, description);
option.addEventListener("pointerenter", () => {
if (!option.hidden) setActive(index);
});
option.addEventListener("pointerdown", event => event.preventDefault());
option.addEventListener("click", () => select(index));
listbox.append(option);
return {
...marker,
element: option,
search: `${marker.marker} ${marker.value}`.toLocaleLowerCase(),
};
});
listbox.append(empty);
let open = false;
let showAll = false;
let active = -1;
let composing = false;
let committedValue = "";
function updateLabel(value) {
setAttributeIfChanged(toggle, "aria-label", `${open ? "Hide" : "Show"} marker suggestions for ${value}`);
setAttributeIfChanged(listbox, "aria-label", `Marker suggestions for ${value}`);
}
function setActive(index) {
active = index;
for (const [optionIndex, option] of options.entries()) {
option.element.setAttribute("aria-selected", `${open && optionIndex === active}`);
}
if (open && active >= 0) {
editor.setAttribute("aria-activedescendant", options[active].element.id);
options[active].element.scrollIntoView?.({ block: "nearest" });
} else {
editor.removeAttribute("aria-activedescendant");
}
}
function visibleOptions() {
return options.map((option, index) => option.element.hidden ? -1 : index).filter(index => index >= 0);
}
function refreshOptions() {
const selection = markerSelection(editor.value, editor.selectionStart, editor.selectionEnd);
const query = showAll ? "" : selection.query.toLocaleLowerCase();
for (const option of options) option.element.hidden = query !== "" && !option.search.includes(query);
const visible = visibleOptions();
empty.hidden = visible.length !== 0;
if (!visible.includes(active)) {
const exact = visible.find(index => options[index].value === editor.value);
setActive(exact ?? visible[0] ?? -1);
} else {
setActive(active);
}
if (visible.length === 0) {
status.textContent = query
? `No marker suggestions match ${selection.query}. Keep typing to use this text as-is.`
: "No marker suggestions are available.";
} else {
status.textContent = `${visible.length} marker suggestion${visible.length === 1 ? "" : "s"} available. Use Arrow keys and Enter to select.`;
}
}
function setOpen(value, all = false) {
open = value;
showAll = value && all;
editor.setAttribute("aria-expanded", `${open}`);
toggle.setAttribute("aria-expanded", `${open}`);
listbox.hidden = !open;
updateLabel(labelText);
if (open) refreshOptions();
else {
status.textContent = "";
setActive(-1);
}
}
function commit() {
if (editor.value === committedValue) return;
onValue(editor.value);
}
function select(index) {
if (index < 0 || options[index].element.hidden) return;
const selection = markerSelection(editor.value, editor.selectionStart, editor.selectionEnd);
const value = options[index].value;
editor.value = `${editor.value.slice(0, selection.start)}${value}${editor.value.slice(selection.end)}`;
const caret = selection.start + value.length;
editor.setSelectionRange(caret, caret);
commit();
setOpen(false);
editor.focus({ preventScroll: true });
}
function moveActive(offset) {
const visible = visibleOptions();
if (visible.length === 0) return;
const position = visible.indexOf(active);
let next = 0;
if (position >= 0) next = (position + offset + visible.length) % visible.length;
else if (offset < 0) next = visible.length - 1;
setActive(visible[next]);
}
function moveActiveToEdge(last) {
const visible = visibleOptions();
setActive(last ? visible.at(-1) ?? -1 : visible[0] ?? -1);
}
function handleOpenKey(event) {
if (!open) return false;
if (event.key === "Tab") {
setOpen(false);
return false;
}
switch (event.key) {
case "Home":
moveActiveToEdge(false);
return true;
case "End":
moveActiveToEdge(true);
return true;
case "Enter":
if (event.shiftKey || active < 0) return false;
select(active);
return true;
case "Escape":
setOpen(false);
return true;
default:
return false;
}
}
function handleKeydown(event) {
if (composing || event.isComposing) return;
let handled = false;
if (event.altKey && event.key === "ArrowUp" && open) {
setOpen(false);
handled = true;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
if (!open) setOpen(true, true);
else moveActive(event.key === "ArrowDown" ? 1 : -1);
handled = true;
} else {
handled = handleOpenKey(event);
}
if (handled) event.preventDefault();
}
editor.addEventListener("input", event => {
showAll = false;
if (!composing && !event.isComposing) commit();
if (!open) setOpen(true);
else refreshOptions();
});
editor.addEventListener("compositionstart", () => { composing = true; });
editor.addEventListener("compositionend", () => {
composing = false;
commit();
if (!open) setOpen(true);
else refreshOptions();
});
editor.addEventListener("keydown", handleKeydown);
toggle.addEventListener("pointerdown", event => event.preventDefault());
toggle.addEventListener("click", () => {
const wasOpen = open;
editor.focus({ preventScroll: true });
setOpen(!wasOpen, true);
});
root.addEventListener("focusout", () => {
document.defaultView.queueMicrotask(() => {
if (root.contains(document.activeElement)) return;
if (composing) {
composing = false;
commit();
}
setOpen(false);
});
});
updateLabel(labelText);
return {
root,
editor,
error,
close: () => setOpen(false),
update({ value, label, errors }) {
labelText = label;
committedValue = value;
updateLabel(label);
if (editor !== document.activeElement && editor.value !== value) editor.value = value;
const message = errors.join("\n");
if (error.textContent !== message) error.textContent = message;
setAttributeIfChanged(editor, "aria-invalid", `${errors.length > 0}`);
if (open) refreshOptions();
},
};
}
export function createAnkiSettingsController({
document,
readConfig,
editConfig,
send,
capabilities = { screenshot: true },
readOwnerKey = () => "",
}) {
const { ANKI_FIELDS, ANKI_OVERWRITE_MODES, normaliseAnkiConnectUrl } = document.defaultView.HDReaderOptions;
const element = id => document.getElementById(id);
const selects = new WeakMap();
let discovery = null;
let discoveryKey = null;
let pendingPreset = null;
let presetModel = null;
let requestSequence = 0;
let requestedKey = null;
let loading = false;
let apiKeyPanelInitialized = false;
let findingSetup = false;
let setupRequestSequence = 0;
let setupSnapshot = null;
let ownerKey = String(readOwnerKey() ?? "");
const templateRows = new Map();
let nextTemplateId = 0;
const connectionKey = config => JSON.stringify([config.model, config.apiKey, config.url]);
function setApiKeyExpanded(expanded) {
setAttributeIfChanged(element("anki-api-key-toggle"), "aria-expanded", `${expanded}`);
if (element("anki-api-key-panel").hidden !== !expanded) element("anki-api-key-panel").hidden = !expanded;
}
if (!capabilities.screenshot) {
element("opt-anki-screenshot").disabled = true;
element("anki-screenshot-help").textContent = "Page screenshots are unavailable in this overlay. Screenshot fields stay empty.";
}
function change(patch) {
if (Object.hasOwn(patch, "fields") || Object.hasOwn(patch, "fieldTemplates")) pendingPreset = null;
editConfig({ ...readConfig(), ...patch });
render();
}
function currentFields() {
const config = readConfig();
return discovery?.model === config.model && discoveryKey === connectionKey(config) ? discovery.fields : [];
}
function templatePresentation(config = readConfig()) {
const fields = currentFields();
const resolved = resolveAnkiTemplates(config, fields);
if (config.fieldTemplates !== null) {
return {
resolved,
entries: [...Object.entries(resolved.templates),
...resolved.staleFields.map(field => [field, config.fieldTemplates[field]])],
unavailable: new Set(resolved.staleFields),
};
}
const names = [...fields];
const folded = new Set(names.map(field => field.toLowerCase()));
for (const semantic of ANKI_FIELDS) {
const field = config.fields[semantic];
if (field && !folded.has(field.toLowerCase())) {
names.push(field);
folded.add(field.toLowerCase());
}
}
const display = resolveAnkiTemplates(config, names);
const available = ankiFieldNames(fields);
return {
resolved,
entries: Object.entries(display.templates),
unavailable: new Set(names.filter(field => !available.has(field.toLowerCase()))),
};
}
function materializeTemplates() {
return Object.fromEntries(templatePresentation().entries.map(([field, template]) => [field, { ...template }]));
}
function editTemplate(field, patch) {
const templates = materializeTemplates();
templates[field] = { ...templates[field], ...patch };
change({ fieldTemplates: templates });
}
function createTemplateRow(field) {
const row = document.createElement("div");
row.className = "anki-template-row";
row.innerHTML = `<div class="anki-template-heading"><span class="anki-field-index" aria-hidden="true"></span><label class="field-label"></label><button type="button" class="ghost">Remove unavailable field</button></div>
<label class="anki-template-mode"><span>On overwrite</span><select></select></label>`;
const label = row.querySelector(".field-label"), mode = row.querySelector("select");
const indexBadge = row.querySelector(".anki-field-index");
const id = `opt-anki-template-${++nextTemplateId}`;
let record;
const combobox = createMarkerCombobox(document, id, field, value => editTemplate(record.field, { value }));
const editor = combobox.editor;
row.querySelector(".anki-template-mode").before(combobox.root);
label.htmlFor = editor.id;
label.id = `${editor.id}-label`;
label.textContent = field;
mode.id = `${editor.id}-mode`;
mode.setAttribute("aria-label", `On overwrite: ${field}`);
const names = { coalesce: "Keep existing, fill empty", "coalesce-new": "Use new, keep if empty", skip: "Keep existing",
append: "Append", prepend: "Prepend", overwrite: "Replace" };
for (const value of ANKI_OVERWRITE_MODES) mode.add(new document.defaultView.Option(names[value], value));
const remove = row.querySelector("button");
remove.setAttribute("aria-label", `Remove unavailable field: ${field}`);
record = { field, row, label, editor, combobox, mode, remove, indexBadge, modeLabel: mode.parentElement };
mode.addEventListener("change", () => editTemplate(record.field, { overwriteMode: mode.value }));
remove.addEventListener("click", () => {
const templates = materializeTemplates();
delete templates[record.field];
change({ fieldTemplates: templates });
element("anki-apply-preset").focus();
});
return record;
}
function updateTemplateRow(row, template, showMode, unavailable, index) {
const errors = ankiTemplateErrors(template.value);
row.combobox.update({ value: template.value, label: row.field, errors });
if (row.mode !== document.activeElement && row.mode.value !== template.overwriteMode) row.mode.value = template.overwriteMode;
if (row.modeLabel.hidden === showMode) row.modeLabel.hidden = !showMode;
if (row.remove.hidden === unavailable) row.remove.hidden = !unavailable;
if (row.row.dataset.ankiField !== row.field) row.row.dataset.ankiField = row.field;
const displayIndex = String(index + 1).padStart(2, "0");
if (row.indexBadge.textContent !== displayIndex) row.indexBadge.textContent = displayIndex;
const unmapped = template.value.trim() === "";
if (row.row.classList.contains("is-unmapped") !== unmapped) row.row.classList.toggle("is-unmapped", unmapped);
}
function renderTemplates(config, presentation) {
const templates = presentation.entries;
const retained = new Set(templates.map(([field]) => field));
const showMode = config.duplicateBehavior === "overwrite";
const renamedRows = new Map([...templateRows].filter(([field]) => !retained.has(field))
.map(([field, row]) => [field.toLowerCase(), row]));
const container = element("anki-templates");
for (const [index, [field, template]] of templates.entries()) {
if (!templateRows.has(field)) {
const previous = renamedRows.get(field.toLowerCase());
if (previous) adoptRenamedRow(previous, field, renamedRows);
templateRows.set(field, previous || createTemplateRow(field));
}
updateTemplateRow(templateRows.get(field), template, showMode, presentation.unavailable.has(field), index);
}
for (const [field, row] of templateRows) {
if (!retained.has(field)) { row.row.remove(); templateRows.delete(field); }
}
reorderSettingsRows(container, templates.map(([field]) => templateRows.get(field).row));
filterTemplateRows(templates.map(([field]) => field));
const canApply = !loading && currentFields().length > 0;
if (element("anki-apply-preset").disabled === canApply) element("anki-apply-preset").disabled = !canApply;
}
function adoptRenamedRow(previous, field, renamedRows) {
templateRows.delete(previous.field);
renamedRows.delete(field.toLowerCase());
previous.field = field;
previous.label.textContent = field;
previous.mode.setAttribute("aria-label", `On overwrite: ${field}`);
previous.remove.setAttribute("aria-label", `Remove unavailable field: ${field}`);
}
// Rows are hidden, never dropped, so the mapping keeps Anki's field order.
function filterTemplateRows(fields) {
const query = element("anki-field-filter").value.trim().toLocaleLowerCase();
let visible = 0;
for (const field of fields) {
const row = templateRows.get(field).row;
const hidden = query !== "" && !field.toLocaleLowerCase().includes(query);
if (row.hidden !== hidden) row.hidden = hidden;
if (!hidden) visible += 1;
}
const count = query === "" ? `Showing ${fields.length} fields` : `Showing ${visible} of ${fields.length} fields`;
if (element("anki-field-count").textContent !== count) element("anki-field-count").textContent = count;
}
function optionGroup(label, choices, optionLabel) {
const group = document.createElement("optgroup");
group.label = label;
group.append(...choices.map(name => new document.defaultView.Option(optionLabel(name), name)));
return group;
}
// A saved value that discovery no longer lists stays selectable as "(unavailable)".
function selectChoices(id, names, value, placeholder, { suggested = "", allLabel, labels = {} }) {
const select = element(id);
if (select === document.activeElement) return;
const key = JSON.stringify([names, value, suggested, allLabel, labels]);
if (selects.get(select) === key) return;
const optionLabel = name => {
if (Object.hasOwn(labels, name)) return labels[name];
return names.includes(name) ? name : `${name} (unavailable)`;
};
const groups = [];
if (suggested) groups.push(optionGroup("Suggested", [suggested], name => `Suggested: ${optionLabel(name)}`));
const rest = names.filter(name => name !== suggested);
if (value && !names.includes(value) && value !== suggested) rest.push(value);
groups.push(optionGroup(allLabel, rest, optionLabel));
select.replaceChildren(new document.defaultView.Option(placeholder, ""), ...groups);
select.value = value;
selects.set(select, key);
}
function renderDuplicateScope(config) {
const select = element("opt-anki-duplicate-scope");
if (select === document.activeElement) return;
const choices = [
["model", `Note type: ${config.model || "Choose a note type"}`],
["deck", `Deck: ${config.deck || "Choose a deck"}`],
["all", "All of Anki"],
];
const key = JSON.stringify(choices);
if (selects.get(select) !== key) {
select.replaceChildren(...choices.map(([value, label]) => new document.defaultView.Option(label, value)));
selects.set(select, key);
}
select.value = config.duplicateScope;
}
function renderStatus(config, resolved) {
const status = element("anki-status");
// A URL/API-key/model edit retires the old discovery immediately. Its
// fields no longer match `resolved`, and must not be rendered while the
// replacement request (or a linked host-side save) is still pending.
const currentDiscovery = discoveryKey === connectionKey(config) ? discovery : null;
const errors = ankiAvailability(config, currentDiscovery, resolved);
const connected = currentDiscovery?.connected === true;
let state = "Not connected";
if (connected) state = errors.length ? "Connected · configuration needs attention" : "Connected · configuration ready";
const message = loading ? "Checking AnkiConnect…" : [state, ...errors].join("\n");
let tone;
let connection = "offline";
if (loading) {
tone = "working";
connection = "checking";
} else if (connected) {
tone = errors.length ? "error" : "ready";
connection = "connected";
} else if (errors.length) {
tone = "error";
}
setAttributeIfChanged(status, "data-state", connection);
setStatusOutput(status, message, tone);
if (element("anki-refresh").disabled !== loading) element("anki-refresh").disabled = loading;
}
function setupStatus(message, tone) {
const status = element("anki-setup-status");
status.hidden = message === "";
setStatusOutput(status, message, tone);
}
function syncOwner() {
const next = String(readOwnerKey() ?? "");
if (next === ownerKey) return;
ownerKey = next;
pendingPreset = null;
setupSnapshot = null;
setupRequestSequence += 1;
findingSetup = false;
for (const row of templateRows.values()) row.combobox.close();
element("anki-find-setup").disabled = false;
setupStatus("");
}
async function findSetup() {
if (findingSetup || commitConnectionUrl() === null) return;
syncOwner();
const config = readConfig();
const snapshot = JSON.stringify(config);
const requestOwner = ownerKey;
const sequence = ++setupRequestSequence;
findingSetup = true;
element("anki-find-setup").disabled = true;
setupStatus("Finding your Anki setup…", "working");
try {
const reply = await send("hd_anki_setup", {
anki: config,
...(ownerKey === "" ? {} : { templateId: ownerKey }),
});
if (sequence !== setupRequestSequence || requestOwner !== String(readOwnerKey() ?? "")) return;
if (snapshot !== JSON.stringify(readConfig())) {
throw new Error("Anki settings changed while checking. Your changes were kept; retry to check them.");
}
if (!reply.ok) throw new Error(reply.error || "Anki setup discovery did not reply.");
const { proposal, outcome } = reply;
if (proposal?.status === "configured") {
change({ model: proposal.model, deck: proposal.deck, fieldTemplates: proposal.fieldTemplates });
setupStatus(`Found ${outcome.model} in deck ${outcome.deck}. Changes save automatically.`, "ready");
} else if (outcome.status === "already-configured") {
setupStatus(`Your saved ${outcome.model} setup for deck ${outcome.deck} is ready.`, "ready");
} else {
setupStatus(outcome.detail, "error");
}
} catch (error) {
if (sequence !== setupRequestSequence || requestOwner !== String(readOwnerKey() ?? "")) return;
setupStatus(error.message, "error");
} finally {
if (sequence === setupRequestSequence && requestOwner === String(readOwnerKey() ?? "")) {
findingSetup = false;
setupSnapshot = JSON.stringify(readConfig());
element("anki-find-setup").disabled = false;
}
}
}
async function refresh() {
const config = readConfig();
const key = connectionKey(config);
requestedKey = key;
const sequence = ++requestSequence;
loading = true;
renderStatus(config);
element("anki-apply-preset").disabled = true;
try {
const reply = await send("hd_anki_discover", { model: config.model, apiKey: config.apiKey, url: config.url });
if (sequence !== requestSequence || key !== connectionKey(readConfig())) return;
if (!reply.ok) throw new Error(reply.error);
discovery = reply;
discoveryKey = key;
if (pendingPreset?.key === key && pendingPreset.owner === ownerKey && reply.connected && reply.fields.length > 0) {
const preset = pendingPreset;
pendingPreset = null;
const current = readConfig();
if (current.fieldTemplates === null && JSON.stringify(current.fields) === preset.fields) {
editConfig(applyAnkiPreset(current, reply.fields, preset.family));
}
}
} catch (error) {
if (sequence !== requestSequence || key !== connectionKey(readConfig())) return;
discovery = { connected: false, model: config.model, decks: [], models: [], fields: [], errors: [error.message] };
discoveryKey = key;
} finally {
if (sequence === requestSequence && key === connectionKey(readConfig())) {
loading = false;
render();
}
}
}
const controls = [
["tags", "opt-anki-tags"], ["apiKey", "opt-anki-api-key"],
["duplicateScope", "opt-anki-duplicate-scope"], ["duplicateBehavior", "opt-anki-duplicate-behavior"],
["captureScreenshot", "opt-anki-screenshot"],
];
function renderFormControls(config) {
const values = { ...config, captureScreenshot: capabilities.screenshot && config.captureScreenshot };
for (const [key, id] of controls) {
const control = element(id);
if (control === document.activeElement) continue;
if (control.type === "checkbox") control.checked = values[key];
else control.value = key === "tags" ? values.tags.join(" ") : values[key];
}
}
function render() {
syncOwner();
const config = readConfig();
if (!apiKeyPanelInitialized) {
apiKeyPanelInitialized = true;
setApiKeyExpanded(config.apiKey !== "");
}
if (!findingSetup && setupSnapshot !== null && setupSnapshot !== JSON.stringify(config)) {
setupSnapshot = null;
setupStatus("");
}
if (pendingPreset && (pendingPreset.key !== connectionKey(config) || pendingPreset.owner !== ownerKey)) pendingPreset = null;
if (presetModel !== config.model) {
presetModel = config.model;
element("anki-preset").value = ankiSetupFamily(config.model) || "automatic";
}
const models = discovery?.models || [];
const suggestedModel = ankiSetupFamily(config.model)
? config.model
: models.find(model => ankiSetupFamily(model)) || "";
const modelLabels = {};
if (discoveryKey === connectionKey(config) && discovery?.connected && discovery.model === config.model
&& models.includes(config.model)) {
modelLabels[config.model] = `${config.model} (${discovery.fields.length} fields)`;
}
selectChoices("opt-anki-deck", discovery?.decks || [], config.deck, "Choose a deck", {
suggested: config.deck,
allLabel: "All decks",
});
selectChoices("opt-anki-model", models, config.model, "Choose a note type", {
suggested: suggestedModel,
allLabel: "All note types",
labels: modelLabels,
});
renderDuplicateScope(config);
const url = element("opt-anki-url");
if (url !== document.activeElement && !url.validity.customError && url.value !== config.url) url.value = config.url;
renderFormControls(config);
const presentation = templatePresentation(config);
renderStatus(config, presentation.resolved);
renderTemplates(config, presentation);
if (connectionKey(config) !== requestedKey) void refresh();
}
element("opt-anki-deck").addEventListener("change", event => change({ deck: event.target.value }));
element("opt-anki-model").addEventListener("change", event => {
if (event.target.value === readConfig().model) return;
const next = { ...readConfig(), model: event.target.value,
fields: Object.fromEntries(ANKI_FIELDS.map(key => [key, ""])), fieldTemplates: null };
const family = ankiSetupFamily(next.model);
pendingPreset = family
? { family, key: connectionKey(next), fields: JSON.stringify(next.fields), owner: ownerKey }
: null;
editConfig(next);
render();
});
for (const [key, id] of controls) {
element(id).addEventListener("change", event => {
const value = event.target.type === "checkbox" ? event.target.checked : event.target.value;
change({ [key]: key === "tags" ? value.split(/\s+/u).filter(Boolean) : value });
});
}
element("anki").addEventListener("focusout", () => queueMicrotask(render));
function commitConnectionUrl() {
const input = element("opt-anki-url");
const url = normaliseAnkiConnectUrl(input.value);
const error = url ? "" : "Enter a valid HTTP or HTTPS AnkiConnect URL without a username or password.";
input.setCustomValidity(error);
element("anki-url-error").textContent = error;
if (!url) return null;
input.value = url;
if (url === readConfig().url) return false;
change({ url });
return true;
}
element("opt-anki-url").addEventListener("change", commitConnectionUrl);
element("anki-api-key-toggle").addEventListener("click", event => {
setApiKeyExpanded(event.currentTarget.getAttribute("aria-expanded") !== "true");
});
element("anki-refresh").addEventListener("click", () => {
// A changed URL starts discovery through render; don't start it twice.
if (commitConnectionUrl() === false) void refresh();
});
element("anki-find-setup").addEventListener("click", () => { void findSetup(); });
element("anki-preset").addEventListener("change", () => { pendingPreset = null; });
element("anki-field-filter").addEventListener("input", () => {
const config = readConfig();
renderTemplates(config, templatePresentation(config));
});
element("anki-apply-preset").addEventListener("click", () => {
pendingPreset = null;
editConfig(applyAnkiPreset(readConfig(), currentFields(), element("anki-preset").value));
render();
});
return { render, refresh };
}
export function createAnkiTemplateSettingsController({
document,
readAnki,
editAnki,
readButtons = () => [],
send,
capabilities = { screenshot: true },
createId = () => document.defaultView.crypto.randomUUID(),
}) {
const {
ANKI_TEMPLATE_CONFIG_KEYS,
DEFAULT_ANKI_TEMPLATE,
ankiTemplateConfig,
normaliseAnki,
} = document.defaultView.HDReaderOptions;
const element = id => document.getElementById(id);
let selectedId = null;
let renderedChoices = null;
function currentAnki() {
return normaliseAnki(readAnki());
}
function selected(anki = currentAnki()) {
let index = anki.templates.findIndex(template => template.id === selectedId);
if (index < 0) {
index = 0;
selectedId = anki.templates[0].id;
}
return { anki, index, template: anki.templates[index] };
}
function saveTemplates(anki, templates) {
editAnki(normaliseAnki({ url: anki.url, apiKey: anki.apiKey, templates }));
}
function editSelectedConfig(config) {
const { anki, template } = selected();
const templates = anki.templates.map(value => value.id === template.id
? {
id: value.id,
name: value.name,
...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, config[key]])),
}
: value);
editAnki(normaliseAnki({ url: config.url, apiKey: config.apiKey, templates }));
}
const editor = createAnkiSettingsController({
document,
readConfig: () => {
const { anki, template } = selected();
return ankiTemplateConfig(anki, template.id);
},
editConfig: editSelectedConfig,
send,
capabilities,
readOwnerKey: () => selected().template.id,
});
function uniqueId() {
const ids = new Set(currentAnki().templates.map(template => template.id));
let id;
do id = createId();
while (typeof id !== "string" || id === "" || ids.has(id));
return id;
}
function uniqueName(base = "Template") {
const names = new Set(currentAnki().templates.map(template => template.name));
if (!names.has(base)) return base;
for (let suffix = 2; ; suffix++) {
const name = `${base} ${suffix}`;
if (!names.has(name)) return name;
}
}
function setSelected(id, focus = false) {
selectedId = id;
element("anki-template-status").textContent = "";
render();
if (focus) element("anki-template-select").focus();
}
function reorder(anki, index, destination) {
if (destination < 0 || destination >= anki.templates.length || destination === index) return false;
const templates = anki.templates.slice();
const [template] = templates.splice(index, 1);
templates.splice(destination, 0, template);
saveTemplates(anki, templates);
render();
return true;
}
function move(offset) {
const { anki, index } = selected();
if (!reorder(anki, index, index + offset)) return;
const preferred = element(offset < 0 ? "anki-template-up" : "anki-template-down");
let focusTarget = preferred;
if (preferred.disabled) {
focusTarget = element(offset < 0 ? "anki-template-down" : "anki-template-up");
}
focusTarget.focus();
}
function setBuiltin() {
const { anki, index } = selected();
reorder(anki, index, 0);
}
function add() {
const anki = currentAnki();
const template = {
...DEFAULT_ANKI_TEMPLATE,
id: uniqueId(),
name: uniqueName(),
tags: [...DEFAULT_ANKI_TEMPLATE.tags],
fields: { ...DEFAULT_ANKI_TEMPLATE.fields },
fieldTemplates: null,
};
selectedId = template.id;
saveTemplates(anki, [...anki.templates, template]);
render();
element("opt-anki-template-name").focus();
element("opt-anki-template-name").select();
}
function duplicate() {
const { anki, index, template } = selected();
const copy = normaliseAnki({
url: anki.url,
apiKey: anki.apiKey,
templates: [{ ...template, id: uniqueId(), name: uniqueName(`${template.name} copy`) }],
}).templates[0];
selectedId = copy.id;
saveTemplates(anki, [...anki.templates.slice(0, index + 1), copy, ...anki.templates.slice(index + 1)]);
render();
element("opt-anki-template-name").focus();
element("opt-anki-template-name").select();
}
function remove() {
const { anki, index, template } = selected();
if (anki.templates.length === 1) return;
const references = readButtons().filter(button => button.type === "anki" && button.templateId === template.id);
if (references.length > 0) {
let usage = `${references.length} custom buttons`;
if (references.length === 1) usage = `the “${references[0].label}” custom button`;
element("anki-template-status").textContent =
`“${template.name}” is used by ${usage}. Choose another Template for those buttons before deleting it.`;
return;
}
const templates = anki.templates.filter(value => value.id !== template.id);
selectedId = templates[Math.min(index, templates.length - 1)].id;
saveTemplates(anki, templates);
render();
element("anki-template-select").focus();
}
function handleTemplateSelection(event) {
setSelected(event.currentTarget.value);
}
function renderChoices(anki) {
const select = element("anki-template-select");
const pills = element("anki-template-pills");
const key = JSON.stringify(anki.templates.map(({ id, name }) => [id, name]));
if (renderedChoices !== key) {
select.replaceChildren(...anki.templates.map(template => new document.defaultView.Option(template.name, template.id)));
pills.replaceChildren(...anki.templates.map((template, index) => {
const pill = document.createElement("button");
pill.type = "button";
pill.className = "anki-template-pill";
pill.value = template.id;
pill.append(template.name);
if (index === 0) {
const badge = document.createElement("span");
badge.className = "anki-template-pill-badge";
badge.textContent = "Built-in";
pill.append(badge);
}
pill.addEventListener("click", handleTemplateSelection);
return pill;
}));
renderedChoices = key;
}
select.value = selectedId;
for (const pill of pills.children) pill.setAttribute("aria-pressed", `${pill.value === selectedId}`);
}
function renderManager() {
const { anki, index, template } = selected();
renderChoices(anki);
const name = element("opt-anki-template-name");
if (name !== document.activeElement && name.value !== template.name) name.value = template.name;
element("anki-template-position").textContent = `${index + 1} of ${anki.templates.length}`;
element("anki-template-role").hidden = index !== 0;
element("anki-template-previous").disabled = index === 0;
element("anki-template-next").disabled = index === anki.templates.length - 1;
element("anki-template-up").disabled = index === 0;
element("anki-template-down").disabled = index === anki.templates.length - 1;
element("anki-template-set-builtin").disabled = index === 0;
element("anki-template-delete").disabled = anki.templates.length === 1;
}
function render() {
renderManager();
editor.render();
}
element("anki-template-select").addEventListener("change", handleTemplateSelection);
element("anki-template-previous").addEventListener("click", () => {
const { anki, index } = selected();
if (index > 0) setSelected(anki.templates[index - 1].id, true);
});
element("anki-template-next").addEventListener("click", () => {
const { anki, index } = selected();
if (index + 1 < anki.templates.length) setSelected(anki.templates[index + 1].id, true);
});
element("opt-anki-template-name").addEventListener("input", () => {
element("anki-template-status").textContent = "";
});
element("opt-anki-template-name").addEventListener("change", event => {
const name = event.target.value.trim();
if (!name || /[\u0000-\u001f\u007f]/u.test(name)) {
element("anki-template-status").textContent = "Enter a name for the Template.";
return;
}
const { anki, template } = selected();
if (name === template.name) return;
saveTemplates(anki, anki.templates.map(value => value.id === template.id ? { ...value, name } : value));
render();
});
element("anki-template-add").addEventListener("click", add);
element("anki-template-duplicate").addEventListener("click", duplicate);
element("anki-template-set-builtin").addEventListener("click", setBuiltin);
element("anki-template-up").addEventListener("click", () => move(-1));
element("anki-template-down").addEventListener("click", () => move(1));
element("anki-template-delete").addEventListener("click", remove);
return {
render,
refresh: () => editor.refresh(),
dirty: () => {
const { template } = selected();
return element("opt-anki-template-name").value !== template.name;
},
};
}