fix(anki): refresh secondary subtitle before creating sentence card

- Fetch secondary-sub-text live via requestProperty so SelectionText uses current translation, not stale cache
- Suppress secondary text when it equals primary (deduplication)
- Make recordSubtitleMiningContext errors non-fatal; warn and continue to record lookup
- Fix field-grouping merge to use raw tag instead of re-wrapping with ensureImageGroupId
This commit is contained in:
2026-05-27 00:28:06 -07:00
parent eb04ea97b1
commit 760dd9e2ce
6 changed files with 117 additions and 3 deletions
@@ -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.
+1 -1
View File
@@ -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('');
}
+40
View File
@@ -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);
+8 -1
View File
@@ -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();
});
+32
View File
@@ -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[] = [];
+32 -1
View File
@@ -25,6 +25,7 @@ interface MpvClientLike {
currentSubStart: number;
currentSubEnd: number;
currentSecondarySubText?: string;
requestProperty?: (name: string) => Promise<unknown>;
}
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<string | undefined> {
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,
);
}