diff --git a/changes/sentence-card-secondary-subtitle.md b/changes/sentence-card-secondary-subtitle.md new file mode 100644 index 00000000..c37592a4 --- /dev/null +++ b/changes/sentence-card-secondary-subtitle.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Sentence cards now refresh the current secondary subtitle before saving, so SelectionText uses the loaded translation instead of repeating the primary subtitle. diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index e2883dfa..d7def9ef 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -357,7 +357,7 @@ export class FieldGroupingMergeCollaborator { return existingValue || newValue; } return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]) - .map((entry) => this.ensureImageGroupId(entry.tag, entry.groupId)) + .map((entry) => entry.tag) .join(''); } diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index fd09cd24..17a5afde 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -667,6 +667,46 @@ test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => ]); }); +test('registerIpcHandlers records yomitan lookup when subtitle context recording fails', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: string[] = []; + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + const deps = createRegisterIpcDeps({ + immersionTracker: createFakeImmersionTracker({ + recordYomitanLookup: () => { + calls.push('lookup'); + }, + }), + }) as IpcServiceDeps & { + recordSubtitleMiningContext: (context: unknown | null) => void; + }; + deps.recordSubtitleMiningContext = () => { + throw new Error('context write failed'); + }; + + try { + registerIpcHandlers(deps, registrar); + + const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup); + assert.equal(typeof handler, 'function'); + + assert.doesNotThrow(() => { + handler?.({}, { source: 'subtitle-sidebar', text: 'line', startTime: 1, endTime: 2 }); + }); + + assert.deepEqual(calls, ['lookup']); + assert.equal(warnings.length, 1); + assert.equal(warnings[0]?.[0], 'Failed to record subtitle mining context:'); + assert.equal(warnings[0]?.[1], 'context write failed'); + } finally { + console.warn = originalWarn; + } +}); + test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers(createRegisterIpcDeps(), registrar); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 9f03ef2c..466f2714 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -465,7 +465,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }); ipc.on(IPC_CHANNELS.command.recordYomitanLookup, (_event: unknown, payload: unknown) => { - deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload)); + try { + deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload)); + } catch (error) { + console.warn( + 'Failed to record subtitle mining context:', + error instanceof Error ? error.message : String(error), + ); + } deps.immersionTracker?.recordYomitanLookup(); }); diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 431a1332..b31604e5 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -124,6 +124,38 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async () ]); }); +test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => { + const created: Array<{ sentence: string; secondarySub?: string }> = []; + const requestedProperties: string[] = []; + + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { + created.push({ sentence, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: '日本語字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: '日本語字幕', + requestProperty: async (name: string) => { + requestedProperties.push(name); + return name === 'secondary-sub-text' ? 'English subtitle' : null; + }, + }, + showMpvOsd: () => {}, + }); + + assert.deepEqual(requestedProperties, ['secondary-sub-text']); + assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]); +}); + test('handleMultiCopyDigit copies available history and reports truncation', () => { const osd: string[] = []; const copied: string[] = []; diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 3baa7275..a812a428 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -25,6 +25,7 @@ interface MpvClientLike { currentSubStart: number; currentSubEnd: number; currentSecondarySubText?: string; + requestProperty?: (name: string) => Promise; } export function handleMultiCopyDigit( @@ -95,6 +96,35 @@ function getSecondarySubTextForMinedBlocks( return getCurrentSecondarySubText(); } +function normalizeSecondarySubText(text: unknown, primaryText: string): string | undefined { + if (typeof text !== 'string') { + return undefined; + } + const trimmed = text.trim(); + if (!trimmed || trimmed === primaryText.trim()) { + return undefined; + } + return trimmed; +} + +async function getCurrentSecondarySubTextForSentenceCard( + mpvClient: MpvClientLike, +): Promise { + const primaryText = mpvClient.currentSubText; + if (mpvClient.requestProperty) { + try { + const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text'); + const normalizedLatest = normalizeSecondarySubText(latestSecondaryText, primaryText); + if (normalizedLatest) { + return normalizedLatest; + } + } catch { + // Fall back to the cached secondary subtitle below. + } + } + return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText); +} + export async function updateLastCardFromClipboard(deps: { ankiIntegration: AnkiIntegrationLike | null; readClipboardText: () => string; @@ -141,11 +171,12 @@ export async function mineSentenceCard(deps: { return false; } + const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient); return await anki.createSentenceCard( mpvClient.currentSubText, mpvClient.currentSubStart, mpvClient.currentSubEnd, - mpvClient.currentSecondarySubText || undefined, + secondarySubText, ); }