mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-04 00:41:33 -07:00
fix: refresh current subtitle after known-word mining
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
id: TASK-320
|
||||||
|
title: Refresh current subtitle known-word highlight after successful mining
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Codex
|
||||||
|
created_date: '2026-05-03 03:22'
|
||||||
|
updated_date: '2026-05-03 03:29'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- anki
|
||||||
|
- subtitle-annotations
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
After a sentence card is mined successfully, the mined word is added to the known-word cache and future subtitle appearances render as known. The currently displayed subtitle must also be refreshed immediately so the mined word turns known-color without waiting for a later cue.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Successful sentence-card mining refreshes the current displayed subtitle so newly mined known words render immediately.
|
||||||
|
- [x] #2 Unsuccessful/no-op mining does not refresh the current subtitle.
|
||||||
|
- [x] #3 Regression coverage verifies the successful and unsuccessful mining paths.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a regression test around AnkiIntegration known-word cache appends: when mined note info changes known words, a callback fires.
|
||||||
|
2. Make KnownWordCacheManager.appendFromNoteInfo report whether it changed the immediate known-word cache.
|
||||||
|
3. Add an AnkiIntegration known-word-cache-updated callback and invoke it after successful immediate append.
|
||||||
|
4. Wire main process callback to subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText), forcing active-line retokenization after popup/proxy or local mining updates the known-word cache.
|
||||||
|
5. Add a changelog fragment and run targeted tests plus typecheck.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented generic known-word-cache update notification instead of shortcut-only refresh. KnownWordCacheManager.appendFromNoteInfo now returns whether in-memory known words changed; AnkiIntegration notifies a callback after successful append. Main process wires that callback to subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText), forcing retokenization without using stale prefetch/cache data. Added regression coverage in anki-integration.test.ts.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Summary:
|
||||||
|
- Added a known-word-cache update callback on AnkiIntegration and wired it in the main process to refresh the current subtitle after mined note info changes known words.
|
||||||
|
- Made KnownWordCacheManager.appendFromNoteInfo report whether it changed the known-word cache, so refresh only happens after an actual immediate known-word append.
|
||||||
|
- Added regression coverage proving mined note info updates known words and emits the update notification.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- bun test src/anki-integration.test.ts src/anki-integration/known-word-cache.test.ts src/main/runtime/anki-actions.test.ts src/main/runtime/anki-actions-main-deps.test.ts
|
||||||
|
- bun run typecheck
|
||||||
|
- bun run changelog:lint currently blocked by pre-existing invalid metadata in changes/319-interjection-annotation-filter.md.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Overlay: Refresh the current subtitle after successful card mining so newly known words recolor immediately.
|
||||||
@@ -177,6 +177,44 @@ test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is di
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration notifies when mined note info updates known words', () => {
|
||||||
|
const ctx = createIntegrationTestContext({
|
||||||
|
stateDirPrefix: 'subminer-anki-integration-known-update-',
|
||||||
|
});
|
||||||
|
let notifications = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const integrationState = ctx.integration as unknown as {
|
||||||
|
config: AnkiConnectConfig;
|
||||||
|
appendKnownWordsFromNoteInfo: (noteInfo: {
|
||||||
|
noteId: number;
|
||||||
|
fields: Record<string, { value: string }>;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
integrationState.config.deck = 'Mining';
|
||||||
|
integrationState.config.knownWords = {
|
||||||
|
...integrationState.config.knownWords,
|
||||||
|
decks: {
|
||||||
|
Mining: ['Word'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
integrationState.appendKnownWordsFromNoteInfo({
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '食べる' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(ctx.integration.isKnownWord('食べる'), true);
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
} finally {
|
||||||
|
cleanupIntegrationTestContext(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
|
test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
|
||||||
let releaseFindNotes: (() => void) | undefined;
|
let releaseFindNotes: (() => void) | undefined;
|
||||||
const findNotesPromise = new Promise<void>((resolve) => {
|
const findNotesPromise = new Promise<void>((resolve) => {
|
||||||
|
|||||||
+21
-1
@@ -148,6 +148,7 @@ export class AnkiIntegration {
|
|||||||
private runtime: AnkiIntegrationRuntime;
|
private runtime: AnkiIntegrationRuntime;
|
||||||
private aiConfig: AiConfig;
|
private aiConfig: AiConfig;
|
||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
|
|
||||||
@@ -552,10 +553,25 @@ export class AnkiIntegration {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knownWordCache.appendFromNoteInfo({
|
const changed = this.knownWordCache.appendFromNoteInfo({
|
||||||
noteId: noteInfo.noteId,
|
noteId: noteInfo.noteId,
|
||||||
fields: noteInfo.fields,
|
fields: noteInfo.fields,
|
||||||
});
|
});
|
||||||
|
if (changed) {
|
||||||
|
this.notifyKnownWordCacheUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyKnownWordCacheUpdated(): void {
|
||||||
|
if (!this.knownWordCacheUpdatedCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.knownWordCacheUpdatedCallback();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Known-word cache update callback failed:', (error as Error).message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLapisConfig(): {
|
private getLapisConfig(): {
|
||||||
@@ -1267,6 +1283,10 @@ export class AnkiIntegration {
|
|||||||
this.recordCardsMinedCallback = callback;
|
this.recordCardsMinedCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
|
||||||
|
this.knownWordCacheUpdatedCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
resolveCurrentNoteId(noteId: number): number {
|
resolveCurrentNoteId(noteId: number): number {
|
||||||
let resolved = noteId;
|
let resolved = noteId;
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|||||||
@@ -165,9 +165,9 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): boolean {
|
||||||
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||||
@@ -180,13 +180,13 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
const preferredFields = this.getImmediateAppendFields();
|
const preferredFields = this.getImmediateAppendFields();
|
||||||
if (!preferredFields) {
|
if (!preferredFields) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
||||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||||
@@ -199,6 +199,7 @@ export class KnownWordCacheManager {
|
|||||||
`wordCount=${nextWords.length}`,
|
`wordCount=${nextWords.length}`,
|
||||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearKnownWordCacheState(): void {
|
clearKnownWordCacheState(): void {
|
||||||
|
|||||||
+11
-3
@@ -1407,9 +1407,8 @@ const subtitleProcessingController = createSubtitleProcessingController(
|
|||||||
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
||||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastObservedTimePos = 0;
|
let lastObservedTimePos = 0;
|
||||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst:
|
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
|
||||||
| CancelLinuxMpvFullscreenOverlayRefreshBurst
|
null;
|
||||||
| null = null;
|
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
const SEEK_THRESHOLD_SECONDS = 3;
|
||||||
|
|
||||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
@@ -3439,6 +3438,9 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||||
};
|
};
|
||||||
|
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
};
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
let hasAttemptedImmersionTrackerStartup = false;
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
const ensureImmersionTrackerStarted = (): void => {
|
||||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||||
@@ -4264,6 +4266,9 @@ function destroyTray(): void {
|
|||||||
function initializeOverlayRuntime(): void {
|
function initializeOverlayRuntime(): void {
|
||||||
initializeOverlayRuntimeHandler();
|
initializeOverlayRuntimeHandler();
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
|
);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4970,6 +4975,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
||||||
appState.ankiIntegration = integration;
|
appState.ankiIntegration = integration;
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
|
|||||||
Reference in New Issue
Block a user