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
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+721
View File
@@ -0,0 +1,721 @@
// SPDX-License-Identifier: GPL-3.0-or-later
(function () {
"use strict";
const CAPTURE_TARGET = "hachidori-capture";
const CONTENT_TARGET = "hachidori-capture-content";
const MAX_TEXT_LENGTH = 4096;
const MAX_LINES = 1000;
const TYPEWRITER_GAP_MS = 750;
const TYPEWRITER_GROWTH_LIMIT = 12;
const INLINE_DISPLAY_PATTERN = /^(?:inline|ruby|contents)/u;
const OMIT_TEXT_SELECTOR = [
"button", "input", "select", "textarea", "[contenteditable]",
"rt", "rp", "script", "style", "noscript", "hachidori-host",
].join(",");
const videoIds = new WeakMap();
const trackIds = new WeakMap();
const cueIds = new WeakMap();
let nextVideoId = 0;
let nextTrackId = 0;
let nextCueId = 0;
let nextRequestId = 0;
let nextLineId = 0;
let linked = false;
let linkedDocumentId = null;
let options = null;
let documentEpoch = crypto.randomUUID();
let selectedVideoCleanup = null;
let trackedElement = null;
let trackedEpoch = "";
let trackedLines = [];
let trackedObserver = null;
let trackedVisibilityObserver = null;
let trackedMountObserver = null;
let trackedQueued = false;
let pickerCleanup = null;
let rootPin = null;
let rootPinTail = Promise.resolve();
const now = () => performance.timeOrigin + performance.now();
const normalize = value => typeof value === "string"
? value.normalize("NFC").replace(/\s+/gu, " ").trim() : "";
async function send(type, fields = {}) {
const reply = await chrome.runtime.sendMessage({
target: CAPTURE_TARGET,
type,
requestId: `capture-content-${++nextRequestId}`,
...fields,
});
if (!reply?.ok) throw new Error(reply?.error || "The capture service did not reply.");
return reply;
}
async function identify(captureSessionId) {
return send("hd_capture_content_identify", { captureSessionId });
}
function report(message, fields = {}) {
void send("hd_capture_page_status", { message, ...fields }).catch(() => {});
}
function videoId(video) {
let id = videoIds.get(video);
if (!id) {
id = `video-${++nextVideoId}`;
videoIds.set(video, id);
}
return id;
}
function videoTracks(video) {
const tracks = [];
try {
for (const track of video.textTracks ?? []) {
if (track.mode !== "disabled") tracks.push(track);
}
} catch {
return [];
}
return tracks;
}
function activeTrackCues(track) {
try {
return [...(track.activeCues ?? [])];
} catch {
return [];
}
}
function trackId(track) {
let id = trackIds.get(track);
if (!id) {
id = `track-${++nextTrackId}`;
trackIds.set(track, id);
}
return id;
}
function videos() {
return [...document.querySelectorAll("video")].map((video, index) => ({
id: videoId(video),
label: String(video.getAttribute("aria-label") || video.title
|| `Video ${index + 1} (${video.videoWidth || "?"}×${video.videoHeight || "?"})`).slice(0, 200),
trackCount: videoTracks(video).length,
}));
}
function cueId(cue) {
if (cue.id) return String(cue.id).slice(0, 160);
let id = cueIds.get(cue);
if (!id) {
id = `cue-${++nextCueId}`;
cueIds.set(cue, id);
}
return id;
}
function emitBegin(record) {
void send("hd_capture_text_begin", { record }).catch(() => {});
}
function emitClose(identity, endMs = now()) {
void send("hd_capture_text_close", { identity, endMs }).catch(() => {});
}
function attachVideo(video) {
selectedVideoCleanup?.();
selectedVideoCleanup = null;
if (!video) return;
const attachmentEpoch = crypto.randomUUID();
let epochCounter = 0;
let sourceEpoch = "";
let interrupted = true;
const active = new Map();
const cleanups = [];
const trackCleanups = new Map();
function identity(id) {
return { sourceKind: "cue", sourceEpoch, occurrenceId: id };
}
function closeAll(at = now()) {
for (const id of active.keys()) emitClose(identity(id), at);
active.clear();
}
function resetEpoch(at = now()) {
closeAll(at);
sourceEpoch = `video:${videoId(video)}:${attachmentEpoch}:${++epochCounter}`;
}
function sync(onsetKnown) {
const at = now();
if (video.paused || video.seeking || video.ended || !video.isConnected) {
closeAll(at);
return;
}
const next = new Map();
for (const track of videoTracks(video)) {
for (const cue of activeTrackCues(track)) {
const text = String(cue.text ?? "");
if (!normalize(text) || text.length > MAX_TEXT_LENGTH) continue;
const id = `${trackId(track)}:${cueId(cue)}`;
next.set(id, text);
if (active.get(id) !== text) {
emitBegin({
sourceKind: "cue",
sourceEpoch,
occurrenceId: id,
text,
startMs: at,
onsetKnown,
});
}
}
}
for (const id of active.keys()) if (!next.has(id)) emitClose(identity(id), at);
active.clear();
for (const entry of next) active.set(...entry);
}
function listen(target, type, listener) {
target.addEventListener(type, listener);
cleanups.push(() => target.removeEventListener(type, listener));
}
function bindTracks() {
const available = new Set(videoTracks(video));
for (const [track, cleanup] of trackCleanups) {
if (available.has(track)) continue;
cleanup();
trackCleanups.delete(track);
}
for (const track of available) {
if (trackCleanups.has(track)) continue;
const listener = () => sync(true);
track.addEventListener("cuechange", listener);
trackCleanups.set(track, () => track.removeEventListener("cuechange", listener));
}
}
function interrupt() {
closeAll(now());
interrupted = true;
}
function resume() {
if (video.paused || video.seeking || video.ended || !video.isConnected) return;
if (interrupted) resetEpoch();
interrupted = false;
sync(false);
}
function resetAndSync() {
resetEpoch();
bindTracks();
interrupted = video.paused || video.seeking || video.ended;
if (!interrupted) sync(false);
}
resetEpoch();
bindTracks();
listen(video, "play", resume);
listen(video, "pause", interrupt);
listen(video, "seeking", interrupt);
listen(video, "seeked", resetAndSync);
listen(video, "ended", interrupt);
listen(video, "emptied", () => { resetEpoch(); interrupted = true; });
listen(video, "loadedmetadata", resetAndSync);
if (video.textTracks?.addEventListener) {
listen(video.textTracks, "addtrack", resetAndSync);
listen(video.textTracks, "removetrack", resetAndSync);
listen(video.textTracks, "change", resetAndSync);
}
resume();
selectedVideoCleanup = () => {
closeAll();
for (const cleanup of trackCleanups.values()) cleanup();
trackCleanups.clear();
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
};
}
function renderedElement(element, requireLayout = false, style = null) {
if (!element?.isConnected || element.hidden || element.getAttribute("aria-hidden") === "true"
|| element.closest("hachidori-host")) return false;
style ??= getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"
&& Number(style.opacity) !== 0 && (!requireLayout || element.getClientRects().length > 0);
}
function visible(element) {
if (!renderedElement(element, true)) return false;
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
if (!renderedElement(ancestor)) return false;
}
return true;
}
function validTrackedElement(element) {
return element instanceof Element && !["HTML", "BODY"].includes(element.tagName)
&& !element.closest("input, textarea, select, button, [contenteditable], hachidori-host");
}
function domRangeFor(boundaries, previous) {
if (previous?.startContainer === boundaries.startContainer && previous.startOffset === boundaries.startOffset
&& previous.endContainer === boundaries.endContainer && previous.endOffset === boundaries.endOffset) {
return previous;
}
const range = document.createRange();
range.setStart(boundaries.startContainer, boundaries.startOffset);
range.setEnd(boundaries.endContainer, boundaries.endOffset);
return range;
}
function extractLines(element) {
if (!visible(element)) return [];
const lines = [];
let text = "";
let range = null;
const lineBreak = () => {
const normalized = normalize(text);
if (normalized && lines.length < MAX_LINES) {
lines.push({ text: normalized.slice(0, MAX_TEXT_LENGTH),
range: domRangeFor(range, trackedLines[lines.length]?.range) });
}
text = "";
range = null;
};
function appendPart(node, value, offset) {
range ??= { startContainer: node, startOffset: offset };
range.endContainer = node;
range.endOffset = offset + value.length;
text += value;
}
function appendText(node) {
const value = node.nodeValue || "";
if (!/[\r\n]/u.test(value)) {
appendPart(node, value, 0);
return;
}
for (const part of value.matchAll(/[^\r\n]+|[\r\n]+/gu)) {
if (/^[\r\n]/u.test(part[0])) {
lineBreak();
continue;
}
appendPart(node, part[0], part.index);
}
}
function visit(node, root = false) {
if (node.nodeType === Node.TEXT_NODE) {
appendText(node);
return;
}
if (!(node instanceof Element) || (!root && node.matches(OMIT_TEXT_SELECTOR))) return;
const style = getComputedStyle(node);
if (!renderedElement(node, false, style)) return;
if (node.tagName === "BR") {
lineBreak();
return;
}
const block = !root && !INLINE_DISPLAY_PATTERN.test(style.display);
if (block) lineBreak();
for (const child of node.childNodes) visit(child);
if (block) lineBreak();
}
visit(element, true);
lineBreak();
return lines;
}
function domIdentity(line) {
return { sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id };
}
function closeTrackedLines(at = now()) {
for (const line of trackedLines) emitClose(domIdentity(line), at);
trackedLines = [];
}
function isTypewriterContinuation(previous, text, at) {
if (!previous || !text || text === previous.text || !text.startsWith(previous.text)) return false;
return Array.from(text.slice(previous.text.length)).length <= TYPEWRITER_GROWTH_LIMIT
&& at - previous.updatedMs <= TYPEWRITER_GAP_MS;
}
function lineIndexes(values) {
const indexesByText = new Map();
for (const [index, text] of values.entries()) {
const indexes = indexesByText.get(text) ?? [];
indexes.push(index);
indexesByText.set(text, indexes);
}
return indexesByText;
}
function retainedLinesFor(values) {
const previousByText = lineIndexes(trackedLines.map(line => line.text));
const nextByText = lineIndexes(values);
const retained = new Map();
const usedPrevious = new Set();
for (const [text, previousIndexes] of previousByText) {
const nextIndexes = nextByText.get(text);
if (previousIndexes.length !== 1 || nextIndexes?.length !== 1) continue;
retained.set(nextIndexes[0], trackedLines[previousIndexes[0]]);
usedPrevious.add(previousIndexes[0]);
}
return { previousByText, nextByText, retained, usedPrevious };
}
function refreshUnchangedLineRanges(extracted) {
if (extracted.length !== trackedLines.length
|| !extracted.every((line, index) => line.text === trackedLines[index].text)) return false;
for (const [index, line] of trackedLines.entries()) line.range = extracted[index].range;
return true;
}
function reconcileTracked(initial = false) {
trackedQueued = false;
if (!trackedElement) return;
if (!trackedElement.isConnected) {
clearTrackedArea(false);
report("The tracked text area was replaced. Select it again or use the next lookup to relearn it.",
{ tracked: false });
return;
}
const at = now();
const extracted = extractLines(trackedElement);
if (refreshUnchangedLineRanges(extracted)) return;
const values = extracted.map(line => line.text);
const { previousByText, nextByText, retained, usedPrevious } = retainedLinesFor(values);
const previousLastIndex = trackedLines.length - 1;
const nextLastIndex = values.length - 1;
const previousLast = trackedLines[previousLastIndex];
const nextLast = values[nextLastIndex];
let typewriter = null;
if (isTypewriterContinuation(previousLast, nextLast, at)
&& previousByText.get(previousLast.text)?.length === 1
&& nextByText.get(nextLast)?.length === 1
&& !usedPrevious.has(previousLastIndex) && !retained.has(nextLastIndex)) {
typewriter = { index: nextLastIndex, line: previousLast };
usedPrevious.add(previousLastIndex);
}
for (let index = 0; index < trackedLines.length; index += 1) {
if (!usedPrevious.has(index)) emitClose(domIdentity(trackedLines[index]), at);
}
const next = [];
for (let index = 0; index < values.length; index += 1) {
const text = values[index];
const range = extracted[index].range;
const previous = retained.get(index);
if (previous) {
next.push({ ...previous, range });
continue;
}
if (typewriter?.index === index) {
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: typewriter.line.id,
text, startMs: at, onsetKnown: !initial });
next.push({ ...typewriter.line, text, range, updatedMs: at });
continue;
}
const line = { id: `line-${++nextLineId}`, text, range, updatedMs: at };
emitBegin({ sourceKind: "dom", sourceEpoch: trackedEpoch, occurrenceId: line.id,
text, startMs: at, onsetKnown: !initial && !previousByText.has(text) });
next.push(line);
}
trackedLines = next;
}
function queueTrackedReconcile() {
if (trackedQueued) return;
trackedQueued = true;
queueMicrotask(() => reconcileTracked(false));
}
function clearTrackedArea(reportChange = true) {
trackedObserver?.disconnect();
trackedObserver = null;
trackedVisibilityObserver?.disconnect();
trackedVisibilityObserver = null;
trackedMountObserver?.disconnect();
trackedMountObserver = null;
closeTrackedLines();
trackedElement = null;
trackedEpoch = "";
if (reportChange) report("Tracked text area cleared.", { tracked: false });
}
function trackArea(element, manual = false) {
if (!validTrackedElement(element)) throw new Error("Choose a bounded, non-editable text area.");
clearTrackedArea(false);
trackedElement = element;
trackedEpoch = `dom:${documentEpoch}:${crypto.randomUUID()}`;
reconcileTracked(true);
trackedObserver = new MutationObserver(queueTrackedReconcile);
trackedObserver.observe(element, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
});
trackedVisibilityObserver = new MutationObserver(queueTrackedReconcile);
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
trackedVisibilityObserver.observe(ancestor, {
attributes: true,
attributeFilter: ["class", "style", "hidden", "aria-hidden"],
});
}
trackedMountObserver = new MutationObserver(queueTrackedReconcile);
for (let node = element; node.parentElement; node = node.parentElement) {
trackedMountObserver.observe(node.parentElement, { childList: true });
}
report(manual ? "Text area selected." : "Text area learned from the first lookup.", { tracked: true });
}
function learnArea(candidate) {
if (trackedElement || !linked || !options?.mediaCapture.page.domText
|| !options.mediaCapture.page.autoLearnArea) return;
let element = candidate?.anchor instanceof Element ? candidate.anchor : candidate?.anchor?.parentElement;
if (!validTrackedElement(element)) return;
let selected = element;
for (let depth = 0; depth < 3; depth += 1) {
const parent = selected.parentElement;
if (!validTrackedElement(parent)) break;
const textLength = (parent.textContent || "").length;
if (textLength > MAX_TEXT_LENGTH || parent.querySelectorAll("*").length > 100) break;
selected = parent;
}
try { trackArea(selected, false); } catch { /* Conservative auto-learning may decline the page. */ }
}
function occurrenceFor(candidate) {
if (!trackedElement || !candidate?.anchor || !trackedElement.contains(candidate.anchor)) return null;
// Keep the reader's DOM evidence local: a sentence or selection may occupy
// only part of a timed paragraph, and identical lines need their own identity.
const range = candidate.anchorRange ?? document.createRange();
if (!candidate.anchorRange) range.selectNodeContents(candidate.anchor);
const matches = trackedLines.filter(line => line.range?.comparePoint(range.startContainer, range.startOffset) === 0
&& line.range.comparePoint(range.endContainer, range.endOffset) === 0);
return matches.length === 1 ? { occurrenceId: matches[0].id, occurrenceSourceKind: "dom" } : null;
}
function blockPickerInput(event) {
event.preventDefault();
event.stopImmediatePropagation();
}
function picker() {
pickerCleanup?.();
let candidate = null;
const outline = document.createElement("div");
outline.setAttribute("aria-hidden", "true");
outline.style.cssText = [
"all: initial !important", "position: fixed !important", "pointer-events: none !important",
"z-index: 2147483647 !important", "border: 3px solid #36d399 !important",
"background: rgba(54,211,153,.12) !important", "box-sizing: border-box !important",
].join(";");
document.documentElement.append(outline);
function paint(element) {
candidate = validTrackedElement(element) ? element : null;
if (!candidate) {
outline.style.display = "none";
return;
}
const rect = candidate.getBoundingClientRect();
outline.style.display = "block";
outline.style.left = `${rect.left}px`;
outline.style.top = `${rect.top}px`;
outline.style.width = `${rect.width}px`;
outline.style.height = `${rect.height}px`;
}
function finish(element = null) {
pickerCleanup?.();
if (element) {
try { trackArea(element, true); }
catch (error) { report(error.message, { tracked: false }); }
} else {
report("Text area selection cancelled.", { tracked: Boolean(trackedElement) });
}
}
const listeners = [
["pointermove", event => {
blockPickerInput(event);
paint(document.elementFromPoint(event.clientX, event.clientY));
}, true],
["pointerdown", blockPickerInput, true],
["pointerup", blockPickerInput, true],
["mousedown", blockPickerInput, true],
["mouseup", blockPickerInput, true],
["auxclick", blockPickerInput, true],
["dblclick", blockPickerInput, true],
["contextmenu", blockPickerInput, true],
["click", event => { blockPickerInput(event); finish(candidate); }, true],
["keydown", event => {
blockPickerInput(event);
if (event.key === "Escape") finish();
else if (event.key === "ArrowUp" && candidate?.parentElement) {
paint(candidate.parentElement);
}
}, true],
["keypress", blockPickerInput, true],
["keyup", blockPickerInput, true],
];
for (const [type, listener, capture] of listeners) window.addEventListener(type, listener, capture);
pickerCleanup = () => {
for (const [type, listener, capture] of listeners) window.removeEventListener(type, listener, capture);
outline.remove();
pickerCleanup = null;
};
report("Choose a bounded text area. Arrow Up selects its parent; Escape cancels.", { picking: true });
}
async function link(mediaCapture, captureSessionId) {
const epoch = crypto.randomUUID();
documentEpoch = epoch;
linked = false;
const identity = await identify(captureSessionId);
if (documentEpoch !== epoch) throw new Error("The reading page link changed before its identity arrived.");
if (!mediaCapture || typeof mediaCapture !== "object") {
throw new Error("The capture service did not provide page timing settings.");
}
options = {
mediaCapture: {
...mediaCapture,
texthooker: { ...mediaCapture.texthooker },
page: { ...mediaCapture.page },
},
};
linked = true;
linkedDocumentId = identity.documentId;
const available = videos();
if (options.mediaCapture.timingMode !== "recent"
&& available.length === 1 && available[0].trackCount > 0
&& options.mediaCapture.page.nativeCues) {
attachVideo([...document.querySelectorAll("video")][0]);
} else {
attachVideo(null);
}
let message = "Reading page linked.";
if (available.length > 1) message = "Choose which video supplies native subtitle cues.";
else if (available.length === 0) message = "No native video cues found; page text and recent timing remain available.";
return { videos: available, message };
}
function lookupSnapshot(candidate, lookupTimeMs = now()) {
if (!linked || !options?.mediaCapture.enabled) return null;
const pageTiming = options.mediaCapture.timingMode !== "recent";
if (pageTiming) learnArea(candidate);
const occurrence = pageTiming
? occurrenceFor(candidate) ?? { occurrenceId: "", occurrenceSourceKind: "" }
: { occurrenceId: "", occurrenceSourceKind: "" };
return {
documentEpoch,
lookup: {
lookupText: String(candidate.sentence || candidate.query || "").slice(0, MAX_TEXT_LENGTH),
lookupTimeMs,
...occurrence,
},
};
}
async function pinLookup(snapshot) {
if (!snapshot || !linked || snapshot.documentEpoch !== documentEpoch) return null;
try {
return await send("hd_capture_pin", { lookup: snapshot.lookup });
} catch {
return null;
}
}
async function releaseToken(pin) {
if (pin?.token) {
try { await send("hd_capture_release", { token: pin.token }); } catch { /* Expired pins need no cleanup. */ }
}
}
function rootLookup(candidate) {
const snapshot = lookupSnapshot(candidate);
const operation = rootPinTail.then(async () => {
const previous = rootPin;
rootPin = null;
await releaseToken(previous);
const pin = await pinLookup(snapshot);
rootPin = pin;
return pin;
});
rootPinTail = operation.catch(() => {});
return operation;
}
function release(pin) {
const operation = rootPinTail.then(async () => {
const owned = pin === undefined ? rootPin : pin;
if (!owned?.token || rootPin?.token !== owned.token) return;
rootPin = null;
await releaseToken(owned);
});
rootPinTail = operation.catch(() => {});
return operation;
}
async function unlink() {
const epoch = crypto.randomUUID();
documentEpoch = epoch;
linked = false;
await release();
if (documentEpoch !== epoch) return { linked };
pickerCleanup?.();
selectedVideoCleanup?.();
clearTrackedArea(false);
linked = false;
linkedDocumentId = null;
options = null;
return { linked: false };
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.target !== CONTENT_TARGET) return false;
Promise.resolve().then(async () => {
switch (message.type) {
case "hd_capture_link": return link(message.mediaCapture, message.captureSessionId);
case "hd_capture_recover": return { linked, documentId: linkedDocumentId };
case "hd_capture_video_select": {
if (options?.mediaCapture.timingMode === "recent"
|| !options?.mediaCapture.page.nativeCues) {
throw new Error("Native cue timing is disabled for this capture session.");
}
const video = [...document.querySelectorAll("video")].find(item => videoId(item) === message.videoId);
if (!video) throw new Error("That video is no longer available.");
attachVideo(video);
return { selected: message.videoId };
}
case "hd_capture_track_area":
if (options?.mediaCapture.timingMode === "recent"
|| !options?.mediaCapture.page.domText) {
throw new Error("Enable webpage timing and watched page text in Media capture settings first.");
}
picker();
return { picking: true };
case "hd_capture_clear_area":
clearTrackedArea();
return { tracked: false };
case "hd_capture_unlink": return unlink();
default: throw new Error("Unknown capture command.");
}
}).then(result => sendResponse(result), error => sendResponse({ error: error.message || String(error) }));
return true;
});
window.addEventListener("pagehide", () => {
void unlink();
});
globalThis.HDCapture = { rootLookup, release };
}());