// 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 = `
`;
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 = `
`;
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;
},
};
}