Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8b0ef662bc
|
|||
| 0a58c20ad7 | |||
| 38ddb29aa0 | |||
| 8b9a70c5a6 | |||
| 48a084914a | |||
| a042b04357 |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 436 B |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 549 B |
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||||
|
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||||
|
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed never-mined compound words (e.g. 待ち合わせてる) being highlighted green as known: subtitle tokens now carry complete readings instead of kanji-only furigana joins, and the known-word reading fallback rejects readings that don't cover the token surface. Stored word readings in the stats database are no longer truncated for new lines.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: branding
|
||||||
|
|
||||||
|
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
|
|||||||
|
|
||||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||||
- `serverPort` controls the localhost dashboard URL.
|
- `serverPort` controls the localhost dashboard URL.
|
||||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
|
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
@@ -569,7 +569,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -703,8 +703,8 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isKnownWord(text: string): boolean {
|
isKnownWord(text: string, reading?: string): boolean {
|
||||||
return this.knownWordCache.isKnownWord(text);
|
return this.knownWordCache.isKnownWord(text, reading);
|
||||||
}
|
}
|
||||||
|
|
||||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
|
|||||||
const deleted: number[][] = [];
|
const deleted: number[][] = [];
|
||||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||||
const statuses: string[] = [];
|
const statuses: string[] = [];
|
||||||
|
const osdMessages: string[] = [];
|
||||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||||
const mergeCalls: Array<{
|
const mergeCalls: Array<{
|
||||||
keepNoteId: number;
|
keepNoteId: number;
|
||||||
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
|
|||||||
statuses.push(message);
|
statuses.push(message);
|
||||||
},
|
},
|
||||||
showNotification: async () => undefined,
|
showNotification: async () => undefined,
|
||||||
showOsdNotification: () => undefined,
|
showOsdNotification: (message: string) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
truncateSentence: (value: string) => value,
|
truncateSentence: (value: string) => value,
|
||||||
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
|
|||||||
addedTags,
|
addedTags,
|
||||||
rememberedMerges,
|
rememberedMerges,
|
||||||
statuses,
|
statuses,
|
||||||
|
osdMessages,
|
||||||
mergeCalls,
|
mergeCalls,
|
||||||
setManualChoice: (choice: typeof manualChoice) => {
|
setManualChoice: (choice: typeof manualChoice) => {
|
||||||
manualChoice = choice;
|
manualChoice = choice;
|
||||||
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
|||||||
assert.equal(harness.updates.length, 0);
|
assert.equal(harness.updates.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.setManualChoice({
|
||||||
|
keepNoteId: 0,
|
||||||
|
deleteNoteId: 0,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handled = await harness.workflow.handleManual(1, 2, {
|
||||||
|
noteId: 2,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-2' },
|
||||||
|
Sentence: { value: 'line-2' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
|
||||||
|
assert.equal(harness.updates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.setManualChoice({
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () => [];
|
||||||
|
|
||||||
|
const handled = await harness.workflow.handleManual(1, 2, {
|
||||||
|
noteId: 2,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'word-2' },
|
||||||
|
Sentence: { value: 'line-2' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, false);
|
||||||
|
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
|
||||||
|
});
|
||||||
|
|
||||||
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.setManualChoice({
|
harness.setManualChoice({
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
|
|||||||
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
||||||
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||||
|
// handleManual owns all user-facing notifications; callers must not re-notify on false.
|
||||||
|
this.deps.showOsdNotification('Field grouping failed: original card not found');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
|||||||
assert.deepEqual(harness.manualCalls, []);
|
assert.deepEqual(harness.manualCalls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
|
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
kikuFieldGrouping: 'manual',
|
kikuFieldGrouping: 'manual',
|
||||||
noteIds: [9],
|
noteIds: [9],
|
||||||
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
|
|||||||
expression: 'word-9',
|
expression: 'word-9',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
|
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
|
||||||
|
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
|
||||||
|
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
||||||
|
|||||||
@@ -156,15 +156,14 @@ export class FieldGroupingService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handled = await this.deps.handleFieldGroupingManual(
|
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
|
||||||
|
// unavailable, failed) — re-notifying on a false return here duplicated them.
|
||||||
|
await this.deps.handleFieldGroupingManual(
|
||||||
duplicateNoteId,
|
duplicateNoteId,
|
||||||
noteId,
|
noteId,
|
||||||
noteInfo,
|
noteInfo,
|
||||||
expressionText,
|
expressionText,
|
||||||
);
|
);
|
||||||
if (!handled) {
|
|
||||||
this.deps.showOsdNotification('Field grouping cancelled');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error triggering field grouping:', (error as Error).message);
|
log.error('Error triggering field grouping:', (error as Error).message);
|
||||||
|
|||||||
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
|||||||
assert.equal(manager.isKnownWord('猫'), true);
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
assert.equal(calls.findNotes, 0);
|
assert.equal(calls.findNotes, 0);
|
||||||
assert.equal(calls.notesInfo, 0);
|
assert.equal(calls.notesInfo, 0);
|
||||||
|
// v2 states carry no readings, so they load usable but stale to trigger a
|
||||||
|
// prompt upgrade refresh.
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
manager as unknown as {
|
||||||
|
getMsUntilNextRefresh: () => number;
|
||||||
|
}
|
||||||
|
).getMsUntilNextRefresh(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
manager.stopLifecycle();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
refreshMinutes: 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Date.now = () => 120_000;
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 3,
|
||||||
|
refreshedAtMs: 120_000,
|
||||||
|
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||||
|
notes: {
|
||||||
|
'1': [{ word: '猫', reading: 'ねこ' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.startLifecycle();
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('猫'), true);
|
||||||
|
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
|
||||||
|
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
|
||||||
|
assert.equal(calls.findNotes, 0);
|
||||||
|
assert.equal(calls.notesInfo, 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
manager as unknown as {
|
manager as unknown as {
|
||||||
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||||
version: number;
|
version: number;
|
||||||
words: string[];
|
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||||
notes?: Record<string, string[]>;
|
|
||||||
};
|
};
|
||||||
assert.equal(persisted.version, 2);
|
assert.equal(persisted.version, 3);
|
||||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
|
||||||
assert.deepEqual(persisted.notes, {
|
assert.deepEqual(persisted.notes, {
|
||||||
'1': ['鳥'],
|
'1': [{ word: '鳥', reading: null }],
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
|||||||
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||||
scope: string;
|
scope: string;
|
||||||
words: string[];
|
notes: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||||
};
|
};
|
||||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||||
assert.deepEqual(persisted.words, ['猫']);
|
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -648,3 +695,200 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
|
|||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesResult = [1];
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '床' },
|
||||||
|
'Word Reading': { value: 'ゆか' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('床'), true);
|
||||||
|
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||||
|
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
|
||||||
|
// Same spelling, different word (床/とこ "bed") must not match.
|
||||||
|
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||||
|
// Note readings stay matchable as kana words.
|
||||||
|
assert.equal(manager.isKnownWord('ゆか'), true);
|
||||||
|
assert.equal(manager.isKnownWord('とこ'), false);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
decks: {
|
||||||
|
'Kaishi 1.5k': ['Word'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '床' },
|
||||||
|
'Word Reading': { value: 'ゆか' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||||
|
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager matches words without readings in any reading', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesResult = [1];
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: '床' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('床'), true);
|
||||||
|
assert.equal(manager.isKnownWord('床', 'とこ'), true);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesResult = [1];
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Word: { value: 'お 決[き]まり' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('お決まり'), true);
|
||||||
|
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
|
||||||
|
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesResult = [1];
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Reading: { value: '漢字' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('漢字'), true);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: {
|
||||||
|
word: 'Word',
|
||||||
|
},
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesResult = [1];
|
||||||
|
clientState.notesInfoResult = [
|
||||||
|
{
|
||||||
|
noteId: 1,
|
||||||
|
fields: {
|
||||||
|
Reading: { value: 'たべる' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await manager.refresh(true);
|
||||||
|
|
||||||
|
assert.equal(manager.isKnownWord('たべる'), true);
|
||||||
|
assert.equal(manager.isKnownWord('タベル'), true);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
|||||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||||
|
KnownWordEntry,
|
||||||
|
convertKatakanaToHiragana,
|
||||||
|
isReadingFieldName,
|
||||||
|
knownWordEntryListsEqual,
|
||||||
|
normalizeKnownReadingForLookup,
|
||||||
|
normalizeKnownWordEntryList,
|
||||||
|
parseFuriganaAnnotatedText,
|
||||||
|
} from './known-word-entries';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.known-word-cache');
|
const log = createLogger('anki').child('integration.known-word-cache');
|
||||||
|
|
||||||
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
|
|||||||
readonly notes: Record<string, string[]>;
|
readonly notes: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
interface KnownWordCacheStateV3 {
|
||||||
|
readonly version: 3;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||||
|
|
||||||
|
const NO_READING_KEY = '';
|
||||||
|
|
||||||
interface KnownWordCacheClient {
|
interface KnownWordCacheClient {
|
||||||
findNotes: (
|
findNotes: (
|
||||||
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
|
|||||||
export class KnownWordCacheManager {
|
export class KnownWordCacheManager {
|
||||||
private knownWordsLastRefreshedAtMs = 0;
|
private knownWordsLastRefreshedAtMs = 0;
|
||||||
private knownWordsStateKey = '';
|
private knownWordsStateKey = '';
|
||||||
private knownWords: Set<string> = new Set();
|
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||||
private wordReferenceCounts = new Map<string, number>();
|
// entries fail open: the word matches regardless of the token's reading.
|
||||||
private noteWordsById = new Map<number, string[]>();
|
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||||
|
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||||
|
private readingCounts = new Map<string, number>();
|
||||||
|
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isRefreshingKnownWords = false;
|
private isRefreshingKnownWords = false;
|
||||||
@@ -120,13 +142,28 @@ export class KnownWordCacheManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isKnownWord(text: string): boolean {
|
isKnownWord(text: string, reading?: string): boolean {
|
||||||
if (!this.isKnownWordCacheEnabled()) {
|
if (!this.isKnownWordCacheEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = this.normalizeKnownWordForLookup(text);
|
const normalized = this.normalizeKnownWordForLookup(text);
|
||||||
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
|
if (normalized.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||||
|
if (knownReadings && knownReadings.size > 0) {
|
||||||
|
const normalizedReading =
|
||||||
|
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||||
|
return (
|
||||||
|
normalizedReading.length === 0 ||
|
||||||
|
knownReadings.has(NO_READING_KEY) ||
|
||||||
|
knownReadings.has(normalizedReading)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.readingCounts.has(convertKatakanaToHiragana(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(force = false): Promise<void> {
|
refresh(force = false): Promise<void> {
|
||||||
@@ -173,7 +210,7 @@ export class KnownWordCacheManager {
|
|||||||
let didMutateCache = false;
|
let didMutateCache = false;
|
||||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||||
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
|
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||||
this.clearKnownWordCacheState();
|
this.clearKnownWordCacheState();
|
||||||
}
|
}
|
||||||
if (!this.knownWordsStateKey) {
|
if (!this.knownWordsStateKey) {
|
||||||
@@ -185,8 +222,8 @@ export class KnownWordCacheManager {
|
|||||||
return didMutateCache;
|
return didMutateCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
|
||||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return didMutateCache;
|
return didMutateCache;
|
||||||
}
|
}
|
||||||
@@ -198,7 +235,7 @@ export class KnownWordCacheManager {
|
|||||||
log.info(
|
log.info(
|
||||||
'Known-word cache updated in-session',
|
'Known-word cache updated in-session',
|
||||||
`noteId=${noteInfo.noteId}`,
|
`noteId=${noteInfo.noteId}`,
|
||||||
`wordCount=${nextWords.length}`,
|
`wordCount=${nextEntries.length}`,
|
||||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -236,11 +273,11 @@ export class KnownWordCacheManager {
|
|||||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
if (this.noteWordsById.size === 0) {
|
if (this.noteEntriesById.size === 0) {
|
||||||
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
||||||
} else {
|
} else {
|
||||||
const currentNoteIdSet = new Set(currentNoteIds);
|
const currentNoteIdSet = new Set(currentNoteIds);
|
||||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
for (const noteId of Array.from(this.noteEntriesById.keys())) {
|
||||||
if (!currentNoteIdSet.has(noteId)) {
|
if (!currentNoteIdSet.has(noteId)) {
|
||||||
this.removeNoteSnapshot(noteId);
|
this.removeNoteSnapshot(noteId);
|
||||||
}
|
}
|
||||||
@@ -251,7 +288,7 @@ export class KnownWordCacheManager {
|
|||||||
for (const noteInfo of noteInfos) {
|
for (const noteInfo of noteInfos) {
|
||||||
this.replaceNoteSnapshot(
|
this.replaceNoteSnapshot(
|
||||||
noteInfo.noteId,
|
noteInfo.noteId,
|
||||||
this.extractNormalizedKnownWordsFromNoteInfo(
|
this.extractKnownWordEntriesFromNoteInfo(
|
||||||
noteInfo,
|
noteInfo,
|
||||||
noteFieldsById.get(noteInfo.noteId),
|
noteFieldsById.get(noteInfo.noteId),
|
||||||
),
|
),
|
||||||
@@ -266,7 +303,7 @@ export class KnownWordCacheManager {
|
|||||||
log.info(
|
log.info(
|
||||||
'Known-word cache refreshed',
|
'Known-word cache refreshed',
|
||||||
`noteCount=${currentNoteIds.length}`,
|
`noteCount=${currentNoteIds.length}`,
|
||||||
`wordCount=${this.knownWords.size}`,
|
`wordCount=${this.wordReadingCounts.size}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||||
@@ -291,7 +328,13 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
private getDefaultKnownWordFields(): string[] {
|
private getDefaultKnownWordFields(): string[] {
|
||||||
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
|
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
|
||||||
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
|
return this.withDefaultReadingFields([configuredWordField, 'Word']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading fields are always probed (even when a deck configures explicit
|
||||||
|
// word fields) so entries can carry the reading their note teaches.
|
||||||
|
private withDefaultReadingFields(fields: string[]): string[] {
|
||||||
|
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getKnownWordDecks(): string[] {
|
private getKnownWordDecks(): string[] {
|
||||||
@@ -337,7 +380,9 @@ export class KnownWordCacheManager {
|
|||||||
.filter((field) => field.length > 0),
|
.filter((field) => field.length > 0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
|
return normalizedFields.length > 0
|
||||||
|
? this.withDefaultReadingFields(normalizedFields)
|
||||||
|
: this.getDefaultKnownWordFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckFields = selectedDeckEntry[1];
|
const deckFields = selectedDeckEntry[1];
|
||||||
@@ -351,7 +396,7 @@ export class KnownWordCacheManager {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (normalizedFields.length > 0) {
|
if (normalizedFields.length > 0) {
|
||||||
return normalizedFields;
|
return this.withDefaultReadingFields(normalizedFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +427,10 @@ export class KnownWordCacheManager {
|
|||||||
: [];
|
: [];
|
||||||
scopes.push({
|
scopes.push({
|
||||||
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
|
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
|
||||||
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
|
fields:
|
||||||
|
normalizedFields.length > 0
|
||||||
|
? this.withDefaultReadingFields(normalizedFields)
|
||||||
|
: this.getDefaultKnownWordFields(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (scopes.length > 0) {
|
if (scopes.length > 0) {
|
||||||
@@ -490,7 +538,7 @@ export class KnownWordCacheManager {
|
|||||||
for (const noteInfo of noteInfos) {
|
for (const noteInfo of noteInfos) {
|
||||||
this.replaceNoteSnapshot(
|
this.replaceNoteSnapshot(
|
||||||
noteInfo.noteId,
|
noteInfo.noteId,
|
||||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,56 +567,77 @@ export class KnownWordCacheManager {
|
|||||||
return noteInfos;
|
return noteInfos;
|
||||||
}
|
}
|
||||||
|
|
||||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
|
||||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
|
||||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
|
||||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeWordsFromCounts(previousWords);
|
this.removeEntriesFromCounts(previousEntries);
|
||||||
if (normalizedWords.length > 0) {
|
if (normalizedEntries.length > 0) {
|
||||||
this.noteWordsById.set(noteId, normalizedWords);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addWordsToCounts(normalizedWords);
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
} else {
|
} else {
|
||||||
this.noteWordsById.delete(noteId);
|
this.noteEntriesById.delete(noteId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeNoteSnapshot(noteId: number): void {
|
private removeNoteSnapshot(noteId: number): void {
|
||||||
const previousWords = this.noteWordsById.get(noteId);
|
const previousEntries = this.noteEntriesById.get(noteId);
|
||||||
if (!previousWords) {
|
if (!previousEntries) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.noteWordsById.delete(noteId);
|
this.noteEntriesById.delete(noteId);
|
||||||
this.removeWordsFromCounts(previousWords);
|
this.removeEntriesFromCounts(previousEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addWordsToCounts(words: string[]): void {
|
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||||
for (const word of words) {
|
for (const entry of entries) {
|
||||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
this.wordReferenceCounts.set(word, nextCount);
|
let readings = this.wordReadingCounts.get(entry.word);
|
||||||
this.knownWords.add(word);
|
if (!readings) {
|
||||||
|
readings = new Map();
|
||||||
|
this.wordReadingCounts.set(entry.word, readings);
|
||||||
|
}
|
||||||
|
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||||
|
if (entry.reading) {
|
||||||
|
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeWordsFromCounts(words: string[]): void {
|
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||||
for (const word of words) {
|
for (const entry of entries) {
|
||||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
if (nextCount > 0) {
|
const readings = this.wordReadingCounts.get(entry.word);
|
||||||
this.wordReferenceCounts.set(word, nextCount);
|
if (readings) {
|
||||||
} else {
|
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||||
this.wordReferenceCounts.delete(word);
|
if (nextCount > 0) {
|
||||||
this.knownWords.delete(word);
|
readings.set(readingKey, nextCount);
|
||||||
|
} else {
|
||||||
|
readings.delete(readingKey);
|
||||||
|
if (readings.size === 0) {
|
||||||
|
this.wordReadingCounts.delete(entry.word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.reading) {
|
||||||
|
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||||
|
if (nextReadingCount > 0) {
|
||||||
|
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||||
|
} else {
|
||||||
|
this.readingCounts.delete(entry.reading);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearInMemoryState(): void {
|
private clearInMemoryState(): void {
|
||||||
this.knownWords = new Set();
|
this.wordReadingCounts = new Map();
|
||||||
this.wordReferenceCounts = new Map();
|
this.readingCounts = new Map();
|
||||||
this.noteWordsById = new Map();
|
this.noteEntriesById = new Map();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
this.knownWordsLastRefreshedAtMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,32 +670,48 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
|
if (parsed.version === 3) {
|
||||||
|
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||||
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
||||||
|
if (normalizedEntries.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
|
}
|
||||||
|
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||||
|
this.knownWordsStateKey = parsed.scope;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.version === 2) {
|
if (parsed.version === 2) {
|
||||||
|
// Older states have no readings; load them reading-less (fail-open,
|
||||||
|
// matching the old behavior) but leave the cache marked stale so the
|
||||||
|
// next refresh upgrades entries with readings from Anki.
|
||||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalizedWords = normalizeKnownWordList(words);
|
const normalizedEntries = normalizeKnownWordEntryList(
|
||||||
if (normalizedWords.length === 0) {
|
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||||
|
);
|
||||||
|
if (normalizedEntries.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.noteWordsById.set(noteId, normalizedWords);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addWordsToCounts(normalizedWords);
|
this.addEntriesToCounts(normalizedEntries);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const value of parsed.words) {
|
|
||||||
const normalized = this.normalizeKnownWordForLookup(value);
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.knownWords.add(normalized);
|
|
||||||
this.wordReferenceCounts.set(normalized, 1);
|
|
||||||
}
|
}
|
||||||
|
this.knownWordsStateKey = parsed.scope;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||||
this.knownWordsStateKey = parsed.scope;
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
@@ -636,18 +721,17 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
private persistKnownWordCacheState(): void {
|
private persistKnownWordCacheState(): void {
|
||||||
try {
|
try {
|
||||||
const notes: Record<string, string[]> = {};
|
const notes: Record<string, KnownWordEntry[]> = {};
|
||||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||||
if (words.length > 0) {
|
if (entries.length > 0) {
|
||||||
notes[String(noteId)] = words;
|
notes[String(noteId)] = entries;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: KnownWordCacheStateV2 = {
|
const state: KnownWordCacheStateV3 = {
|
||||||
version: 2,
|
version: 3,
|
||||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||||
scope: this.knownWordsStateKey,
|
scope: this.knownWordsStateKey,
|
||||||
words: Array.from(this.knownWords),
|
|
||||||
notes,
|
notes,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||||
@@ -659,14 +743,18 @@ export class KnownWordCacheManager {
|
|||||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
|
||||||
if (typeof candidate.scope !== 'string') return false;
|
|
||||||
if (!Array.isArray(candidate.words)) return false;
|
|
||||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (candidate.version === 2) {
|
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||||
|
if (typeof candidate.scope !== 'string') return false;
|
||||||
|
if (candidate.version !== 3) {
|
||||||
|
if (!Array.isArray(candidate.words)) return false;
|
||||||
|
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.version === 2 || candidate.version === 3) {
|
||||||
if (
|
if (
|
||||||
typeof candidate.notes !== 'object' ||
|
typeof candidate.notes !== 'object' ||
|
||||||
candidate.notes === null ||
|
candidate.notes === null ||
|
||||||
@@ -674,10 +762,18 @@ export class KnownWordCacheManager {
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const isValidNoteEntry =
|
||||||
|
candidate.version === 2
|
||||||
|
? (entry: unknown): boolean => typeof entry === 'string'
|
||||||
|
: (entry: unknown): boolean =>
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
entry !== null &&
|
||||||
|
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||||
|
((entry as KnownWordEntry).reading === null ||
|
||||||
|
typeof (entry as KnownWordEntry).reading === 'string');
|
||||||
if (
|
if (
|
||||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||||
(entry) =>
|
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -686,11 +782,12 @@ export class KnownWordCacheManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNormalizedKnownWordsFromNoteInfo(
|
private extractKnownWordEntriesFromNoteInfo(
|
||||||
noteInfo: KnownWordCacheNoteInfo,
|
noteInfo: KnownWordCacheNoteInfo,
|
||||||
preferredFields = this.getConfiguredFields(),
|
preferredFields = this.getConfiguredFields(),
|
||||||
): string[] {
|
): KnownWordEntry[] {
|
||||||
const words: string[] = [];
|
const wordValues: string[] = [];
|
||||||
|
let noteReading: string | null = null;
|
||||||
for (const preferredField of preferredFields) {
|
for (const preferredField of preferredFields) {
|
||||||
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
||||||
if (!fieldName) continue;
|
if (!fieldName) continue;
|
||||||
@@ -698,12 +795,36 @@ export class KnownWordCacheManager {
|
|||||||
const raw = noteInfo.fields[fieldName]?.value;
|
const raw = noteInfo.fields[fieldName]?.value;
|
||||||
if (!raw) continue;
|
if (!raw) continue;
|
||||||
|
|
||||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
const cleaned = this.normalizeRawKnownWordValue(raw);
|
||||||
if (normalized) {
|
if (!cleaned) continue;
|
||||||
words.push(normalized);
|
|
||||||
|
if (isReadingFieldName(preferredField)) {
|
||||||
|
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
|
||||||
|
if (normalizedReading) {
|
||||||
|
noteReading ??= normalizedReading;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-kana content in a reading field: treat it as a word so decks
|
||||||
|
// with repurposed reading fields keep matching (fail-open).
|
||||||
}
|
}
|
||||||
|
wordValues.push(cleaned);
|
||||||
}
|
}
|
||||||
return normalizeKnownWordList(words);
|
|
||||||
|
const entries: KnownWordEntry[] = [];
|
||||||
|
for (const value of wordValues) {
|
||||||
|
const parsed = parseFuriganaAnnotatedText(value);
|
||||||
|
const word = parsed.text.trim().toLowerCase();
|
||||||
|
if (!word) continue;
|
||||||
|
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
|
||||||
|
entries.push({ word, reading: inlineReading || noteReading });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kana-only notes (reading field but no word field) stay matchable.
|
||||||
|
if (entries.length === 0 && noteReading) {
|
||||||
|
entries.push({ word: noteReading, reading: noteReading });
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeKnownWordEntryList(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRawKnownWordValue(value: string): string {
|
private normalizeRawKnownWordValue(value: string): string {
|
||||||
@@ -718,22 +839,6 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKnownWordList(words: string[]): string[] {
|
|
||||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < left.length; index += 1) {
|
|
||||||
if (left[index] !== right[index]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Known-word cache entries pair a word with the reading its Anki note teaches,
|
||||||
|
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
|
||||||
|
// unrelated words as known. reading === null means the note carries no usable
|
||||||
|
// reading and the word matches in any reading (fail-open).
|
||||||
|
export interface KnownWordEntry {
|
||||||
|
word: string;
|
||||||
|
reading: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||||
|
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||||
|
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||||
|
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
|
||||||
|
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
|
||||||
|
const WHITESPACE_PATTERN = /[\s ]+/g;
|
||||||
|
|
||||||
|
// Reading-bearing field names probed on every known-word note, in addition to
|
||||||
|
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
|
||||||
|
// "ExpressionReading" note types).
|
||||||
|
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
|
||||||
|
'Reading',
|
||||||
|
'Word Reading',
|
||||||
|
'ExpressionReading',
|
||||||
|
'Expression Reading',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isReadingFieldName(fieldName: string): boolean {
|
||||||
|
return /reading/i.test(fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertKatakanaToHiragana(text: string): string {
|
||||||
|
let converted = '';
|
||||||
|
for (const char of text) {
|
||||||
|
const code = char.codePointAt(0);
|
||||||
|
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
|
||||||
|
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
converted += char;
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHiraganaReadingChar(char: string): boolean {
|
||||||
|
const code = char.codePointAt(0);
|
||||||
|
if (code === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
|
||||||
|
// reading. Values without brackets pass through with reading null.
|
||||||
|
export function parseFuriganaAnnotatedText(value: string): {
|
||||||
|
text: string;
|
||||||
|
reading: string | null;
|
||||||
|
} {
|
||||||
|
if (!value.includes('[')) {
|
||||||
|
return { text: value, reading: null };
|
||||||
|
}
|
||||||
|
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
|
||||||
|
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
|
||||||
|
return { text, reading: reading.length > 0 ? reading : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the hiragana-normalized reading, or '' when the value is not a
|
||||||
|
// plausible kana reading (callers fall back to text-only matching then).
|
||||||
|
export function normalizeKnownReadingForLookup(value: string): string {
|
||||||
|
const parsed = parseFuriganaAnnotatedText(value.trim());
|
||||||
|
const candidate = (parsed.reading ?? parsed.text).trim();
|
||||||
|
if (!candidate) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const hiragana = convertKatakanaToHiragana(candidate);
|
||||||
|
for (const char of hiragana) {
|
||||||
|
if (!isHiraganaReadingChar(char)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hiragana;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
|
||||||
|
return `${entry.word}\u0000${entry.reading ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
|
||||||
|
const byKey = new Map<string, KnownWordEntry>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const word = entry.word.trim();
|
||||||
|
if (!word) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const reading = entry.reading?.trim() || null;
|
||||||
|
const normalized: KnownWordEntry = { word, reading };
|
||||||
|
byKey.set(makeKnownWordEntryKey(normalized), normalized);
|
||||||
|
}
|
||||||
|
return [...byKey.values()].sort((left, right) =>
|
||||||
|
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -317,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
kind: 'object',
|
kind: 'object',
|
||||||
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
||||||
description:
|
description:
|
||||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
|
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
|
|||||||
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
|
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
assert.deepEqual(seenArgs, ['90d', 'month', true]);
|
||||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||||
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||||
});
|
});
|
||||||
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
|
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(seenArgs, ['365d', 'month']);
|
assert.deepEqual(seenArgs, ['365d', 'month', true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||||
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
|
|||||||
|
|
||||||
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
|
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(seenArgs, ['30d', 'day']);
|
assert.deepEqual(seenArgs, ['30d', 'day', true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
|
||||||
|
let seenArgs: unknown[] = [];
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getTrendsDashboard: async (...args: unknown[]) => {
|
||||||
|
seenArgs = args;
|
||||||
|
return TRENDS_DASHBOARD;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request(
|
||||||
|
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(seenArgs, ['30d', 'day', false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||||
|
|||||||
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand ensures background stats server for initial --start --background', () => {
|
||||||
|
const ensured: number[] = [];
|
||||||
|
const { deps } = createDeps({
|
||||||
|
ensureBackgroundStatsServer: () => {
|
||||||
|
ensured.push(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
|
||||||
|
|
||||||
|
assert.equal(ensured.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
|
||||||
|
const ensured: number[] = [];
|
||||||
|
const { deps } = createDeps({
|
||||||
|
isOverlayRuntimeInitialized: () => true,
|
||||||
|
ensureBackgroundStatsServer: () => {
|
||||||
|
ensured.push(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
|
||||||
|
|
||||||
|
assert.equal(ensured.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
||||||
|
const ensured: number[] = [];
|
||||||
|
const { deps } = createDeps({
|
||||||
|
ensureBackgroundStatsServer: () => {
|
||||||
|
ensured.push(1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
|
||||||
|
|
||||||
|
assert.equal(ensured.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
|||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
source: CliCommandSource;
|
source: CliCommandSource;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
ensureBackgroundStatsServer?: () => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
getMultiCopyTimeoutMs: () => number;
|
getMultiCopyTimeoutMs: () => number;
|
||||||
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
|
|||||||
interface AppCliRuntime {
|
interface AppCliRuntime {
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
|
ensureBackgroundStatsServer?: () => void;
|
||||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||||
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
runUpdateCommand: options.app.runUpdateCommand,
|
runUpdateCommand: options.app.runUpdateCommand,
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||||
|
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||||
@@ -393,6 +396,10 @@ export function handleCliCommand(
|
|||||||
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
|
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.start && args.background) {
|
||||||
|
deps.ensureBackgroundStatsServer?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (args.sessionAction) {
|
if (args.sessionAction) {
|
||||||
dispatchCliSessionAction(
|
dispatchCliSessionAction(
|
||||||
args.sessionAction,
|
args.sessionAction,
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
|||||||
restoreOnModalClose: 'kiku',
|
restoreOnModalClose: 'kiku',
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
},
|
},
|
||||||
|
// Abandonment also asks the renderer hosting the modal to close its dialog.
|
||||||
|
{
|
||||||
|
channel: 'kiku:field-grouping-cancel',
|
||||||
|
restoreOnModalClose: undefined,
|
||||||
|
preferModalWindow: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(waitCalls, [
|
assert.deepEqual(waitCalls, [
|
||||||
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
|||||||
assert.deepEqual(warnings, [
|
assert.deepEqual(warnings, [
|
||||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(closed, ['kiku']);
|
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
|
||||||
|
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
|
||||||
|
// the duplicate is a harmless no-op.
|
||||||
|
assert.deepEqual(closed, ['kiku', 'kiku']);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
|
||||||
|
// The field grouping modal must run the same prerequisites as every other modal
|
||||||
|
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
|
||||||
|
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
|
||||||
|
const order: string[] = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
// The modal acknowledges open below, so the callback stays pending and arms its response
|
||||||
|
// timeout; stub the timer so no real 90s handle leaks into the test runner.
|
||||||
|
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: () => null,
|
||||||
|
setResolver: () => {},
|
||||||
|
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||||
|
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
|
||||||
|
sendToVisibleOverlay: (channel) => {
|
||||||
|
order.push(`send:${channel}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
waitForModalOpen: async () => {
|
||||||
|
order.push('wait');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
|
||||||
|
void runtime.createFieldGroupingCallback()({
|
||||||
|
original: {
|
||||||
|
noteId: 1,
|
||||||
|
expression: 'a',
|
||||||
|
sentencePreview: 'a',
|
||||||
|
hasAudio: false,
|
||||||
|
hasImage: false,
|
||||||
|
isOriginal: true,
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
noteId: 2,
|
||||||
|
expression: 'b',
|
||||||
|
sentencePreview: 'b',
|
||||||
|
hasAudio: false,
|
||||||
|
hasImage: false,
|
||||||
|
isOriginal: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the async send + modal-open ack chain run.
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(order, [
|
||||||
|
'prereqs',
|
||||||
|
'windows-ready',
|
||||||
|
'send:kiku:field-grouping-request',
|
||||||
|
'wait',
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.setTimeout = originalSetTimeout;
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||||
|
|
||||||
interface WindowLike {
|
interface WindowLike {
|
||||||
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
|||||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||||
handleOverlayModalClosed?: (modal: T) => void;
|
handleOverlayModalClosed?: (modal: T) => void;
|
||||||
logWarn?: (message: string) => void;
|
logWarn?: (message: string) => void;
|
||||||
|
/**
|
||||||
|
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
|
||||||
|
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
|
||||||
|
* them the field grouping modal can open with no sibling overlay window present, and on
|
||||||
|
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
|
||||||
|
* modals do.
|
||||||
|
*/
|
||||||
|
ensureOverlayStartupPrereqs?: () => void;
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
|
||||||
sendToVisibleOverlay?: (
|
sendToVisibleOverlay?: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const kikuModal = 'kiku' as T;
|
const kikuModal = 'kiku' as T;
|
||||||
const sendOpen = (): boolean =>
|
const sendOpen = (): boolean => {
|
||||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
|
||||||
|
// and visible overlay window exist before handing off to the dedicated modal window.
|
||||||
|
options.ensureOverlayStartupPrereqs?.();
|
||||||
|
options.ensureOverlayWindowsReadyForVisibilityActions?.();
|
||||||
|
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||||
restoreOnModalClose: kikuModal,
|
restoreOnModalClose: kikuModal,
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!options.waitForModalOpen) {
|
if (!options.waitForModalOpen) {
|
||||||
return sendOpen();
|
return sendOpen();
|
||||||
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
return opened;
|
return opened;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dismissModalUi = (): void => {
|
||||||
|
const kikuModal = 'kiku' as T;
|
||||||
|
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
|
||||||
|
// lives in the dedicated modal window this is redundant with the teardown below, but it
|
||||||
|
// also covers the case where the request was routed into the visible overlay.
|
||||||
|
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
|
||||||
|
preferModalWindow: true,
|
||||||
|
});
|
||||||
|
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
|
||||||
|
// dedicated modal window). This is what recovers the frozen overlay when a grouping
|
||||||
|
// request times out or fails to reach a visible modal.
|
||||||
|
options.handleOverlayModalClosed?.(kikuModal);
|
||||||
|
};
|
||||||
|
|
||||||
const createFieldGroupingCallback = (): ((
|
const createFieldGroupingCallback = (): ((
|
||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>) => {
|
) => Promise<KikuFieldGroupingChoice>) => {
|
||||||
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
|||||||
setResolver: options.setResolver,
|
setResolver: options.setResolver,
|
||||||
sendToVisibleOverlay,
|
sendToVisibleOverlay,
|
||||||
sendKikuFieldGroupingRequest,
|
sendKikuFieldGroupingRequest,
|
||||||
|
dismissModalUi,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||||
|
import { createFieldGroupingCallback } from './field-grouping';
|
||||||
|
|
||||||
|
function makeRequestData(): KikuFieldGroupingRequestData {
|
||||||
|
return {
|
||||||
|
original: {
|
||||||
|
noteId: 1,
|
||||||
|
expression: 'a',
|
||||||
|
sentencePreview: 'a',
|
||||||
|
hasAudio: false,
|
||||||
|
hasImage: false,
|
||||||
|
isOriginal: true,
|
||||||
|
},
|
||||||
|
duplicate: {
|
||||||
|
noteId: 2,
|
||||||
|
expression: 'a',
|
||||||
|
sentencePreview: 'b',
|
||||||
|
hasAudio: false,
|
||||||
|
hasImage: false,
|
||||||
|
isOriginal: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
|
||||||
|
* sequence-guarded closure, so the value read back is never identity-equal to the
|
||||||
|
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
|
||||||
|
* never matched and leaked the resolver, wedging every later grouping attempt.
|
||||||
|
*/
|
||||||
|
function createWrappedResolverStore() {
|
||||||
|
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||||
|
return {
|
||||||
|
getResolver: () => stored,
|
||||||
|
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||||
|
stored = resolver ? (choice) => resolver(choice) : null;
|
||||||
|
},
|
||||||
|
respond: (choice: KikuFieldGroupingChoice) => {
|
||||||
|
stored?.(choice);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
let visible = false;
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => visible,
|
||||||
|
setVisibleOverlayVisible: (next) => {
|
||||||
|
visible = next;
|
||||||
|
},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = callback(makeRequestData());
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.notEqual(store.getResolver(), null);
|
||||||
|
|
||||||
|
const choice: KikuFieldGroupingChoice = {
|
||||||
|
keepNoteId: 1,
|
||||||
|
deleteNoteId: 2,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: false,
|
||||||
|
};
|
||||||
|
store.respond(choice);
|
||||||
|
|
||||||
|
assert.deepEqual(await pending, choice);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback does not reject the next request after a response', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = callback(makeRequestData());
|
||||||
|
await Promise.resolve();
|
||||||
|
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||||
|
const firstChoice = await first;
|
||||||
|
assert.equal(firstChoice.cancelled, false);
|
||||||
|
|
||||||
|
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
|
||||||
|
const second = callback(makeRequestData());
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.notEqual(store.getResolver(), null);
|
||||||
|
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
|
||||||
|
const secondChoice = await second;
|
||||||
|
assert.equal(secondChoice.cancelled, false);
|
||||||
|
assert.equal(secondChoice.keepNoteId, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
let dismissed = 0;
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => false,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
dismissed += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callback(makeRequestData());
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(dismissed, 1);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => false,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
throw new Error('dismiss failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callback(makeRequestData());
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => true,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
throw new Error('dismiss failed');
|
||||||
|
},
|
||||||
|
responseTimeoutMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callback(makeRequestData());
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback reports modal dismiss failures', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
errors.push(args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => false,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
throw new Error('dismiss failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await callback(makeRequestData());
|
||||||
|
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
} finally {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
let dismissed = 0;
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => true,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
dismissed += 1;
|
||||||
|
},
|
||||||
|
responseTimeoutMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callback(makeRequestData());
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(dismissed, 1);
|
||||||
|
assert.equal(store.getResolver(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
let dismissed = 0;
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => true,
|
||||||
|
dismissModalUi: () => {
|
||||||
|
dismissed += 1;
|
||||||
|
},
|
||||||
|
responseTimeoutMs: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = callback(makeRequestData());
|
||||||
|
await Promise.resolve();
|
||||||
|
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||||
|
await pending;
|
||||||
|
assert.equal(dismissed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field grouping callback rejects a concurrent request while one is pending', async () => {
|
||||||
|
const store = createWrappedResolverStore();
|
||||||
|
let sends = 0;
|
||||||
|
let dismissed = 0;
|
||||||
|
const callback = createFieldGroupingCallback({
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: store.getResolver,
|
||||||
|
setResolver: store.setResolver,
|
||||||
|
sendRequestToVisibleOverlay: () => {
|
||||||
|
sends += 1;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
dismissModalUi: () => {
|
||||||
|
dismissed += 1;
|
||||||
|
},
|
||||||
|
responseTimeoutMs: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = callback(makeRequestData());
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(sends, 1);
|
||||||
|
|
||||||
|
const second = await callback(makeRequestData());
|
||||||
|
assert.equal(second.cancelled, true);
|
||||||
|
assert.equal(sends, 1);
|
||||||
|
assert.equal(dismissed, 0);
|
||||||
|
|
||||||
|
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||||
|
await first;
|
||||||
|
});
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
|
||||||
|
|
||||||
export function createFieldGroupingCallback(options: {
|
export function createFieldGroupingCallback(options: {
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Tears down the modal UI when the request is abandoned without a renderer response
|
||||||
|
* (send failure or response timeout). Without this the dedicated modal window, its
|
||||||
|
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
|
||||||
|
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
|
||||||
|
*/
|
||||||
|
dismissModalUi?: () => void;
|
||||||
|
responseTimeoutMs?: number;
|
||||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||||
|
const cancelledChoice = (): KikuFieldGroupingChoice => ({
|
||||||
|
keepNoteId: 0,
|
||||||
|
deleteNoteId: 0,
|
||||||
|
deleteDuplicate: true,
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissModalUi = (): void => {
|
||||||
|
try {
|
||||||
|
options.dismissModalUi?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (options.getResolver()) {
|
if (options.getResolver()) {
|
||||||
resolve({
|
resolve(cancelledChoice());
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (timeout !== null) {
|
if (timeout !== null) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = null;
|
timeout = null;
|
||||||
}
|
}
|
||||||
if (options.getResolver() === finish) {
|
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
|
||||||
options.setResolver(null);
|
// resolver, so an identity check against `finish` never matches and would leak
|
||||||
}
|
// the resolver — blocking every later grouping attempt with an instant cancel.
|
||||||
|
options.setResolver(null);
|
||||||
resolve(choice);
|
resolve(choice);
|
||||||
|
|
||||||
|
// When abandoned without a renderer response, tear down the modal window/state
|
||||||
|
// that the request path spun up. A normal response already routes through the
|
||||||
|
// renderer's close handler, so only the abandon paths need this.
|
||||||
|
if (abandoned) {
|
||||||
|
dismissModalUi();
|
||||||
|
}
|
||||||
|
|
||||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||||
options.setVisibleOverlayVisible(false);
|
options.setVisibleOverlayVisible(false);
|
||||||
}
|
}
|
||||||
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
|
|||||||
(sent) => {
|
(sent) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 90000);
|
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
finish({
|
finish(cancelledChoice(), true);
|
||||||
keepNoteId: 0,
|
|
||||||
deleteNoteId: 0,
|
|
||||||
deleteDuplicate: true,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildHyprlandPlacementDispatches,
|
buildHyprlandPlacementDispatches,
|
||||||
ensureHyprlandWindowFloatingByTitle,
|
ensureHyprlandWindowFloatingByTitle,
|
||||||
|
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||||
findHyprlandWindowForPlacement,
|
findHyprlandWindowForPlacement,
|
||||||
hasHyprlandWindowPlacementBoundsMismatch,
|
hasHyprlandWindowPlacementBoundsMismatch,
|
||||||
shouldAttemptHyprlandWindowPlacement,
|
shouldAttemptHyprlandWindowPlacement,
|
||||||
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
|
||||||
|
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||||
|
title: 'SubMiner Overlay Modal',
|
||||||
|
platform: 'linux',
|
||||||
|
env: {},
|
||||||
|
execFileSync: (() => {
|
||||||
|
throw new Error('should not query the compositor when placement is not applicable');
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
|
||||||
|
// The window has not been mapped by the compositor yet, so no client matches. Callers use
|
||||||
|
// this to keep retrying until the modal is actually placed above fullscreen mpv.
|
||||||
|
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||||
|
title: 'SubMiner Overlay Modal',
|
||||||
|
platform: 'linux',
|
||||||
|
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||||
|
pid: 999,
|
||||||
|
execFileSync: ((command: string, args: string[]) => {
|
||||||
|
if (args.join(' ') === '-j clients') {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
|
||||||
|
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||||
|
title: 'SubMiner Overlay Modal',
|
||||||
|
platform: 'linux',
|
||||||
|
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||||
|
pid: 456,
|
||||||
|
execFileSync: ((command: string, args: string[]) => {
|
||||||
|
if (args.join(' ') === '-j clients') {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
address: '0xmatch',
|
||||||
|
pid: 456,
|
||||||
|
title: 'SubMiner Overlay Modal',
|
||||||
|
mapped: true,
|
||||||
|
floating: false,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (args.join(' ') === '-j status') {
|
||||||
|
return JSON.stringify({ configProvider: 'lua' });
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}) as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||||
const calls: unknown[][] = [];
|
const calls: unknown[][] = [];
|
||||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||||
|
|||||||
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement outcome for a single reconcile attempt.
|
||||||
|
* - `applicable`: false when not running under Hyprland (nothing to retry).
|
||||||
|
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
|
||||||
|
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
|
||||||
|
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
|
||||||
|
* invisible.
|
||||||
|
* - `dispatched`: whether any hyprctl dispatch was issued.
|
||||||
|
*/
|
||||||
|
export interface HyprlandPlacementStatus {
|
||||||
|
applicable: boolean;
|
||||||
|
clientFound: boolean;
|
||||||
|
dispatched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||||
title: string;
|
title: string;
|
||||||
bounds?: HyprlandPlacementBounds | null;
|
bounds?: HyprlandPlacementBounds | null;
|
||||||
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
promote?: boolean;
|
promote?: boolean;
|
||||||
execFileSync?: ExecFileSync;
|
execFileSync?: ExecFileSync;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
|
||||||
|
title: string;
|
||||||
|
bounds?: HyprlandPlacementBounds | null;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
pid?: number;
|
||||||
|
promote?: boolean;
|
||||||
|
execFileSync?: ExecFileSync;
|
||||||
|
}): HyprlandPlacementStatus {
|
||||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||||
return false;
|
return { applicable: false, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = options.execFileSync ?? execFileSync;
|
const run = options.execFileSync ?? execFileSync;
|
||||||
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
title: options.title,
|
title: options.title,
|
||||||
});
|
});
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return false;
|
return { applicable: true, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const configProvider = detectHyprlandConfigProvider(run);
|
const configProvider = detectHyprlandConfigProvider(run);
|
||||||
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dispatches.length > 0;
|
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return { applicable: true, clientFound: false, dispatched: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
|
|||||||
async getTrendsDashboard(
|
async getTrendsDashboard(
|
||||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||||
groupBy: 'day' | 'month' = 'day',
|
groupBy: 'day' | 'month' = 'day',
|
||||||
|
fillEmptyBuckets = true,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return getTrendsDashboard(this.db, range, groupBy);
|
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
||||||
|
|||||||
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
withMockNowMs('1772395200000', () => {
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
|
||||||
|
canonicalTitle: '30d Zero Fill',
|
||||||
|
sourcePath: '/tmp/30d-zerofill.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertDailyRollup = db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const createdAtMs = '1772395200000';
|
||||||
|
// Local "today" for the mocked clock is epoch day 20513. Seed only two
|
||||||
|
// active days inside the 30-day window, leaving the rest empty.
|
||||||
|
const todayEpochDay = 20513;
|
||||||
|
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||||
|
insertDailyRollup.run(
|
||||||
|
todayEpochDay - 10,
|
||||||
|
videoId,
|
||||||
|
1,
|
||||||
|
45,
|
||||||
|
4,
|
||||||
|
120,
|
||||||
|
3,
|
||||||
|
createdAtMs,
|
||||||
|
createdAtMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dashboard = getTrendsDashboard(db, '30d', 'day');
|
||||||
|
|
||||||
|
// Exactly 30 calendar days, not just the two active ones.
|
||||||
|
assert.equal(dashboard.activity.watchTime.length, 30);
|
||||||
|
// Most recent day carries its seeded value; a gap day reads zero.
|
||||||
|
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
|
||||||
|
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
|
||||||
|
// Only the two seeded days contribute to the totals.
|
||||||
|
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
|
||||||
|
assert.equal(nonZeroDays.length, 2);
|
||||||
|
// Cumulative watch time still tops out at the sum of both active days.
|
||||||
|
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
|
||||||
|
// Every day-bucketed series shares the same 30-day axis.
|
||||||
|
assert.equal(dashboard.progress.episodes.length, 30);
|
||||||
|
assert.deepEqual(
|
||||||
|
dashboard.progress.episodes.map((point) => point.label),
|
||||||
|
dashboard.activity.watchTime.map((point) => point.label),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
withMockNowMs('1772395200000', () => {
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
|
||||||
|
canonicalTitle: 'No Zero Fill',
|
||||||
|
sourcePath: '/tmp/no-zerofill.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertDailyRollup = db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const createdAtMs = '1772395200000';
|
||||||
|
const todayEpochDay = 20513;
|
||||||
|
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||||
|
insertDailyRollup.run(
|
||||||
|
todayEpochDay - 10,
|
||||||
|
videoId,
|
||||||
|
1,
|
||||||
|
45,
|
||||||
|
4,
|
||||||
|
120,
|
||||||
|
3,
|
||||||
|
createdAtMs,
|
||||||
|
createdAtMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filled = getTrendsDashboard(db, '30d', 'day', true);
|
||||||
|
assert.equal(filled.activity.watchTime.length, 30);
|
||||||
|
|
||||||
|
const compact = getTrendsDashboard(db, '30d', 'day', false);
|
||||||
|
// Only the two active days survive; no zero-filled gaps.
|
||||||
|
assert.equal(compact.activity.watchTime.length, 2);
|
||||||
|
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
||||||
{ timeout: 20_000 },
|
{ timeout: 20_000 },
|
||||||
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
|
|
||||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||||
|
|
||||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
|
||||||
|
// months, so January is zero-filled rather than dropped.
|
||||||
|
assert.equal(dashboard.activity.watchTime.length, 3);
|
||||||
|
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
dashboard.progress.newWords.map((point) => point.label),
|
dashboard.progress.newWords.map((point) => point.label),
|
||||||
dashboard.activity.watchTime.map((point) => point.label),
|
dashboard.activity.watchTime.map((point) => point.label),
|
||||||
|
|||||||
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
|
|||||||
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
|
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
|
||||||
|
// selected range, so charts render one point per calendar bucket instead of
|
||||||
|
// silently collapsing to only the buckets that have activity. Returns null for
|
||||||
|
// the unbounded "all" range, where builders fall back to the buckets in data.
|
||||||
|
function buildBucketAxis(
|
||||||
|
db: DatabaseSync,
|
||||||
|
groupBy: TrendGroupBy,
|
||||||
|
cutoffMs: string | null,
|
||||||
|
referenceMs: string,
|
||||||
|
): number[] | null {
|
||||||
|
if (cutoffMs === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
const startKey = getLocalMonthKey(db, cutoffMs);
|
||||||
|
const endKey = getLocalMonthKey(db, referenceMs);
|
||||||
|
const keys: number[] = [];
|
||||||
|
let year = Math.floor(startKey / 100);
|
||||||
|
let month = startKey % 100;
|
||||||
|
const endYear = Math.floor(endKey / 100);
|
||||||
|
const endMonth = endKey % 100;
|
||||||
|
while (year < endYear || (year === endYear && month <= endMonth)) {
|
||||||
|
keys.push(year * 100 + month);
|
||||||
|
month += 1;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDay = getLocalEpochDay(db, cutoffMs);
|
||||||
|
const endDay = getLocalEpochDay(db, referenceMs);
|
||||||
|
const keys: number[] = [];
|
||||||
|
for (let day = startDay; day <= endDay; day += 1) {
|
||||||
|
keys.push(day);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project a bucket→value map onto the axis, filling absent buckets with zero.
|
||||||
|
// Without an axis (the "all" range) it falls back to the populated buckets in
|
||||||
|
// ascending order, preserving the previous behaviour.
|
||||||
|
function fillAxisPoints(
|
||||||
|
axis: number[] | null,
|
||||||
|
valueByBucket: Map<number, number>,
|
||||||
|
): TrendChartPoint[] {
|
||||||
|
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
|
||||||
|
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
return points.map((point) => {
|
return points.map((point) => {
|
||||||
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
|
||||||
const byKey = new Map<
|
const byKey = new Map<
|
||||||
number,
|
number,
|
||||||
{ activeMin: number; cards: number; words: number; sessions: number }
|
{ activeMin: number; cards: number; words: number; sessions: number }
|
||||||
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
|||||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(byKey.entries())
|
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
|
||||||
.sort(([left], [right]) => left - right)
|
return keys.map((key) => {
|
||||||
.map(([key, value]) => ({
|
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
|
||||||
|
return {
|
||||||
label: makeTrendLabel(key),
|
label: makeTrendLabel(key),
|
||||||
activeMin: Math.round(value.activeMin),
|
activeMin: Math.round(value.activeMin),
|
||||||
cards: value.cards,
|
cards: value.cards,
|
||||||
words: value.words,
|
words: value.words,
|
||||||
sessions: value.sessions,
|
sessions: value.sessions,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
||||||
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayLabel(epochDay: number): string {
|
function buildSessionSeries(
|
||||||
const { month, day } = dayPartsFromEpochDay(epochDay);
|
|
||||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessionSeriesByDay(
|
|
||||||
sessions: TrendSessionMetricRow[],
|
sessions: TrendSessionMetricRow[],
|
||||||
|
groupBy: TrendGroupBy,
|
||||||
getValue: (session: TrendSessionMetricRow) => number,
|
getValue: (session: TrendSessionMetricRow) => number,
|
||||||
|
axis: number[] | null,
|
||||||
): TrendChartPoint[] {
|
): TrendChartPoint[] {
|
||||||
const byDay = new Map<number, number>();
|
const byBucket = new Map<number, number>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
|
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
|
||||||
|
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
|
||||||
}
|
}
|
||||||
return Array.from(byDay.entries())
|
return fillAxisPoints(axis, byBucket);
|
||||||
.sort(([left], [right]) => left - right)
|
|
||||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessionSeriesByMonth(
|
|
||||||
sessions: TrendSessionMetricRow[],
|
|
||||||
getValue: (session: TrendSessionMetricRow) => number,
|
|
||||||
): TrendChartPoint[] {
|
|
||||||
const byMonth = new Map<number, number>();
|
|
||||||
for (const session of sessions) {
|
|
||||||
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
|
|
||||||
}
|
|
||||||
return Array.from(byMonth.entries())
|
|
||||||
.sort(([left], [right]) => left - right)
|
|
||||||
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLookupsPerHundredWords(
|
function buildLookupsPerHundredWords(
|
||||||
sessions: TrendSessionMetricRow[],
|
sessions: TrendSessionMetricRow[],
|
||||||
groupBy: TrendGroupBy,
|
groupBy: TrendGroupBy,
|
||||||
|
axis: number[] | null,
|
||||||
): TrendChartPoint[] {
|
): TrendChartPoint[] {
|
||||||
const lookupsByBucket = new Map<number, number>();
|
const lookupsByBucket = new Map<number, number>();
|
||||||
const wordsByBucket = new Map<number, number>();
|
const wordsByBucket = new Map<number, number>();
|
||||||
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(lookupsByBucket.entries())
|
const ratioByBucket = new Map<number, number>();
|
||||||
.sort(([left], [right]) => left - right)
|
for (const [bucketKey, lookups] of lookupsByBucket) {
|
||||||
.map(([bucketKey, lookups]) => {
|
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
|
||||||
return {
|
}
|
||||||
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
|
return fillAxisPoints(axis, ratioByBucket);
|
||||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||||
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEpisodesPerDayFromDailyRollups(
|
function buildEpisodesSeriesFromRollups(
|
||||||
rollups: ImmersionSessionRollupRow[],
|
rollups: ImmersionSessionRollupRow[],
|
||||||
|
axis: number[] | null,
|
||||||
): TrendChartPoint[] {
|
): TrendChartPoint[] {
|
||||||
const byDay = new Map<number, Set<number>>();
|
const byBucket = new Map<number, Set<number>>();
|
||||||
|
|
||||||
for (const rollup of rollups) {
|
for (const rollup of rollups) {
|
||||||
if (rollup.videoId === null) {
|
if (rollup.videoId === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||||
videoIds.add(rollup.videoId);
|
videoIds.add(rollup.videoId);
|
||||||
byDay.set(rollup.rollupDayOrMonth, videoIds);
|
byBucket.set(rollup.rollupDayOrMonth, videoIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(byDay.entries())
|
const counts = new Map<number, number>();
|
||||||
.sort(([left], [right]) => left - right)
|
for (const [bucketKey, videoIds] of byBucket) {
|
||||||
.map(([epochDay, videoIds]) => ({
|
counts.set(bucketKey, videoIds.size);
|
||||||
label: dayLabel(epochDay),
|
|
||||||
value: videoIds.size,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
|
|
||||||
const byMonth = new Map<number, Set<number>>();
|
|
||||||
|
|
||||||
for (const rollup of rollups) {
|
|
||||||
if (rollup.videoId === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
|
||||||
videoIds.add(rollup.videoId);
|
|
||||||
byMonth.set(rollup.rollupDayOrMonth, videoIds);
|
|
||||||
}
|
}
|
||||||
|
return fillAxisPoints(axis, counts);
|
||||||
return Array.from(byMonth.entries())
|
|
||||||
.sort(([left], [right]) => left - right)
|
|
||||||
.map(([monthKey, videoIds]) => ({
|
|
||||||
label: makeTrendLabel(monthKey),
|
|
||||||
value: videoIds.size,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrendSessionMetrics(
|
function getTrendSessionMetrics(
|
||||||
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
function buildNewWordsPerDay(
|
||||||
|
db: DatabaseSync,
|
||||||
|
cutoffMs: string | null,
|
||||||
|
axis: number[] | null,
|
||||||
|
): TrendChartPoint[] {
|
||||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
|
|||||||
wordCount: number;
|
wordCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return rows.map((row) => ({
|
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
|
||||||
label: dayLabel(row.epochDay),
|
return fillAxisPoints(axis, byBucket);
|
||||||
value: row.wordCount,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
function buildNewWordsPerMonth(
|
||||||
|
db: DatabaseSync,
|
||||||
|
cutoffMs: string | null,
|
||||||
|
axis: number[] | null,
|
||||||
|
): TrendChartPoint[] {
|
||||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||||
const prepared = db.prepare(`
|
const prepared = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
|
|||||||
wordCount: number;
|
wordCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return rows.map((row) => ({
|
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
|
||||||
label: makeTrendLabel(row.monthKey),
|
return fillAxisPoints(axis, byBucket);
|
||||||
value: row.wordCount,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTrendsDashboard(
|
export function getTrendsDashboard(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
range: TrendRange = '30d',
|
range: TrendRange = '30d',
|
||||||
groupBy: TrendGroupBy = 'day',
|
groupBy: TrendGroupBy = 'day',
|
||||||
|
fillEmptyBuckets = true,
|
||||||
): TrendsDashboardQueryResult {
|
): TrendsDashboardQueryResult {
|
||||||
const dayLimit = getTrendDayLimit(range);
|
const dayLimit = getTrendDayLimit(range);
|
||||||
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
||||||
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
|
|||||||
const useMonthlyBuckets = groupBy === 'month';
|
const useMonthlyBuckets = groupBy === 'month';
|
||||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||||
|
// A null axis makes the builders fall back to only the buckets present in the
|
||||||
|
// data; the contiguous axis zero-fills every calendar bucket in the window.
|
||||||
|
const bucketAxis = fillEmptyBuckets
|
||||||
|
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
|
||||||
|
: null;
|
||||||
|
|
||||||
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||||
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
|
|||||||
dailyRollups.map((rollup) => rollup.videoId),
|
dailyRollups.map((rollup) => rollup.videoId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
|
||||||
const efficiency = buildEfficiencyRates(aggregatedRows);
|
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||||
const activity = {
|
const activity = {
|
||||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||||
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
|
|||||||
sessions: accumulatePoints(activity.sessions),
|
sessions: accumulatePoints(activity.sessions),
|
||||||
words: accumulatePoints(activity.words),
|
words: accumulatePoints(activity.words),
|
||||||
newWords: accumulatePoints(
|
newWords: accumulatePoints(
|
||||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
useMonthlyBuckets
|
||||||
|
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
|
||||||
|
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
|
||||||
),
|
),
|
||||||
cards: accumulatePoints(activity.cards),
|
cards: accumulatePoints(activity.cards),
|
||||||
episodes: accumulatePoints(
|
episodes: accumulatePoints(
|
||||||
useMonthlyBuckets
|
buildEpisodesSeriesFromRollups(
|
||||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
useMonthlyBuckets ? monthlyRollups : dailyRollups,
|
||||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
bucketAxis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
lookups: accumulatePoints(
|
lookups: accumulatePoints(
|
||||||
useMonthlyBuckets
|
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
|
||||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
|
||||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
|
||||||
cardsPerHour: efficiency.cardsPerHour,
|
cardsPerHour: efficiency.cardsPerHour,
|
||||||
readingSpeed: efficiency.readingSpeed,
|
readingSpeed: efficiency.readingSpeed,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
|||||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||||
) => boolean;
|
) => boolean;
|
||||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||||
|
dismissModalUi?: () => void;
|
||||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||||
return createFieldGroupingCallback({
|
return createFieldGroupingCallback({
|
||||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||||
getResolver: options.getResolver,
|
getResolver: options.getResolver,
|
||||||
setResolver: options.setResolver,
|
setResolver: options.setResolver,
|
||||||
|
dismissModalUi: options.dismissModalUi,
|
||||||
sendRequestToVisibleOverlay: (data) =>
|
sendRequestToVisibleOverlay: (data) =>
|
||||||
options.sendKikuFieldGroupingRequest
|
options.sendKikuFieldGroupingRequest
|
||||||
? options.sendKikuFieldGroupingRequest(data)
|
? options.sendKikuFieldGroupingRequest(data)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||||
import { updateOverlayWindowBounds } from './overlay-window';
|
import { updateOverlayWindowBounds } from './overlay-window';
|
||||||
|
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
|
||||||
|
|
||||||
export interface OverlayManager {
|
export interface OverlayManager {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
@@ -9,7 +10,7 @@ export interface OverlayManager {
|
|||||||
setModalWindow: (window: BrowserWindow | null) => void;
|
setModalWindow: (window: BrowserWindow | null) => void;
|
||||||
getOverlayWindow: () => BrowserWindow | null;
|
getOverlayWindow: () => BrowserWindow | null;
|
||||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
|||||||
let visibleOverlayVisible = false;
|
let visibleOverlayVisible = false;
|
||||||
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
||||||
|
|
||||||
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
|
const updateWindowBounds = (
|
||||||
|
geometry: WindowGeometry,
|
||||||
|
window: BrowserWindow | null,
|
||||||
|
): HyprlandPlacementStatus => {
|
||||||
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
||||||
applyOverlayBounds(geometry, window, { promote });
|
return applyOverlayBounds(geometry, window, { promote });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
|||||||
setOverlayWindowBounds: (geometry) => {
|
setOverlayWindowBounds: (geometry) => {
|
||||||
updateWindowBounds(geometry, mainWindow);
|
updateWindowBounds(geometry, mainWindow);
|
||||||
},
|
},
|
||||||
setModalWindowBounds: (geometry) => {
|
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
|
||||||
updateWindowBounds(geometry, modalWindow);
|
|
||||||
},
|
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: (visible) => {
|
setVisibleOverlayVisible: (visible) => {
|
||||||
visibleOverlayVisible = visible;
|
visibleOverlayVisible = visible;
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
handleOverlayWindowBlurred,
|
handleOverlayWindowBlurred,
|
||||||
type OverlayWindowKind,
|
type OverlayWindowKind,
|
||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
|
import {
|
||||||
|
ensureHyprlandWindowFloatingByTitle,
|
||||||
|
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||||
|
type HyprlandPlacementStatus,
|
||||||
|
} from './hyprland-window-placement';
|
||||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||||
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
|
|||||||
options: {
|
options: {
|
||||||
promote?: boolean;
|
promote?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): void {
|
): HyprlandPlacementStatus {
|
||||||
if (!geometry || !window || window.isDestroyed()) return;
|
if (!geometry || !window || window.isDestroyed()) {
|
||||||
|
return { applicable: false, clientFound: false, dispatched: false };
|
||||||
|
}
|
||||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||||
geometry,
|
geometry,
|
||||||
process.platform,
|
process.platform,
|
||||||
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
|
|||||||
window,
|
window,
|
||||||
);
|
);
|
||||||
window.setBounds(bounds);
|
window.setBounds(bounds);
|
||||||
ensureHyprlandWindowFloatingByTitle({
|
return ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||||
title: window.getTitle(),
|
title: window.getTitle(),
|
||||||
bounds,
|
bounds,
|
||||||
promote: options.promote,
|
promote: options.promote,
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
|||||||
return raw === 'month' ? 'month' : 'day';
|
return raw === 'month' ? 'month' : 'day';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defaults to true (zero-fill empty calendar buckets); only an explicit
|
||||||
|
// "false" opts into the compact, data-only view.
|
||||||
|
function parseTrendFillEmpty(raw: string | undefined): boolean {
|
||||||
|
return raw !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const parsed = raw
|
const parsed = raw
|
||||||
@@ -685,7 +691,8 @@ export function createStatsApp(
|
|||||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||||
const range = parseTrendRange(c.req.query('range'));
|
const range = parseTrendRange(c.req.query('range'));
|
||||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||||
return c.json(await tracker.getTrendsDashboard(range, groupBy));
|
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||||
|
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions', async (c) => {
|
app.get('/api/stats/sessions', async (c) => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface TokenizerServiceDeps {
|
|||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: (text: string) => boolean;
|
isKnownWord: (text: string, reading?: string) => boolean;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
getKnownWordsEnabled?: () => boolean;
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
@@ -77,7 +77,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: (text: string) => boolean;
|
isKnownWord: (text: string, reading?: string) => boolean;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
getKnownWordsEnabled?: () => boolean;
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
@@ -129,7 +129,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
|
|||||||
function getKnownWordLookup(
|
function getKnownWordLookup(
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
options: TokenizerAnnotationOptions,
|
options: TokenizerAnnotationOptions,
|
||||||
): (text: string) => boolean {
|
): (text: string, reading?: string) => boolean {
|
||||||
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||||
return () => false;
|
return () => false;
|
||||||
}
|
}
|
||||||
@@ -723,6 +723,7 @@ async function parseWithYomitanInternalParser(
|
|||||||
surface: token.surface,
|
surface: token.surface,
|
||||||
reading: token.reading,
|
reading: token.reading,
|
||||||
headword: token.headword,
|
headword: token.headword,
|
||||||
|
headwordReading: token.headwordReading,
|
||||||
startPos: token.startPos,
|
startPos: token.startPos,
|
||||||
endPos: token.endPos,
|
endPos: token.endPos,
|
||||||
partOfSpeech: posMetadata.partOfSpeech,
|
partOfSpeech: posMetadata.partOfSpeech,
|
||||||
|
|||||||
@@ -56,6 +56,70 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
|||||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotateTokens passes dictionary-form reading so spelling collisions stay unknown', () => {
|
||||||
|
// とこ (colloquial ところ) resolves to headword 床/とこ; a known 床/ゆか card
|
||||||
|
// must not mark it known (#138 regression).
|
||||||
|
const cache = new Map([['床', 'ゆか']]);
|
||||||
|
const isKnownWord = (text: string, reading?: string): boolean => {
|
||||||
|
if (!cache.has(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return reading === undefined || cache.get(text) === reading;
|
||||||
|
};
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: 'とこ',
|
||||||
|
headword: '床',
|
||||||
|
reading: 'とこ',
|
||||||
|
headwordReading: 'とこ',
|
||||||
|
endPos: 2,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotateTokens keeps inflected known words matched via headword reading', () => {
|
||||||
|
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||||
|
text === '行く' && (reading === undefined || reading === 'いく');
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: '行きたい',
|
||||||
|
headword: '行く',
|
||||||
|
reading: 'いきたい',
|
||||||
|
headwordReading: 'いく',
|
||||||
|
partOfSpeech: PartOfSpeech.verb,
|
||||||
|
endPos: 4,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotateTokens omits reading for headword match when token lacks headword reading and is inflected', () => {
|
||||||
|
// MeCab tokens have no dictionary-form reading; the surface reading of an
|
||||||
|
// inflected form must not be compared against the note's dictionary reading.
|
||||||
|
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||||
|
text === '食べる' && reading === undefined;
|
||||||
|
const tokens = [
|
||||||
|
makeToken({
|
||||||
|
surface: '食べた',
|
||||||
|
headword: '食べる',
|
||||||
|
reading: 'タベタ',
|
||||||
|
partOfSpeech: PartOfSpeech.verb,
|
||||||
|
endPos: 3,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||||
|
|
||||||
|
assert.equal(result[0]?.isKnown, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('annotateTokens marks known words when N+1 is disabled', () => {
|
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||||
const tokens = [
|
const tokens = [
|
||||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const jlptLevelLookupCaches = new WeakMap<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
export interface AnnotationStageDeps {
|
export interface AnnotationStageDeps {
|
||||||
isKnownWord: (text: string) => boolean;
|
isKnownWord: (text: string, reading?: string) => boolean;
|
||||||
knownWordMatchMode: NPlusOneMatchMode;
|
knownWordMatchMode: NPlusOneMatchMode;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
}
|
}
|
||||||
@@ -661,26 +661,57 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the token's trimmed reading only when it plausibly covers the surface
|
||||||
|
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
|
||||||
|
// known-word reading disambiguation and the reading fallback stay in sync if the
|
||||||
|
// validity rule changes.
|
||||||
|
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
|
||||||
|
const normalizedReading = token.reading.trim();
|
||||||
|
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return normalizedReading;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading to disambiguate the known-word text match, or undefined when the
|
||||||
|
// token has no reading that describes the match text: in headword mode an
|
||||||
|
// inflected surface's reading does not match the dictionary form's reading,
|
||||||
|
// and partial furigana readings (see isCompleteReadingForSurface) would cause
|
||||||
|
// false negatives. Undefined falls back to text-only matching (fail-open).
|
||||||
|
function resolveKnownWordReadingForMatch(
|
||||||
|
token: MergedToken,
|
||||||
|
knownWordMatchMode: NPlusOneMatchMode,
|
||||||
|
): string | undefined {
|
||||||
|
if (knownWordMatchMode === 'headword') {
|
||||||
|
const headwordReading = token.headwordReading?.trim();
|
||||||
|
if (headwordReading) {
|
||||||
|
return headwordReading;
|
||||||
|
}
|
||||||
|
if (token.surface !== token.headword) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveCompleteTokenReading(token);
|
||||||
|
}
|
||||||
|
|
||||||
function computeTokenKnownStatus(
|
function computeTokenKnownStatus(
|
||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
isKnownWord: (text: string) => boolean,
|
isKnownWord: (text: string, reading?: string) => boolean,
|
||||||
knownWordMatchMode: NPlusOneMatchMode,
|
knownWordMatchMode: NPlusOneMatchMode,
|
||||||
): boolean {
|
): boolean {
|
||||||
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
||||||
if (token.isKnown || (matchText ? isKnownWord(matchText) : false)) {
|
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
|
||||||
|
if (token.isKnown || (matchText ? isKnownWord(matchText, matchReading) : false)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedReading = token.reading.trim();
|
const fallbackReading = resolveCompleteTokenReading(token);
|
||||||
if (!normalizedReading) {
|
if (!fallbackReading) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTokenFrequencyRank(
|
function filterTokenFrequencyRank(
|
||||||
|
|||||||
@@ -966,6 +966,7 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
|||||||
surface: '潜み',
|
surface: '潜み',
|
||||||
reading: 'ひそみ',
|
reading: 'ひそみ',
|
||||||
headword: '潜む',
|
headword: '潜む',
|
||||||
|
headwordReading: 'ひそむ',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 2,
|
endPos: 2,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
@@ -1032,6 +1033,7 @@ test('requestYomitanScanTokens emits complete readings for kanji-kana compounds'
|
|||||||
surface: '待ち合わせてる',
|
surface: '待ち合わせてる',
|
||||||
reading: 'まちあわせてる',
|
reading: 'まちあわせてる',
|
||||||
headword: '待ち合わせる',
|
headword: '待ち合わせる',
|
||||||
|
headwordReading: 'まちあわせる',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 7,
|
endPos: 7,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
@@ -1139,6 +1141,7 @@ test('requestYomitanScanTokens uses frequency from later exact-match entry when
|
|||||||
surface: '者',
|
surface: '者',
|
||||||
reading: 'もの',
|
reading: 'もの',
|
||||||
headword: '者',
|
headword: '者',
|
||||||
|
headwordReading: 'もの',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 1,
|
endPos: 1,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
@@ -1240,6 +1243,7 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
|
|||||||
surface: '者',
|
surface: '者',
|
||||||
reading: 'もの',
|
reading: 'もの',
|
||||||
headword: '者',
|
headword: '者',
|
||||||
|
headwordReading: 'もの',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 1,
|
endPos: 1,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
@@ -1340,6 +1344,7 @@ test('requestYomitanScanTokens uses exact frequency entry when selected reading
|
|||||||
surface: '第二',
|
surface: '第二',
|
||||||
reading: 'だいに',
|
reading: 'だいに',
|
||||||
headword: '第二',
|
headword: '第二',
|
||||||
|
headwordReading: 'だいに',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
endPos: 2,
|
endPos: 2,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface YomitanScanToken {
|
|||||||
surface: string;
|
surface: string;
|
||||||
reading: string;
|
reading: string;
|
||||||
headword: string;
|
headword: string;
|
||||||
|
headwordReading?: string;
|
||||||
startPos: number;
|
startPos: number;
|
||||||
endPos: number;
|
endPos: number;
|
||||||
isNameMatch?: boolean;
|
isNameMatch?: boolean;
|
||||||
@@ -92,6 +93,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
|||||||
typeof entry.surface === 'string' &&
|
typeof entry.surface === 'string' &&
|
||||||
typeof entry.reading === 'string' &&
|
typeof entry.reading === 'string' &&
|
||||||
typeof entry.headword === 'string' &&
|
typeof entry.headword === 'string' &&
|
||||||
|
(entry.headwordReading === undefined || typeof entry.headwordReading === 'string') &&
|
||||||
typeof entry.startPos === 'number' &&
|
typeof entry.startPos === 'number' &&
|
||||||
typeof entry.endPos === 'number' &&
|
typeof entry.endPos === 'number' &&
|
||||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||||
@@ -1318,6 +1320,7 @@ ${YOMITAN_SCANNING_HELPERS}
|
|||||||
surface: segments.map((segment) => segment.text).join("") || source,
|
surface: segments.map((segment) => segment.text).join("") || source,
|
||||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||||
headword: preferredHeadword.term,
|
headword: preferredHeadword.term,
|
||||||
|
headwordReading: reading || undefined,
|
||||||
startPos: i,
|
startPos: i,
|
||||||
endPos: i + originalTextLength,
|
endPos: i + originalTextLength,
|
||||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
|||||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||||
|
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||||
import {
|
import {
|
||||||
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
|
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
|
||||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||||
@@ -2324,6 +2325,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
|||||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||||
})(),
|
})(),
|
||||||
@@ -4066,6 +4070,14 @@ const statsStartupRuntime = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
|
||||||
|
isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer,
|
||||||
|
isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false,
|
||||||
|
ensureBackgroundStatsServerStarted: () =>
|
||||||
|
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||||
|
logInfo: (message) => logger.info(message),
|
||||||
|
logWarn: (message, error) => logger.warn(message, error),
|
||||||
|
});
|
||||||
|
|
||||||
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||||
getResolvedConfig: () => getResolvedConfig(),
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
@@ -4635,7 +4647,7 @@ const {
|
|||||||
setYomitanParserInitPromise: (promise) => {
|
setYomitanParserInitPromise: (promise) => {
|
||||||
appState.yomitanParserInitPromise = promise;
|
appState.yomitanParserInitPromise = promise;
|
||||||
},
|
},
|
||||||
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
|
isKnownWord: (text, reading) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading)),
|
||||||
recordLookup: (hit) => {
|
recordLookup: (hit) => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordLookup(hit);
|
appState.immersionTracker?.recordLookup(hit);
|
||||||
@@ -5962,6 +5974,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||||
|
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||||
|
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
|
|||||||
app: {
|
app: {
|
||||||
stop: context.stopApp,
|
stop: context.stopApp,
|
||||||
hasMainWindow: context.hasMainWindow,
|
hasMainWindow: context.hasMainWindow,
|
||||||
|
ensureBackgroundStatsServer: context.ensureBackgroundStatsServer,
|
||||||
runUpdateCommand: context.runUpdateCommand,
|
runUpdateCommand: context.runUpdateCommand,
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
|
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
app: {
|
app: {
|
||||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||||
|
ensureBackgroundStatsServer?: CliCommandDepsRuntimeOptions['app']['ensureBackgroundStatsServer'];
|
||||||
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||||
@@ -402,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
app: {
|
app: {
|
||||||
stop: params.app.stop,
|
stop: params.app.stop,
|
||||||
hasMainWindow: params.app.hasMainWindow,
|
hasMainWindow: params.app.hasMainWindow,
|
||||||
|
ensureBackgroundStatsServer: params.app.ensureBackgroundStatsServer,
|
||||||
runUpdateCommand: params.app.runUpdateCommand,
|
runUpdateCommand: params.app.runUpdateCommand,
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||||
|
|||||||
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
|
|||||||
|
|
||||||
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
timers.push(cb);
|
||||||
|
return { unref() {} };
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
|
||||||
|
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {
|
||||||
|
// The compositor never maps the window, so every reconcile reports pending.
|
||||||
|
const status = { applicable: true, clientFound: false, dispatched: false };
|
||||||
|
statuses.push(status);
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
|
||||||
|
let iterations = 0;
|
||||||
|
while (timers.length > 0 && iterations < 50) {
|
||||||
|
const next = timers.shift();
|
||||||
|
next?.();
|
||||||
|
iterations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconcile ladder re-asserts placement across all six delays while the client
|
||||||
|
// stays unmapped, instead of the old single post-show attempt.
|
||||||
|
assert.ok(
|
||||||
|
statuses.length >= 6,
|
||||||
|
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile stops retrying once the client is mapped', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
timers.push(cb);
|
||||||
|
return { unref() {} };
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
|
||||||
|
let reconcileCount = 0;
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {
|
||||||
|
reconcileCount += 1;
|
||||||
|
// Client is already mapped, so placement is settled on the first attempt.
|
||||||
|
return { applicable: true, clientFound: true, dispatched: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
|
||||||
|
let iterations = 0;
|
||||||
|
while (timers.length > 0 && iterations < 50) {
|
||||||
|
const next = timers.shift();
|
||||||
|
next?.();
|
||||||
|
iterations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No 6-deep ladder: a settled placement should not keep rescheduling.
|
||||||
|
assert.ok(
|
||||||
|
reconcileCount < 6,
|
||||||
|
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
type TimerEntry = { active: boolean; callback: () => void };
|
||||||
|
const timers: TimerEntry[] = [];
|
||||||
|
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
|
||||||
|
const runNextActiveTimer = () => {
|
||||||
|
const timer = timers.find((candidate) => candidate.active);
|
||||||
|
if (!timer) return;
|
||||||
|
timer.active = false;
|
||||||
|
timer.callback();
|
||||||
|
};
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
const originalClearTimeout = globalThis.clearTimeout;
|
||||||
|
globalThis.setTimeout = ((cb: () => void) => {
|
||||||
|
const timer = { active: true, callback: cb, unref() {} };
|
||||||
|
timers.push(timer);
|
||||||
|
return timer;
|
||||||
|
}) as unknown as typeof globalThis.setTimeout;
|
||||||
|
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
|
||||||
|
if (timeout) {
|
||||||
|
timeout.active = false;
|
||||||
|
}
|
||||||
|
}) as unknown as typeof globalThis.clearTimeout;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
runtime.notifyOverlayModalOpened('kiku');
|
||||||
|
assert.equal(activeTimerCount(), 1);
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow(
|
||||||
|
'kiku:field-grouping-open',
|
||||||
|
{ test: true },
|
||||||
|
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||||
|
);
|
||||||
|
assert.equal(activeTimerCount(), 2);
|
||||||
|
|
||||||
|
runNextActiveTimer();
|
||||||
|
|
||||||
|
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
|
||||||
|
} finally {
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
globalThis.clearTimeout = originalClearTimeout;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
|
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||||
|
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
|
||||||
|
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
|
||||||
|
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
|
||||||
|
|
||||||
function requestOverlayApplicationFocus(): void {
|
function requestOverlayApplicationFocus(): void {
|
||||||
try {
|
try {
|
||||||
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
|
|||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
createModalWindow: () => BrowserWindow | null;
|
createModalWindow: () => BrowserWindow | null;
|
||||||
getModalGeometry: () => WindowGeometry;
|
getModalGeometry: () => WindowGeometry;
|
||||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayModalRuntime {
|
export interface OverlayModalRuntime {
|
||||||
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
let modalWindowPrimedForImmediateShow = false;
|
let modalWindowPrimedForImmediateShow = false;
|
||||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||||
|
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||||
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
|
|||||||
window.moveTop();
|
window.moveTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
|
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
|
||||||
const modalWindow = deps.getModalWindow();
|
const modalWindow = deps.getModalWindow();
|
||||||
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
return deps.setModalWindowBounds(deps.getModalGeometry());
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
|
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
|
||||||
|
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
|
||||||
|
modalWindowBoundsReconcileGenerations.set(window, generation);
|
||||||
|
return generation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentModalWindowBoundsReconcileGeneration = (
|
||||||
|
window: BrowserWindow,
|
||||||
|
generation: number,
|
||||||
|
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
|
||||||
|
|
||||||
|
const scheduleModalWindowBoundsReconcile = (
|
||||||
|
window: BrowserWindow,
|
||||||
|
generation: number,
|
||||||
|
attempt = 0,
|
||||||
|
): void => {
|
||||||
|
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (window.isDestroyed() || !window.isVisible()) {
|
if (window.isDestroyed() || !window.isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcileModalWindowBounds(window);
|
const status = reconcileModalWindowBounds(window);
|
||||||
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
|
// Keep retrying only while a Hyprland placement is applicable but the compositor has
|
||||||
|
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
|
||||||
|
if (status && status.applicable && !status.clientFound) {
|
||||||
|
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
|
||||||
|
}
|
||||||
|
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
|
||||||
timeout.unref?.();
|
timeout.unref?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (!window.webContents.isFocused()) {
|
if (!window.webContents.isFocused()) {
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
}
|
}
|
||||||
|
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||||
reconcileModalWindowBounds(window);
|
reconcileModalWindowBounds(window);
|
||||||
scheduleModalWindowBoundsReconcile(window);
|
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||||
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (window.isVisible()) {
|
if (window.isVisible()) {
|
||||||
window.focus();
|
window.focus();
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
|
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||||
reconcileModalWindowBounds(window);
|
reconcileModalWindowBounds(window);
|
||||||
scheduleModalWindowBoundsReconcile(window);
|
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createEnsureBackgroundStatsServerHandler } from './background-stats-startup';
|
||||||
|
|
||||||
|
function createDeps(
|
||||||
|
overrides: Partial<Parameters<typeof createEnsureBackgroundStatsServerHandler>[0]> = {},
|
||||||
|
) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const deps: Parameters<typeof createEnsureBackgroundStatsServerHandler>[0] = {
|
||||||
|
isStatsAutoStartEnabled: () => true,
|
||||||
|
isImmersionTrackingEnabled: () => true,
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
calls.push('ensureBackgroundStatsServerStarted');
|
||||||
|
return { url: 'http://127.0.0.1:3888', runningInCurrentProcess: true };
|
||||||
|
},
|
||||||
|
logInfo: (message) => {
|
||||||
|
calls.push(`info:${message}`);
|
||||||
|
},
|
||||||
|
logWarn: (message) => {
|
||||||
|
calls.push(`warn:${message}`);
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { deps, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ensures background stats server and logs local startup', () => {
|
||||||
|
const { deps, calls } = createDeps();
|
||||||
|
|
||||||
|
createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
|
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
|
||||||
|
assert.ok(
|
||||||
|
calls.some((value) => value.startsWith('info:') && value.includes('http://127.0.0.1:3888')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs reuse when a background stats server is already running', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
ensureBackgroundStatsServerStarted: () => ({
|
||||||
|
url: 'http://127.0.0.1:3888',
|
||||||
|
runningInCurrentProcess: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips when stats.autoStartServer is disabled', () => {
|
||||||
|
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
|
||||||
|
|
||||||
|
createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
|
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips when immersion tracking is disabled', () => {
|
||||||
|
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
|
||||||
|
|
||||||
|
createEnsureBackgroundStatsServerHandler(deps)();
|
||||||
|
|
||||||
|
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs a warning instead of throwing when startup fails', () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
throw new Error('port in use');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
|
||||||
|
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface EnsureBackgroundStatsServerDeps {
|
||||||
|
isStatsAutoStartEnabled: () => boolean;
|
||||||
|
isImmersionTrackingEnabled: () => boolean;
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
};
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, error?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnsureBackgroundStatsServerHandler(
|
||||||
|
deps: EnsureBackgroundStatsServerDeps,
|
||||||
|
): () => void {
|
||||||
|
return () => {
|
||||||
|
if (!deps.isStatsAutoStartEnabled()) {
|
||||||
|
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deps.isImmersionTrackingEnabled()) {
|
||||||
|
deps.logInfo('Background start: immersion tracking is disabled; skipping stats server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = deps.ensureBackgroundStatsServerStarted();
|
||||||
|
deps.logInfo(
|
||||||
|
result.runningInCurrentProcess
|
||||||
|
? `Background start: stats server started at ${result.url}.`
|
||||||
|
: `Background start: stats server already running at ${result.url}; skipping.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('Background start: failed to start stats server.', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
|
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runUpdateCommand: deps.runUpdateCommand,
|
runUpdateCommand: deps.runUpdateCommand,
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
source: CliCommandSource,
|
source: CliCommandSource,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
|
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||||
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -140,6 +141,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
|
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
|
||||||
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
|
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
|
||||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||||
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||||
|
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
@@ -130,6 +131,7 @@ export function createCliCommandContext(
|
|||||||
runUpdateCommand: deps.runUpdateCommand,
|
runUpdateCommand: deps.runUpdateCommand,
|
||||||
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
|
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
|
|||||||
@@ -36,3 +36,50 @@ test('field grouping overlay main deps builder maps window visibility and resolv
|
|||||||
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
|
assert.equal(deps.sendToVisibleOverlay('kiku:open', 1), true);
|
||||||
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
|
assert.deepEqual(calls, ['visible:true', 'set-resolver:null', 'send:kiku:open:1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('field grouping overlay main deps builder forwards modal open/teardown/prereq wiring', () => {
|
||||||
|
// Regression: these are optional on the runtime options, so a missing forward compiled
|
||||||
|
// silently and left the field grouping modal with no ack/retry, no teardown, and no prereqs —
|
||||||
|
// the reason the earlier recovery fixes never took effect.
|
||||||
|
const calls: string[] = [];
|
||||||
|
const waitForModalOpen = async (modal: 'kiku', timeoutMs: number): Promise<boolean> => {
|
||||||
|
calls.push(`wait:${modal}:${timeoutMs}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const handleOverlayModalClosed = (modal: 'kiku'): void => {
|
||||||
|
calls.push(`closed:${modal}`);
|
||||||
|
};
|
||||||
|
const logWarn = (message: string): void => {
|
||||||
|
calls.push(`warn:${message}`);
|
||||||
|
};
|
||||||
|
const ensureOverlayStartupPrereqs = (): void => {
|
||||||
|
calls.push('prereqs');
|
||||||
|
};
|
||||||
|
const ensureOverlayWindowsReadyForVisibilityActions = (): void => {
|
||||||
|
calls.push('windows-ready');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createBuildFieldGroupingOverlayMainDepsHandler<'kiku'>({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
setVisibleOverlayVisible: () => {},
|
||||||
|
getResolver: () => null,
|
||||||
|
setResolver: () => {},
|
||||||
|
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||||
|
waitForModalOpen,
|
||||||
|
handleOverlayModalClosed,
|
||||||
|
logWarn,
|
||||||
|
ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
sendToActiveOverlayWindow: () => true,
|
||||||
|
})();
|
||||||
|
|
||||||
|
assert.equal(deps.waitForModalOpen, waitForModalOpen);
|
||||||
|
assert.equal(deps.handleOverlayModalClosed, handleOverlayModalClosed);
|
||||||
|
assert.equal(deps.logWarn, logWarn);
|
||||||
|
assert.equal(deps.ensureOverlayStartupPrereqs, ensureOverlayStartupPrereqs);
|
||||||
|
assert.equal(
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
|
|||||||
getResolver: () => deps.getResolver(),
|
getResolver: () => deps.getResolver(),
|
||||||
setResolver: (resolver) => deps.setResolver(resolver),
|
setResolver: (resolver) => deps.setResolver(resolver),
|
||||||
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
|
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
|
||||||
|
// These are optional on the runtime options, so a missing forward compiles silently — but
|
||||||
|
// dropping them left the field grouping modal with no modal-open ack/retry, no teardown on
|
||||||
|
// failure, and no warn logging, which is why the earlier recovery fixes never took effect.
|
||||||
|
waitForModalOpen: deps.waitForModalOpen,
|
||||||
|
handleOverlayModalClosed: deps.handleOverlayModalClosed,
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||||
|
ensureOverlayWindowsReadyForVisibilityActions:
|
||||||
|
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||||
sendToVisibleOverlay: (
|
sendToVisibleOverlay: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ test('overlay modal runtime main deps builder maps window resolvers', () => {
|
|||||||
getModalWindow: () => modalWindow as never,
|
getModalWindow: () => modalWindow as never,
|
||||||
createModalWindow: () => modalWindow as never,
|
createModalWindow: () => modalWindow as never,
|
||||||
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
||||||
setModalWindowBounds: (geometry) =>
|
setModalWindowBounds: (geometry) => {
|
||||||
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`),
|
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
|
||||||
|
},
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getMainWindow(), mainWindow);
|
assert.equal(deps.getMainWindow(), mainWindow);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
|
||||||
deps.setYomitanParserInitPromise(promise),
|
deps.setYomitanParserInitPromise(promise),
|
||||||
isKnownWord: (text: string) => {
|
isKnownWord: (text: string, reading?: string) => {
|
||||||
const hit = deps.isKnownWord(text);
|
const hit = deps.isKnownWord(text, reading);
|
||||||
deps.recordLookup(hit);
|
deps.recordLookup(hit);
|
||||||
return hit;
|
return hit;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ const onKikuFieldGroupingRequestEvent =
|
|||||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||||
(payload) => payload as KikuFieldGroupingRequestData,
|
(payload) => payload as KikuFieldGroupingRequestData,
|
||||||
);
|
);
|
||||||
|
const onKikuFieldGroupingCancelEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.kikuFieldGroupingCancel,
|
||||||
|
);
|
||||||
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
||||||
IPC_CHANNELS.event.subtitleSet,
|
IPC_CHANNELS.event.subtitleSet,
|
||||||
(payload) => payload as SubtitleData,
|
(payload) => payload as SubtitleData,
|
||||||
@@ -396,6 +399,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||||
|
|
||||||
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
|
onKikuFieldGroupingRequest: onKikuFieldGroupingRequestEvent,
|
||||||
|
onKikuFieldGroupingCancel: onKikuFieldGroupingCancelEvent,
|
||||||
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,13 @@ function registerModalOpenHandlers(): void {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
window.electronAPI.onKikuFieldGroupingCancel(() => {
|
||||||
|
runGuarded('kiku:field-grouping-cancel', () => {
|
||||||
|
// Main already settled the choice (timeout/failure); just close the dialog. Using the
|
||||||
|
// plain close path avoids sending a second, redundant response back to main.
|
||||||
|
kikuModal.closeKikuFieldGroupingModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerKeyboardCommandHandlers(): void {
|
function registerKeyboardCommandHandlers(): void {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export const IPC_CHANNELS = {
|
|||||||
secondarySubtitleMode: 'secondary-subtitle:mode',
|
secondarySubtitleMode: 'secondary-subtitle:mode',
|
||||||
subsyncOpenManual: 'subsync:open-manual',
|
subsyncOpenManual: 'subsync:open-manual',
|
||||||
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
|
kikuFieldGroupingRequest: 'kiku:field-grouping-request',
|
||||||
|
kikuFieldGroupingCancel: 'kiku:field-grouping-cancel',
|
||||||
runtimeOptionsChanged: 'runtime-options:changed',
|
runtimeOptionsChanged: 'runtime-options:changed',
|
||||||
runtimeOptionsOpen: 'runtime-options:open',
|
runtimeOptionsOpen: 'runtime-options:open',
|
||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ export interface ElectronAPI {
|
|||||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||||
|
onKikuFieldGroupingCancel: (callback: () => void) => void;
|
||||||
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
|
kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise<KikuMergePreviewResponse>;
|
||||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
|
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
|
||||||
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
|
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface MergedToken {
|
|||||||
surface: string;
|
surface: string;
|
||||||
reading: string;
|
reading: string;
|
||||||
headword: string;
|
headword: string;
|
||||||
|
/** Dictionary-form reading of headword (kana), when the parser provides it. */
|
||||||
|
headwordReading?: string;
|
||||||
startPos: number;
|
startPos: number;
|
||||||
endPos: number;
|
endPos: number;
|
||||||
partOfSpeech: PartOfSpeech;
|
partOfSpeech: PartOfSpeech;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 6.5 KiB |
@@ -3,8 +3,10 @@ import type { TimeRange, GroupBy } from '../../hooks/useTrends';
|
|||||||
interface DateRangeSelectorProps {
|
interface DateRangeSelectorProps {
|
||||||
range: TimeRange;
|
range: TimeRange;
|
||||||
groupBy: GroupBy;
|
groupBy: GroupBy;
|
||||||
|
fillEmpty: boolean;
|
||||||
onRangeChange: (r: TimeRange) => void;
|
onRangeChange: (r: TimeRange) => void;
|
||||||
onGroupByChange: (g: GroupBy) => void;
|
onGroupByChange: (g: GroupBy) => void;
|
||||||
|
onFillEmptyChange: (fillEmpty: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({
|
function SegmentedControl<T extends string>({
|
||||||
@@ -46,11 +48,13 @@ function SegmentedControl<T extends string>({
|
|||||||
export function DateRangeSelector({
|
export function DateRangeSelector({
|
||||||
range,
|
range,
|
||||||
groupBy,
|
groupBy,
|
||||||
|
fillEmpty,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
onGroupByChange,
|
onGroupByChange,
|
||||||
|
onFillEmptyChange,
|
||||||
}: DateRangeSelectorProps) {
|
}: DateRangeSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="Range"
|
label="Range"
|
||||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||||
@@ -65,6 +69,13 @@ export function DateRangeSelector({
|
|||||||
onChange={onGroupByChange}
|
onChange={onGroupByChange}
|
||||||
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
|
formatLabel={(g) => g.charAt(0).toUpperCase() + g.slice(1)}
|
||||||
/>
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Empty days"
|
||||||
|
options={['show', 'hide']}
|
||||||
|
value={fillEmpty ? 'show' : 'hide'}
|
||||||
|
onChange={(v) => onFillEmptyChange(v === 'show')}
|
||||||
|
formatLabel={(v) => (v === 'show' ? 'Show' : 'Hide')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildLineData,
|
||||||
|
sortTooltipEntries,
|
||||||
|
tooltipColumnCount,
|
||||||
|
type PerAnimeDataPoint,
|
||||||
|
} from './StackedTrendChart';
|
||||||
|
|
||||||
|
function makePoints(titleCount: number): PerAnimeDataPoint[] {
|
||||||
|
const points: PerAnimeDataPoint[] = [];
|
||||||
|
for (let i = 0; i < titleCount; i += 1) {
|
||||||
|
points.push({ epochDay: 20_000 + i, animeTitle: `Title ${i}`, value: titleCount - i });
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sortTooltipEntries orders rows by value descending, tolerating string values', () => {
|
||||||
|
const sorted = sortTooltipEntries([
|
||||||
|
{ name: 'A', value: 5 },
|
||||||
|
{ name: 'B', value: 20 },
|
||||||
|
{ name: 'C', value: '12.5' },
|
||||||
|
{ name: 'D', value: undefined },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
sorted.map((entry) => entry.name),
|
||||||
|
['B', 'C', 'A', 'D'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tooltipColumnCount wraps into extra columns as items grow, capped at 3', () => {
|
||||||
|
assert.equal(tooltipColumnCount(1), 1);
|
||||||
|
assert.equal(tooltipColumnCount(8), 1);
|
||||||
|
assert.equal(tooltipColumnCount(9), 2);
|
||||||
|
assert.equal(tooltipColumnCount(16), 2);
|
||||||
|
assert.equal(tooltipColumnCount(17), 3);
|
||||||
|
assert.equal(tooltipColumnCount(40), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData keeps every title as a series instead of capping at the top 7', () => {
|
||||||
|
const { points, seriesKeys } = buildLineData(makePoints(17));
|
||||||
|
|
||||||
|
assert.equal(seriesKeys.length, 17);
|
||||||
|
assert.ok(seriesKeys.includes('Title 16'));
|
||||||
|
for (const row of points) {
|
||||||
|
for (const key of seriesKeys) {
|
||||||
|
assert.ok(key in row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData caps series at maxSeries when set, keeping the top titles', () => {
|
||||||
|
const { points, seriesKeys } = buildLineData(makePoints(17), 7);
|
||||||
|
|
||||||
|
assert.equal(seriesKeys.length, 7);
|
||||||
|
assert.deepEqual(
|
||||||
|
seriesKeys,
|
||||||
|
Array.from({ length: 7 }, (_, i) => `Title ${i}`),
|
||||||
|
);
|
||||||
|
for (const row of points) {
|
||||||
|
assert.ok(!('Title 16' in row));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData orders series by total value descending', () => {
|
||||||
|
const { seriesKeys } = buildLineData([
|
||||||
|
{ epochDay: 20_000, animeTitle: 'Small', value: 1 },
|
||||||
|
{ epochDay: 20_000, animeTitle: 'Big', value: 10 },
|
||||||
|
{ epochDay: 20_001, animeTitle: 'Medium', value: 5 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(seriesKeys, ['Big', 'Medium', 'Small']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData total mode ranks by final cumulative value, not the sum of snapshots', () => {
|
||||||
|
// "Early" plateaus at 10 across four days (snapshot sum 40); "Late" reaches a
|
||||||
|
// larger final value of 12 in a single day. Ranking by final value keeps Late
|
||||||
|
// first; the old snapshot-sum ranking would have put Early first.
|
||||||
|
const raw: PerAnimeDataPoint[] = [
|
||||||
|
{ epochDay: 20_000, animeTitle: 'Early', value: 10 },
|
||||||
|
{ epochDay: 20_001, animeTitle: 'Early', value: 10 },
|
||||||
|
{ epochDay: 20_002, animeTitle: 'Early', value: 10 },
|
||||||
|
{ epochDay: 20_003, animeTitle: 'Early', value: 10 },
|
||||||
|
{ epochDay: 20_003, animeTitle: 'Late', value: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { seriesKeys } = buildLineData(raw, undefined, 'total');
|
||||||
|
assert.deepEqual(seriesKeys, ['Late', 'Early']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData recent mode keeps the most recently active titles over the largest', () => {
|
||||||
|
// "Old Giant" has a huge cumulative total but stopped growing early; "Fresh"
|
||||||
|
// is small but its cumulative value increased on the latest day.
|
||||||
|
const raw: PerAnimeDataPoint[] = [
|
||||||
|
{ epochDay: 20_000, animeTitle: 'Old Giant', value: 500 },
|
||||||
|
{ epochDay: 20_001, animeTitle: 'Old Giant', value: 500 },
|
||||||
|
{ epochDay: 20_002, animeTitle: 'Old Giant', value: 500 },
|
||||||
|
{ epochDay: 20_000, animeTitle: 'Fresh', value: 1 },
|
||||||
|
{ epochDay: 20_001, animeTitle: 'Fresh', value: 2 },
|
||||||
|
{ epochDay: 20_002, animeTitle: 'Fresh', value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const total = buildLineData(raw, 1, 'total');
|
||||||
|
assert.deepEqual(total.seriesKeys, ['Old Giant']);
|
||||||
|
|
||||||
|
const recent = buildLineData(raw, 1, 'recent');
|
||||||
|
assert.deepEqual(recent.seriesKeys, ['Fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLineData recent mode breaks ties by total then name', () => {
|
||||||
|
// Both titles last increased on day 20_002; the larger total wins the tie.
|
||||||
|
const raw: PerAnimeDataPoint[] = [
|
||||||
|
{ epochDay: 20_001, animeTitle: 'A', value: 2 },
|
||||||
|
{ epochDay: 20_002, animeTitle: 'A', value: 4 },
|
||||||
|
{ epochDay: 20_001, animeTitle: 'B', value: 3 },
|
||||||
|
{ epochDay: 20_002, animeTitle: 'B', value: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { seriesKeys } = buildLineData(raw, 1, 'recent');
|
||||||
|
assert.deepEqual(seriesKeys, ['B']);
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@ interface StackedTrendChartProps {
|
|||||||
title: string;
|
title: string;
|
||||||
data: PerAnimeDataPoint[];
|
data: PerAnimeDataPoint[];
|
||||||
colorPalette?: string[];
|
colorPalette?: string[];
|
||||||
|
maxSeries?: number | null;
|
||||||
|
maxSeriesMode?: SeriesRankMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LINE_COLORS = [
|
const DEFAULT_LINE_COLORS = [
|
||||||
@@ -33,19 +35,147 @@ const DEFAULT_LINE_COLORS = [
|
|||||||
'#f4dbd6',
|
'#f4dbd6',
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildLineData(raw: PerAnimeDataPoint[]) {
|
// Wrap the tooltip into extra columns once a single column would get too tall,
|
||||||
const totalByAnime = new Map<string, number>();
|
// keeping it compact instead of overflowing behind the charts below it.
|
||||||
|
const TOOLTIP_ROWS_PER_COLUMN = 8;
|
||||||
|
const TOOLTIP_MAX_COLUMNS = 3;
|
||||||
|
|
||||||
|
export function tooltipColumnCount(itemCount: number): number {
|
||||||
|
const columns = Math.ceil(itemCount / TOOLTIP_ROWS_PER_COLUMN);
|
||||||
|
return Math.min(TOOLTIP_MAX_COLUMNS, Math.max(1, columns));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipEntry {
|
||||||
|
name?: string | number;
|
||||||
|
value?: string | number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackedTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
label?: string | number;
|
||||||
|
payload?: TooltipEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipEntryValue(entry: TooltipEntry): number {
|
||||||
|
const numeric = typeof entry.value === 'number' ? entry.value : Number(entry.value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip rows are sorted by value (largest first) so the biggest contributors
|
||||||
|
// read top-down, independent of the series/stacking order.
|
||||||
|
export function sortTooltipEntries<T extends TooltipEntry>(entries: readonly T[]): T[] {
|
||||||
|
return [...entries].sort((a, b) => tooltipEntryValue(b) - tooltipEntryValue(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedTooltip({ active, label, payload }: StackedTooltipProps) {
|
||||||
|
if (!active || !payload || payload.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sorted = sortTooltipEntries(payload);
|
||||||
|
const columns = tooltipColumnCount(sorted.length);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...TOOLTIP_CONTENT_STYLE,
|
||||||
|
padding: '6px 10px',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label !== undefined && (
|
||||||
|
<div style={{ color: CHART_THEME.tooltipLabel, marginBottom: 4, fontWeight: 600 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columns}, max-content)`,
|
||||||
|
columnGap: 16,
|
||||||
|
rowGap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sorted.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.name ?? index}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: entry.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
<span style={{ marginLeft: 16, color: CHART_THEME.tooltipLabel }}>{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeriesRankMode = 'total' | 'recent';
|
||||||
|
|
||||||
|
// Per-title ranking key. The data is cumulative per title, so `total` is the
|
||||||
|
// final (latest) cumulative value — not the sum of daily snapshots, which would
|
||||||
|
// over-weight titles that plateaued high early. `recentDay` is the latest epoch
|
||||||
|
// day the value increased, i.e. its last day of real activity — used to keep
|
||||||
|
// the most recently watched titles.
|
||||||
|
function rankTitles(raw: PerAnimeDataPoint[]): Map<string, { total: number; recentDay: number }> {
|
||||||
|
const pointsByTitle = new Map<string, PerAnimeDataPoint[]>();
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
const list = pointsByTitle.get(entry.animeTitle) ?? [];
|
||||||
|
list.push(entry);
|
||||||
|
pointsByTitle.set(entry.animeTitle, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
|
const stats = new Map<string, { total: number; recentDay: number }>();
|
||||||
const topTitles = sorted.slice(0, 7).map(([title]) => title);
|
for (const [title, points] of pointsByTitle) {
|
||||||
const topSet = new Set(topTitles);
|
const sorted = [...points].sort((a, b) => a.epochDay - b.epochDay);
|
||||||
|
let previous = 0;
|
||||||
|
let recentDay = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const point of sorted) {
|
||||||
|
if (point.value > previous) {
|
||||||
|
recentDay = point.epochDay;
|
||||||
|
}
|
||||||
|
previous = point.value;
|
||||||
|
}
|
||||||
|
const total = sorted.length > 0 ? sorted[sorted.length - 1]!.value : 0;
|
||||||
|
stats.set(title, { total, recentDay });
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLineData(
|
||||||
|
raw: PerAnimeDataPoint[],
|
||||||
|
maxSeries?: number | null,
|
||||||
|
mode: SeriesRankMode = 'total',
|
||||||
|
) {
|
||||||
|
const stats = rankTitles(raw);
|
||||||
|
|
||||||
|
let seriesKeys = [...stats.entries()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (mode === 'recent') {
|
||||||
|
return (
|
||||||
|
b[1].recentDay - a[1].recentDay || b[1].total - a[1].total || a[0].localeCompare(b[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return b[1].total - a[1].total || a[0].localeCompare(b[0]);
|
||||||
|
})
|
||||||
|
.map(([title]) => title);
|
||||||
|
if (typeof maxSeries === 'number' && maxSeries > 0) {
|
||||||
|
seriesKeys = seriesKeys.slice(0, maxSeries);
|
||||||
|
}
|
||||||
|
const seriesSet = new Set(seriesKeys);
|
||||||
|
|
||||||
const byDay = new Map<number, Record<string, number>>();
|
const byDay = new Map<number, Record<string, number>>();
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
if (!topSet.has(entry.animeTitle)) continue;
|
if (!seriesSet.has(entry.animeTitle)) continue;
|
||||||
const row = byDay.get(entry.epochDay) ?? {};
|
const row = byDay.get(entry.epochDay) ?? {};
|
||||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||||
byDay.set(entry.epochDay, row);
|
byDay.set(entry.epochDay, row);
|
||||||
@@ -60,17 +190,23 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
for (const title of topTitles) {
|
for (const title of seriesKeys) {
|
||||||
row[title] = values[title] ?? 0;
|
row[title] = values[title] ?? 0;
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { points, seriesKeys: topTitles };
|
return { points, seriesKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StackedTrendChart({ title, data, colorPalette }: StackedTrendChartProps) {
|
export function StackedTrendChart({
|
||||||
const { points, seriesKeys } = buildLineData(data);
|
title,
|
||||||
|
data,
|
||||||
|
colorPalette,
|
||||||
|
maxSeries,
|
||||||
|
maxSeriesMode,
|
||||||
|
}: StackedTrendChartProps) {
|
||||||
|
const { points, seriesKeys } = buildLineData(data, maxSeries, maxSeriesMode);
|
||||||
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
const colors = colorPalette ?? DEFAULT_LINE_COLORS;
|
||||||
|
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
@@ -100,7 +236,11 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
width={32}
|
width={32}
|
||||||
/>
|
/>
|
||||||
<Tooltip contentStyle={TOOLTIP_CONTENT_STYLE} />
|
<Tooltip
|
||||||
|
content={<StackedTooltip />}
|
||||||
|
wrapperStyle={{ zIndex: 50 }}
|
||||||
|
allowEscapeViewBox={{ x: false, y: true }}
|
||||||
|
/>
|
||||||
{seriesKeys.map((key, i) => (
|
{seriesKeys.map((key, i) => (
|
||||||
<Area
|
<Area
|
||||||
key={key}
|
key={key}
|
||||||
@@ -115,18 +255,18 @@ export function StackedTrendChart({ title, data, colorPalette }: StackedTrendCha
|
|||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2 overflow-hidden max-h-10">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
{seriesKeys.map((key, i) => (
|
{seriesKeys.map((key, i) => (
|
||||||
<span
|
<span
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 max-w-[140px]"
|
className="flex items-center gap-1 text-[10px] text-ctp-subtext0 whitespace-nowrap"
|
||||||
title={key}
|
title={key}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: colors[i % colors.length] }}
|
style={{ backgroundColor: colors[i % colors.length] }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{key}</span>
|
<span>{key}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
|
|||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={['KonoSuba']}
|
animeTitles={['KonoSuba']}
|
||||||
hiddenAnime={new Set()}
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={null}
|
||||||
|
maxTitlesMode="total"
|
||||||
onShowAll={() => {}}
|
onShowAll={() => {}}
|
||||||
onHideAll={() => {}}
|
onHideAll={() => {}}
|
||||||
onToggleAnime={() => {}}
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
|
onMaxTitlesModeChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -18,6 +22,64 @@ test('AnimeVisibilityFilter uses title visibility wording', () => {
|
|||||||
assert.doesNotMatch(markup, /Anime Visibility/);
|
assert.doesNotMatch(markup, /Anime Visibility/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnimeVisibilityFilter offers a per-chart title limit selector', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AnimeVisibilityFilter
|
||||||
|
animeTitles={['KonoSuba']}
|
||||||
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={7}
|
||||||
|
maxTitlesMode="total"
|
||||||
|
onShowAll={() => {}}
|
||||||
|
onHideAll={() => {}}
|
||||||
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
|
onMaxTitlesModeChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /per chart/);
|
||||||
|
assert.match(markup, /<option value="all">All<\/option>/);
|
||||||
|
assert.match(markup, /<option value="7" selected="">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnimeVisibilityFilter offers top vs most-recent ranking modes', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AnimeVisibilityFilter
|
||||||
|
animeTitles={['KonoSuba']}
|
||||||
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={7}
|
||||||
|
maxTitlesMode="recent"
|
||||||
|
onShowAll={() => {}}
|
||||||
|
onHideAll={() => {}}
|
||||||
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
|
onMaxTitlesModeChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /<option value="total">top<\/option>/);
|
||||||
|
assert.match(markup, /<option value="recent" selected="">most recent<\/option>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing all titles', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AnimeVisibilityFilter
|
||||||
|
animeTitles={['KonoSuba']}
|
||||||
|
hiddenAnime={new Set()}
|
||||||
|
maxTitles={null}
|
||||||
|
maxTitlesMode="total"
|
||||||
|
onShowAll={() => {}}
|
||||||
|
onHideAll={() => {}}
|
||||||
|
onToggleAnime={() => {}}
|
||||||
|
onMaxTitlesChange={() => {}}
|
||||||
|
onMaxTitlesModeChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, /aria-label="Title ranking mode"/);
|
||||||
|
assert.doesNotMatch(markup, /aria-label="Title ranking mode"[^>]*disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
||||||
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ import { DateRangeSelector } from './DateRangeSelector';
|
|||||||
import { TrendChart } from './TrendChart';
|
import { TrendChart } from './TrendChart';
|
||||||
import { StackedTrendChart } from './StackedTrendChart';
|
import { StackedTrendChart } from './StackedTrendChart';
|
||||||
import {
|
import {
|
||||||
|
MAX_TITLES_MODES,
|
||||||
|
MAX_TITLES_OPTIONS,
|
||||||
buildAnimeVisibilityOptions,
|
buildAnimeVisibilityOptions,
|
||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
|
loadHiddenTitles,
|
||||||
|
loadMaxTitles,
|
||||||
|
loadMaxTitlesMode,
|
||||||
|
loadShowEmptyDays,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
|
saveHiddenTitles,
|
||||||
|
saveMaxTitles,
|
||||||
|
saveMaxTitlesMode,
|
||||||
|
saveShowEmptyDays,
|
||||||
|
type MaxTitlesMode,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
import { LibrarySummarySection } from './LibrarySummarySection';
|
import { LibrarySummarySection } from './LibrarySummarySection';
|
||||||
|
|
||||||
|
const MAX_TITLES_MODE_LABELS: Record<MaxTitlesMode, string> = {
|
||||||
|
recent: 'most recent',
|
||||||
|
total: 'top',
|
||||||
|
};
|
||||||
|
|
||||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
|
<div className="col-span-full mt-6 mb-2 flex items-center gap-3">
|
||||||
@@ -24,17 +40,25 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
|
|||||||
interface AnimeVisibilityFilterProps {
|
interface AnimeVisibilityFilterProps {
|
||||||
animeTitles: string[];
|
animeTitles: string[];
|
||||||
hiddenAnime: ReadonlySet<string>;
|
hiddenAnime: ReadonlySet<string>;
|
||||||
|
maxTitles: number | null;
|
||||||
|
maxTitlesMode: MaxTitlesMode;
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onHideAll: () => void;
|
onHideAll: () => void;
|
||||||
onToggleAnime: (title: string) => void;
|
onToggleAnime: (title: string) => void;
|
||||||
|
onMaxTitlesChange: (value: number | null) => void;
|
||||||
|
onMaxTitlesModeChange: (mode: MaxTitlesMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeVisibilityFilter({
|
export function AnimeVisibilityFilter({
|
||||||
animeTitles,
|
animeTitles,
|
||||||
hiddenAnime,
|
hiddenAnime,
|
||||||
|
maxTitles,
|
||||||
|
maxTitlesMode,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onHideAll,
|
onHideAll,
|
||||||
onToggleAnime,
|
onToggleAnime,
|
||||||
|
onMaxTitlesChange,
|
||||||
|
onMaxTitlesModeChange,
|
||||||
}: AnimeVisibilityFilterProps) {
|
}: AnimeVisibilityFilterProps) {
|
||||||
if (animeTitles.length === 0) {
|
if (animeTitles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -48,10 +72,41 @@ export function AnimeVisibilityFilter({
|
|||||||
Title Visibility
|
Title Visibility
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-1 text-xs text-ctp-overlay1">
|
<p className="mt-1 text-xs text-ctp-overlay1">
|
||||||
Shared across all anime trend charts. Default: show everything.
|
Shared across all anime trend charts. Default: show everything. Selections are saved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-ctp-subtext0">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
aria-label="Title ranking mode"
|
||||||
|
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
|
||||||
|
value={maxTitlesMode}
|
||||||
|
onChange={(event) => onMaxTitlesModeChange(event.target.value as MaxTitlesMode)}
|
||||||
|
>
|
||||||
|
{MAX_TITLES_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{MAX_TITLES_MODE_LABELS[mode]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Titles per chart"
|
||||||
|
className="rounded-md border border-ctp-surface2 bg-ctp-surface0 px-1.5 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue"
|
||||||
|
value={maxTitles === null ? 'all' : String(maxTitles)}
|
||||||
|
onChange={(event) =>
|
||||||
|
onMaxTitlesChange(event.target.value === 'all' ? null : Number(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{MAX_TITLES_OPTIONS.map((count) => (
|
||||||
|
<option key={count} value={count}>
|
||||||
|
{count}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
per chart
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
className="rounded-md border border-ctp-surface2 px-2 py-1 text-[11px] font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||||
@@ -96,8 +151,28 @@ export function AnimeVisibilityFilter({
|
|||||||
export function TrendsTab() {
|
export function TrendsTab() {
|
||||||
const [range, setRange] = useState<TimeRange>('30d');
|
const [range, setRange] = useState<TimeRange>('30d');
|
||||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||||
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => new Set());
|
const [showEmptyDays, setShowEmptyDays] = useState(() => loadShowEmptyDays());
|
||||||
const { data, loading, error } = useTrends(range, groupBy);
|
const [hiddenAnime, setHiddenAnime] = useState<Set<string>>(() => loadHiddenTitles());
|
||||||
|
const [maxTitles, setMaxTitles] = useState<number | null>(() => loadMaxTitles());
|
||||||
|
const [maxTitlesMode, setMaxTitlesMode] = useState<MaxTitlesMode>(() => loadMaxTitlesMode());
|
||||||
|
const { data, loading, error } = useTrends(range, groupBy, showEmptyDays);
|
||||||
|
|
||||||
|
const updateHiddenAnime = (next: Set<string>) => {
|
||||||
|
setHiddenAnime(next);
|
||||||
|
saveHiddenTitles(next);
|
||||||
|
};
|
||||||
|
const updateMaxTitles = (value: number | null) => {
|
||||||
|
setMaxTitles(value);
|
||||||
|
saveMaxTitles(value);
|
||||||
|
};
|
||||||
|
const updateMaxTitlesMode = (mode: MaxTitlesMode) => {
|
||||||
|
setMaxTitlesMode(mode);
|
||||||
|
saveMaxTitlesMode(mode);
|
||||||
|
};
|
||||||
|
const updateShowEmptyDays = (show: boolean) => {
|
||||||
|
setShowEmptyDays(show);
|
||||||
|
saveShowEmptyDays(show);
|
||||||
|
};
|
||||||
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
const cardsMinedColor = 'var(--color-ctp-cards-mined)';
|
||||||
const cardsMinedStackedColors = [
|
const cardsMinedStackedColors = [
|
||||||
cardsMinedColor,
|
cardsMinedColor,
|
||||||
@@ -144,8 +219,10 @@ export function TrendsTab() {
|
|||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
range={range}
|
range={range}
|
||||||
groupBy={groupBy}
|
groupBy={groupBy}
|
||||||
|
fillEmpty={showEmptyDays}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
onGroupByChange={setGroupBy}
|
onGroupByChange={setGroupBy}
|
||||||
|
onFillEmptyChange={updateShowEmptyDays}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
|
<SectionHeader>Activity (per {groupBy === 'month' ? 'month' : 'day'})</SectionHeader>
|
||||||
@@ -246,28 +323,47 @@ export function TrendsTab() {
|
|||||||
<AnimeVisibilityFilter
|
<AnimeVisibilityFilter
|
||||||
animeTitles={animeTitles}
|
animeTitles={animeTitles}
|
||||||
hiddenAnime={activeHiddenAnime}
|
hiddenAnime={activeHiddenAnime}
|
||||||
onShowAll={() => setHiddenAnime(new Set())}
|
maxTitles={maxTitles}
|
||||||
onHideAll={() => setHiddenAnime(new Set(animeTitles))}
|
maxTitlesMode={maxTitlesMode}
|
||||||
onToggleAnime={(title) =>
|
onShowAll={() => updateHiddenAnime(new Set())}
|
||||||
setHiddenAnime((current) => {
|
onHideAll={() => updateHiddenAnime(new Set(animeTitles))}
|
||||||
const next = new Set(current);
|
onToggleAnime={(title) => {
|
||||||
if (next.has(title)) {
|
const next = new Set(hiddenAnime);
|
||||||
next.delete(title);
|
if (next.has(title)) {
|
||||||
} else {
|
next.delete(title);
|
||||||
next.add(title);
|
} else {
|
||||||
}
|
next.add(title);
|
||||||
return next;
|
}
|
||||||
})
|
updateHiddenAnime(next);
|
||||||
}
|
}}
|
||||||
|
onMaxTitlesChange={updateMaxTitles}
|
||||||
|
onMaxTitlesModeChange={updateMaxTitlesMode}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Watch Time Progress (min)"
|
||||||
|
data={filteredWatchTimeProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
maxSeriesMode={maxTitlesMode}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Episodes Progress"
|
||||||
|
data={filteredAnimeProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
maxSeriesMode={maxTitlesMode}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Watch Time Progress (min)" data={filteredWatchTimeProgress} />
|
|
||||||
<StackedTrendChart title="Episodes Progress" data={filteredAnimeProgress} />
|
|
||||||
<StackedTrendChart
|
<StackedTrendChart
|
||||||
title="Cards Mined Progress"
|
title="Cards Mined Progress"
|
||||||
data={filteredCardsProgress}
|
data={filteredCardsProgress}
|
||||||
colorPalette={cardsMinedStackedColors}
|
colorPalette={cardsMinedStackedColors}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
maxSeriesMode={maxTitlesMode}
|
||||||
|
/>
|
||||||
|
<StackedTrendChart
|
||||||
|
title="Words Seen Progress"
|
||||||
|
data={filteredWordsProgress}
|
||||||
|
maxSeries={maxTitles}
|
||||||
|
maxSeriesMode={maxTitlesMode}
|
||||||
/>
|
/>
|
||||||
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} />
|
|
||||||
|
|
||||||
<SectionHeader>Library — Summary</SectionHeader>
|
<SectionHeader>Library — Summary</SectionHeader>
|
||||||
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
<LibrarySummarySection rows={data.librarySummary} hiddenTitles={activeHiddenAnime} />
|
||||||
|
|||||||
@@ -5,9 +5,40 @@ import type { PerAnimeDataPoint } from './StackedTrendChart';
|
|||||||
import {
|
import {
|
||||||
buildAnimeVisibilityOptions,
|
buildAnimeVisibilityOptions,
|
||||||
filterHiddenAnimeData,
|
filterHiddenAnimeData,
|
||||||
|
loadHiddenTitles,
|
||||||
|
loadMaxTitles,
|
||||||
|
loadMaxTitlesMode,
|
||||||
|
loadShowEmptyDays,
|
||||||
pruneHiddenAnime,
|
pruneHiddenAnime,
|
||||||
|
saveHiddenTitles,
|
||||||
|
saveMaxTitles,
|
||||||
|
saveMaxTitlesMode,
|
||||||
|
saveShowEmptyDays,
|
||||||
} from './anime-visibility';
|
} from './anime-visibility';
|
||||||
|
|
||||||
|
function installLocalStorage(initial: Record<string, string> = {}) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
restore: () => {
|
||||||
|
if (previous) {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', previous);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
|
const SAMPLE_POINTS: PerAnimeDataPoint[] = [
|
||||||
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
|
{ epochDay: 1, animeTitle: 'KonoSuba', value: 5 },
|
||||||
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
|
{ epochDay: 2, animeTitle: 'KonoSuba', value: 10 },
|
||||||
@@ -45,3 +76,86 @@ test('pruneHiddenAnime drops titles that are no longer available', () => {
|
|||||||
|
|
||||||
assert.deepEqual([...hidden], ['KonoSuba']);
|
assert.deepEqual([...hidden], ['KonoSuba']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hidden titles round-trip through localStorage', () => {
|
||||||
|
const { restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
saveHiddenTitles(new Set(['KonoSuba', 'One Piece']));
|
||||||
|
assert.deepEqual([...loadHiddenTitles()], ['KonoSuba', 'One Piece']);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadHiddenTitles tolerates missing or malformed stored values', () => {
|
||||||
|
const { restore } = installLocalStorage({
|
||||||
|
'subminer-stats-trends-hidden-titles': '{"not":"an array"}',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.deepEqual([...loadHiddenTitles()], []);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max titles preference defaults to 7 and round-trips including explicit All', () => {
|
||||||
|
const { values, restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
// First run (nothing stored) defaults to the 7-title cap, not "All".
|
||||||
|
assert.equal(loadMaxTitles(), 7);
|
||||||
|
saveMaxTitles(5);
|
||||||
|
assert.equal(loadMaxTitles(), 5);
|
||||||
|
// "All" persists explicitly instead of collapsing back to the default.
|
||||||
|
saveMaxTitles(null);
|
||||||
|
assert.equal(loadMaxTitles(), null);
|
||||||
|
assert.equal(values.get('subminer-stats-trends-max-titles'), 'all');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMaxTitles falls back to the default for unsupported stored values', () => {
|
||||||
|
for (const storedValue of ['8', '0', 'banana']) {
|
||||||
|
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles': storedValue });
|
||||||
|
try {
|
||||||
|
assert.equal(loadMaxTitles(), 7);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max titles mode defaults to recent and round-trips', () => {
|
||||||
|
const { restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
assert.equal(loadMaxTitlesMode(), 'recent');
|
||||||
|
saveMaxTitlesMode('total');
|
||||||
|
assert.equal(loadMaxTitlesMode(), 'total');
|
||||||
|
saveMaxTitlesMode('recent');
|
||||||
|
assert.equal(loadMaxTitlesMode(), 'recent');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMaxTitlesMode falls back to recent for unknown stored values', () => {
|
||||||
|
const { restore } = installLocalStorage({ 'subminer-stats-trends-max-titles-mode': 'sideways' });
|
||||||
|
try {
|
||||||
|
assert.equal(loadMaxTitlesMode(), 'recent');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show empty days preference defaults to true and round-trips', () => {
|
||||||
|
const { restore } = installLocalStorage();
|
||||||
|
try {
|
||||||
|
assert.equal(loadShowEmptyDays(), true);
|
||||||
|
saveShowEmptyDays(false);
|
||||||
|
assert.equal(loadShowEmptyDays(), false);
|
||||||
|
saveShowEmptyDays(true);
|
||||||
|
assert.equal(loadShowEmptyDays(), true);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,109 @@
|
|||||||
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
import type { PerAnimeDataPoint } from './StackedTrendChart';
|
||||||
|
|
||||||
|
const HIDDEN_TITLES_KEY = 'subminer-stats-trends-hidden-titles';
|
||||||
|
const MAX_TITLES_KEY = 'subminer-stats-trends-max-titles';
|
||||||
|
const MAX_TITLES_MODE_KEY = 'subminer-stats-trends-max-titles-mode';
|
||||||
|
const SHOW_EMPTY_DAYS_KEY = 'subminer-stats-trends-show-empty-days';
|
||||||
|
|
||||||
|
export const MAX_TITLES_OPTIONS = [3, 5, 7, 10] as const;
|
||||||
|
|
||||||
|
// How the per-chart limit picks which titles survive: 'recent' keeps the most
|
||||||
|
// recently active titles, 'total' keeps the highest cumulative totals. 'recent'
|
||||||
|
// is listed first so it heads the dropdown.
|
||||||
|
export type MaxTitlesMode = 'total' | 'recent';
|
||||||
|
export const MAX_TITLES_MODES: readonly MaxTitlesMode[] = ['recent', 'total'];
|
||||||
|
|
||||||
|
// First-run defaults: show the 7 most recently active titles per chart.
|
||||||
|
const DEFAULT_MAX_TITLES = 7;
|
||||||
|
const DEFAULT_MAX_TITLES_MODE: MaxTitlesMode = 'recent';
|
||||||
|
const ALL_TITLES_STORED_VALUE = 'all';
|
||||||
|
|
||||||
|
function getStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHiddenTitles(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = getStorage()?.getItem(HIDDEN_TITLES_KEY);
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHiddenTitles(hidden: ReadonlySet<string>): void {
|
||||||
|
try {
|
||||||
|
getStorage()?.setItem(HIDDEN_TITLES_KEY, JSON.stringify([...hidden]));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the persisted per-chart limit: a specific count, or null for "All".
|
||||||
|
// Absent/garbage storage falls back to the default count (never null), so a
|
||||||
|
// first run shows the default cap rather than every title. "All" is stored
|
||||||
|
// explicitly so it round-trips instead of collapsing back to the default.
|
||||||
|
export function loadMaxTitles(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = getStorage()?.getItem(MAX_TITLES_KEY);
|
||||||
|
if (raw === ALL_TITLES_STORED_VALUE) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return (MAX_TITLES_OPTIONS as readonly number[]).includes(value) ? value : DEFAULT_MAX_TITLES;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_MAX_TITLES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMaxTitles(value: number | null): void {
|
||||||
|
try {
|
||||||
|
getStorage()?.setItem(MAX_TITLES_KEY, value === null ? ALL_TITLES_STORED_VALUE : String(value));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadMaxTitlesMode(): MaxTitlesMode {
|
||||||
|
try {
|
||||||
|
const raw = getStorage()?.getItem(MAX_TITLES_MODE_KEY);
|
||||||
|
if (raw === 'recent' || raw === 'total') return raw;
|
||||||
|
return DEFAULT_MAX_TITLES_MODE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_MAX_TITLES_MODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMaxTitlesMode(mode: MaxTitlesMode): void {
|
||||||
|
try {
|
||||||
|
getStorage()?.setItem(MAX_TITLES_MODE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether trend charts zero-fill empty calendar buckets. Defaults to true so a
|
||||||
|
// first run shows the calendar-accurate view.
|
||||||
|
export function loadShowEmptyDays(): boolean {
|
||||||
|
try {
|
||||||
|
return getStorage()?.getItem(SHOW_EMPTY_DAYS_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShowEmptyDays(show: boolean): void {
|
||||||
|
try {
|
||||||
|
getStorage()?.setItem(SHOW_EMPTY_DAYS_KEY, show ? 'true' : 'false');
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
|
export function buildAnimeVisibilityOptions(datasets: PerAnimeDataPoint[][]): string[] {
|
||||||
const totals = new Map<string, number>();
|
const totals = new Map<string, number>();
|
||||||
for (const dataset of datasets) {
|
for (const dataset of datasets) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { TrendsDashboardData } from '../types/stats';
|
|||||||
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||||
export type GroupBy = 'day' | 'month';
|
export type GroupBy = 'day' | 'month';
|
||||||
|
|
||||||
export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
export function useTrends(range: TimeRange, groupBy: GroupBy, fillEmpty = true) {
|
||||||
const [data, setData] = useState<TrendsDashboardData | null>(null);
|
const [data, setData] = useState<TrendsDashboardData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -15,7 +15,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getStatsClient()
|
getStatsClient()
|
||||||
.getTrendsDashboard(range, groupBy)
|
.getTrendsDashboard(range, groupBy, fillEmpty)
|
||||||
.then((nextData) => {
|
.then((nextData) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setData(nextData);
|
setData(nextData);
|
||||||
@@ -31,7 +31,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [range, groupBy]);
|
}, [range, groupBy, fillEmpty]);
|
||||||
|
|
||||||
return { data, loading, error };
|
return { data, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ test('getTrendsDashboard requests the chart-ready trends endpoint with range and
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.getTrendsDashboard('90d', 'month');
|
await apiClient.getTrendsDashboard('90d', 'month');
|
||||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month`);
|
assert.equal(
|
||||||
|
seenUrl,
|
||||||
|
`${BASE_URL}/api/stats/trends/dashboard?range=90d&groupBy=month&fillEmpty=true`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
@@ -208,8 +211,11 @@ test('getTrendsDashboard accepts 365d range and builds correct URL', async () =>
|
|||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.getTrendsDashboard('365d', 'day');
|
await apiClient.getTrendsDashboard('365d', 'day', false);
|
||||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day`);
|
assert.equal(
|
||||||
|
seenUrl,
|
||||||
|
`${BASE_URL}/api/stats/trends/dashboard?range=365d&groupBy=day&fillEmpty=false`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,9 +162,13 @@ export const apiClient = {
|
|||||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||||
getWatchTimePerAnime: (limit = 90) =>
|
getWatchTimePerAnime: (limit = 90) =>
|
||||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||||
getTrendsDashboard: (range: '7d' | '30d' | '90d' | '365d' | 'all', groupBy: 'day' | 'month') =>
|
getTrendsDashboard: (
|
||||||
|
range: '7d' | '30d' | '90d' | '365d' | 'all',
|
||||||
|
groupBy: 'day' | 'month',
|
||||||
|
fillEmpty = true,
|
||||||
|
) =>
|
||||||
fetchJson<TrendsDashboardData>(
|
fetchJson<TrendsDashboardData>(
|
||||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}`,
|
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
|
||||||
),
|
),
|
||||||
getWordDetail: (wordId: number) =>
|
getWordDetail: (wordId: number) =>
|
||||||
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
||||||
|
|||||||