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
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: GPL-3.0-or-later
export const CAPTURE_RECORD_LIMIT = 1000;
export const CAPTURE_TEXT_LIMIT = 4096;
const TIMED_SOURCES = new Set(["texthooker", "cue", "dom"]);
export function normaliseCaptureText(value) {
if (typeof value !== "string") return "";
return value.normalize("NFC").replace(/\s+/gu, " ").trim();
}
function finiteTime(value, label) {
if (!Number.isFinite(value)) throw new Error(`${label} must be a finite timeline value`);
return value;
}
function recordKey(record) {
return `${record.sourceKind}\0${record.sourceId}\0${record.sourceEpoch}\0${record.occurrenceId}`;
}
export function createCaptureTimeline({ limit = CAPTURE_RECORD_LIMIT, textLimit = CAPTURE_TEXT_LIMIT } = {}) {
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(textLimit) || textLimit < 1) {
throw new Error("capture timeline limits must be positive integers");
}
const records = [];
const current = new Map();
function begin(value) {
if (!TIMED_SOURCES.has(value?.sourceKind)
|| typeof value.sourceId !== "string" || !value.sourceId
|| typeof value.sourceEpoch !== "string" || !value.sourceEpoch
|| typeof value.occurrenceId !== "string" || !value.occurrenceId) {
throw new Error("capture timeline record identity is incomplete");
}
if (typeof value.text !== "string" || value.text.length > textLimit) {
throw new Error(`capture timeline text must be at most ${textLimit} characters`);
}
const text = normaliseCaptureText(value.text);
if (!text) throw new Error("capture timeline text is empty");
const startMs = finiteTime(value.startMs, "capture start");
const candidate = {
sourceKind: value.sourceKind,
sourceId: value.sourceId,
sourceEpoch: value.sourceEpoch,
occurrenceId: value.occurrenceId,
text: value.text,
normalizedText: text,
startMs,
endMs: null,
onsetKnown: value.onsetKnown !== false,
};
const key = recordKey(candidate);
const existing = current.get(key);
if (existing) {
existing.text = candidate.text;
existing.normalizedText = candidate.normalizedText;
existing.onsetKnown = existing.onsetKnown && candidate.onsetKnown;
return { ...existing };
}
records.push(candidate);
current.set(key, candidate);
while (records.length > limit) {
const removed = records.shift();
if (current.get(recordKey(removed)) === removed) current.delete(recordKey(removed));
}
return { ...candidate };
}
function close(identity, endMs) {
const key = recordKey(identity);
const record = current.get(key);
if (!record) return null;
const end = finiteTime(endMs, "capture end");
if (end < record.startMs) throw new Error("capture end precedes its start");
record.endMs = end;
current.delete(key);
return { ...record };
}
function closeSource(sourceKind, sourceId, sourceEpoch, endMs) {
const closed = [];
for (const record of current.values()) {
if (record.sourceKind !== sourceKind || record.sourceId !== sourceId
|| (sourceEpoch !== undefined && record.sourceEpoch !== sourceEpoch)) continue;
const result = close(record, endMs);
if (result) closed.push(result);
}
return closed;
}
function reset() {
records.length = 0;
current.clear();
}
return {
begin,
close,
closeSource,
reset,
snapshot: () => records.map(record => ({ ...record })),
};
}
function temporalMatches(record, lookupTimeMs) {
return record.startMs <= lookupTimeMs && (record.endMs == null || lookupTimeMs <= record.endMs);
}
function matchingRecord(records, sourceKind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
texthookerSource) {
const normalized = normaliseCaptureText(lookupText);
const matches = records.filter(record => record.sourceKind === sourceKind
&& record.onsetKnown !== false
&& (sourceKind === "texthooker"
? record.sourceId === texthookerSource?.sourceId && record.sourceEpoch === texthookerSource?.sourceEpoch
&& record.startMs <= lookupTimeMs
: temporalMatches(record, lookupTimeMs))
&& (occurrenceId && occurrenceSourceKind === sourceKind ? record.occurrenceId === occurrenceId
: (record.normalizedText ?? normaliseCaptureText(record.text)) === normalized));
return matches.length === 1 ? matches[0] : null;
}
function timedInterval(record, lookupTimeMs, clipMs, offsetMs) {
const startMs = record.startMs + offsetMs;
const naturalEnd = record.endMs == null ? startMs + clipMs : record.endMs + offsetMs;
return {
startMs,
endMs: Math.min(naturalEnd, startMs + clipMs),
pendingTail: record.endMs == null && lookupTimeMs < naturalEnd,
};
}
/**
* Resolve one lookup to exactly one source interval. `availableStartMs` is the
* oldest retained recorder timestamp and may shorten only the recent fallback.
*/
export function resolveCaptureInterval({
records,
lookupText,
occurrenceId = "",
occurrenceSourceKind = "",
lookupTimeMs,
availableStartMs,
timingMode = "auto",
clipSeconds = 10,
estimatedOffsetMs = -500,
texthookerActive = false,
texthookerSource = null,
}) {
finiteTime(lookupTimeMs, "lookup time");
finiteTime(availableStartMs, "available capture start");
const clipMs = clipSeconds * 1000;
if (![5000, 10000].includes(clipMs)) throw new Error("capture clip length is invalid");
let priorities = [];
if (timingMode !== "recent") {
priorities = ["cue", "dom"];
if (timingMode !== "page" && texthookerActive) priorities.unshift("texthooker");
}
const labels = {
texthooker: "Texthooker estimate",
cue: "Video cue",
dom: "Page-text estimate",
};
for (const kind of priorities) {
const record = matchingRecord(records, kind, lookupText, occurrenceId, occurrenceSourceKind, lookupTimeMs,
texthookerSource);
if (!record) continue;
const interval = timedInterval(record, lookupTimeMs, clipMs, kind === "cue" ? 0 : estimatedOffsetMs);
if (interval.startMs < availableStartMs || interval.endMs <= interval.startMs) continue;
return {
...interval,
sourceKind: kind,
sourceLabel: labels[kind],
sourceId: record.sourceId,
sourceEpoch: record.sourceEpoch,
occurrenceId: record.occurrenceId,
};
}
const startMs = Math.max(availableStartMs, lookupTimeMs - clipMs);
if (lookupTimeMs <= startMs) return null;
return {
sourceKind: "recent",
sourceLabel: "Recent clip",
sourceId: "",
sourceEpoch: "",
occurrenceId: "",
startMs,
endMs: lookupTimeMs,
pendingTail: false,
partial: startMs > lookupTimeMs - clipMs,
};
}