mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
fix(subtitles): widen halfwidth katakana where subtitle text enters the app (#295)
* fix(subtitles): widen halfwidth katakana where subtitle text enters the app Broadcast-derived Japanese subs write katakana halfwidth (アンタ, ダキニ), so overlay lines mixed squashed and normal-width text. Normalize live mpv sub-text and parsed cue text to fullwidth so display, lookups, mining, and stats all see one form. The tokenizer reuses the shared helper. * fix(subtitles): fold stray voiced marks and widen before cue dedup - A lone halfwidth ゙ after fullwidth kana (ガ) now composes to ガ instead of leaving a combining mark that broke name matching. - Parsed cue text is widened before duplicate merging so ガク and ガク on the same span collapse into one sidebar row. - The YouTube track-load refresh widens the sub-text it reads directly. * fix(stats): widen halfwidth katakana in stored lines and mined cards (#296) Lines recorded before ingest widening still hold halfwidth katakana. Occurrence and sentence-search queries widen line text on read, and the mine-card route widens the sentence and word it receives, so the dashboard and cards mined from history match newer rows. Word identity columns are left as stored since the dashboard queries by them. * fix(stats): match katakana in either width in sentence search - Add narrowFullwidthKana, derived from widenHalfwidthKana so the two stay in sync - Match subtitle text against the original, widened, and narrowed forms of each term so a search finds older lines stored as halfwidth
This commit is contained in:
1 parent
bf4e5bb609
commit
01613f1be5
16 files changed
+285
-26
No files matched your search
@@ -3936,6 +3936,83 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
||||
}
|
||||
});
|
||||
|
||||
test('getWordOccurrences widens halfwidth katakana in lines recorded before ingest widening', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
|
||||
canonicalTitle: 'Episode 8',
|
||||
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
|
||||
const wordResult = db
|
||||
.prepare(
|
||||
`INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run('アンタ', 'アンタ', 'あんた', 'noun', '名詞', '代名詞', '', 1_000, 1_000, 1);
|
||||
const lineResult = db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
|
||||
VALUES (?, ?, ?)`,
|
||||
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
|
||||
|
||||
const rows = getWordOccurrences(db, 'アンタ', 'アンタ', 'あんた', 10);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.text),
|
||||
['アンタがアータルたん?'],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSubtitleSentences matches halfwidth and fullwidth katakana in either direction', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
|
||||
canonicalTitle: 'Episode 8',
|
||||
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
|
||||
const insertLine = db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
insertLine.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
|
||||
insertLine.run(sessionId, null, videoId, null, 2, 1000, 2000, 'アンタ、ダキニ', 2_000, 2_000);
|
||||
|
||||
for (const query of ['アンタ', 'アンタ']) {
|
||||
assert.deepEqual(
|
||||
searchSubtitleSentences(db, query, 10).map((row) => row.text),
|
||||
['アンタ、ダキニ', 'アンタがアータルたん?'],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from './types';
|
||||
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
|
||||
import {
|
||||
areLexicalDailyRollupsReady,
|
||||
getLexicalDailyRollups,
|
||||
@@ -382,6 +383,13 @@ export function getKanjiStats(db: DatabaseSync, limit = 100): KanjiStatsRow[] {
|
||||
return stmt.all(limit) as KanjiStatsRow[];
|
||||
}
|
||||
|
||||
// Lines recorded before subtitle text was widened at ingest can still hold halfwidth
|
||||
// katakana; widen them on the way out so the dashboard and cards mined from it match
|
||||
// newer rows. Display-only: word identity columns stay as stored since they are keys.
|
||||
function widenLineText<T extends { text: string }>(row: T): T {
|
||||
return { ...row, text: widenHalfwidthKana(row.text) };
|
||||
}
|
||||
|
||||
export function getWordOccurrences(
|
||||
db: DatabaseSync,
|
||||
headword: string,
|
||||
@@ -390,7 +398,7 @@ export function getWordOccurrences(
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
): WordOccurrenceRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -418,6 +426,7 @@ export function getWordOccurrences(
|
||||
`,
|
||||
)
|
||||
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
|
||||
return rows.map(widenLineText);
|
||||
}
|
||||
|
||||
export function getKanjiOccurrences(
|
||||
@@ -426,7 +435,7 @@ export function getKanjiOccurrences(
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
): KanjiOccurrenceRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -454,6 +463,7 @@ export function getKanjiOccurrences(
|
||||
`,
|
||||
)
|
||||
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
|
||||
return rows.map(widenLineText);
|
||||
}
|
||||
|
||||
export function searchSubtitleSentences(
|
||||
@@ -470,6 +480,14 @@ export function searchSubtitleSentences(
|
||||
const params: string[] = [];
|
||||
for (const term of terms) {
|
||||
const likeTerm = `%${escapeLikeTerm(term)}%`;
|
||||
// Lines recorded before ingest widening can hold halfwidth katakana, so subtitle
|
||||
// text is matched against the term's widened and narrowed forms as well.
|
||||
const widenedTerm = widenHalfwidthKana(term);
|
||||
const textLikeTerms = uniqueNonEmptyTerms([
|
||||
term,
|
||||
widenedTerm,
|
||||
narrowFullwidthKana(widenedTerm),
|
||||
]).map((variant) => `%${escapeLikeTerm(variant)}%`);
|
||||
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
|
||||
const headwordClause =
|
||||
headwords.length > 0
|
||||
@@ -485,16 +503,16 @@ export function searchSubtitleSentences(
|
||||
: '';
|
||||
clauses.push(`
|
||||
(
|
||||
l.text LIKE ? ESCAPE '\\'
|
||||
${textLikeTerms.map(() => "l.text LIKE ? ESCAPE '\\'").join(' OR ')}
|
||||
OR v.canonical_title LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
|
||||
${headwordClause}
|
||||
)
|
||||
`);
|
||||
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
|
||||
params.push(...textLikeTerms, likeTerm, likeTerm, ...headwords);
|
||||
}
|
||||
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -518,6 +536,7 @@ export function searchSubtitleSentences(
|
||||
`,
|
||||
)
|
||||
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
|
||||
return rows.map(widenLineText);
|
||||
}
|
||||
|
||||
export function getSessionEvents(
|
||||
|
||||
Reference in new issue
Block a user