mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
+192
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user