Files
SubMiner/stats/src/lib/sentence-search.tsx
T
sudacode 99401e5a70 feat(stats): add alass sidecar retiming for sentence mining and fix timi
- Retime local English sidecars against the Japanese sidecar via alass before populating sentence card translation fields; cache retimed copies for the process lifetime
- Reject reversed or non-positive subtitle timings in immersion tracker and before media generation
- Fix word card mining so sentence audio goes to SentenceAudio (not ExpressionAudio) and English subtitle text is not written to SelectionText
- Consolidate 10 individual change fragments into stats-updates.md
2026-06-06 14:26:02 -07:00

82 lines
2.3 KiB
TypeScript

import type { ReactNode } from 'react';
import type { SentenceSearchResult } from '../types/stats';
import { getStatsMineCardUnavailableReason } from './mining';
export interface SentenceMatchRange {
start: number;
end: number;
}
export interface SentenceSearchMineAvailability {
canMineSentence: boolean;
canMineWordAudio: boolean;
exactMatch: boolean;
unavailableReason: string | null;
}
function normalizedSearchWord(query: string): string {
return query.trim();
}
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
const needle = normalizedSearchWord(query);
if (!needle) return [];
const ranges: SentenceMatchRange[] = [];
const haystack = text.toLocaleLowerCase();
const normalizedNeedle = needle.toLocaleLowerCase();
let searchFrom = 0;
while (searchFrom < haystack.length) {
const index = haystack.indexOf(normalizedNeedle, searchFrom);
if (index < 0) break;
ranges.push({ start: index, end: index + needle.length });
searchFrom = index + needle.length;
}
return ranges;
}
export function getSentenceSearchMineAvailability(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text'>,
query: string,
): SentenceSearchMineAvailability {
const exactMatch = findExactSentenceMatches(result.text, query).length > 0;
const unavailableReason = getStatsMineCardUnavailableReason(result);
return {
canMineSentence: unavailableReason === null,
canMineWordAudio: unavailableReason === null && exactMatch,
exactMatch,
unavailableReason,
};
}
export function renderSentenceWithMatches(text: string, query: string): ReactNode {
const ranges = findExactSentenceMatches(text, query);
if (ranges.length === 0) return text;
const parts: ReactNode[] = [];
let cursor = 0;
ranges.forEach((range, index) => {
if (range.start > cursor) {
parts.push(text.slice(cursor, range.start));
}
parts.push(
<mark
key={`${range.start}-${index}`}
className="rounded bg-ctp-yellow/15 px-0.5 text-ctp-yellow underline decoration-ctp-yellow/60 underline-offset-2"
>
{text.slice(range.start, range.end)}
</mark>,
);
cursor = range.end;
});
if (cursor < text.length) {
parts.push(text.slice(cursor));
}
return parts;
}