mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
chore: apply remaining workspace formatting and updates
This commit is contained in:
@@ -149,7 +149,13 @@ test('stats command launches attached app command with response path', async ()
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'],
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -187,40 +193,34 @@ test('stats command throws when stats response reports an error', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 0,
|
||||
waitForStatsResponse: async () => ({
|
||||
ok: false,
|
||||
error: 'Immersion tracking is disabled in config.',
|
||||
}),
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Immersion tracking is disabled in config\./,
|
||||
);
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 0,
|
||||
waitForStatsResponse: async () => ({
|
||||
ok: false,
|
||||
error: 'Immersion tracking is disabled in config.',
|
||||
}),
|
||||
removeDir: () => {},
|
||||
});
|
||||
}, /Immersion tracking is disabled in config\./);
|
||||
});
|
||||
|
||||
test('stats command fails if attached app exits before startup response', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 2,
|
||||
waitForStatsResponse: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||
},
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Stats app exited before startup response \(status 2\)\./,
|
||||
);
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 2,
|
||||
waitForStatsResponse: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||
},
|
||||
removeDir: () => {},
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
});
|
||||
|
||||
@@ -81,12 +81,16 @@ export async function runStatsCommand(
|
||||
'stats',
|
||||
);
|
||||
const startupResult = await Promise.race([
|
||||
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
|
||||
deps
|
||||
.waitForStatsResponse(responsePath)
|
||||
.then((response) => ({ kind: 'response' as const, response })),
|
||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||
]);
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
|
||||
throw new Error(
|
||||
`Stats app exited before startup response (status ${startupResult.status}).`,
|
||||
);
|
||||
}
|
||||
const response = await deps.waitForStatsResponse(responsePath);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -335,15 +335,18 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
});
|
||||
});
|
||||
|
||||
test('stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
test(
|
||||
'stats command launches attached app flow and waits for response file',
|
||||
{ timeout: 15000 },
|
||||
() => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
response_path=""
|
||||
prev=""
|
||||
@@ -369,20 +372,24 @@ mkdir -p "$(dirname "$response_path")"
|
||||
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_STATS_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['stats', '--log-level', 'debug'], env);
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_STATS_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['stats', '--log-level', 'debug'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/);
|
||||
});
|
||||
});
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
/^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
|
||||
@@ -15,10 +15,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'libsql';
|
||||
|
||||
const DB_PATH = join(
|
||||
process.env.HOME ?? '~',
|
||||
'.config/SubMiner/immersion.sqlite',
|
||||
);
|
||||
const DB_PATH = join(process.env.HOME ?? '~', '.config/SubMiner/immersion.sqlite');
|
||||
|
||||
function parsePositiveNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
||||
|
||||
@@ -1122,7 +1122,9 @@ export class AnkiIntegration {
|
||||
this.mediaGenerator.cleanup();
|
||||
}
|
||||
|
||||
setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void {
|
||||
setRecordCardsMinedCallback(
|
||||
callback: ((count: number, noteIds?: number[]) => void) | null,
|
||||
): void {
|
||||
this.recordCardsMinedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,9 @@ export class KnownWordCacheManager {
|
||||
private getKnownWordDecks(): string[] {
|
||||
const configuredDecks = this.deps.getConfig().knownWords?.decks;
|
||||
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
|
||||
return Object.keys(configuredDecks).map((d) => d.trim()).filter((d) => d.length > 0);
|
||||
return Object.keys(configuredDecks)
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d.length > 0);
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck?.trim();
|
||||
|
||||
@@ -6,7 +6,10 @@ import { PollingRunner } from './polling';
|
||||
test('polling runner records newly added cards after initialization', async () => {
|
||||
const recordedCards: number[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [[10, 11], [10, 11, 12, 13]];
|
||||
const responses = [
|
||||
[10, 11],
|
||||
[10, 11, 12, 13],
|
||||
];
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
|
||||
@@ -143,7 +143,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(dictionaryTarget.dictionary, true);
|
||||
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
|
||||
|
||||
const stats = parseArgs(['--stats', '--stats-response-path', '/tmp/subminer-stats-response.json']);
|
||||
const stats = parseArgs([
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-response.json',
|
||||
]);
|
||||
assert.equal(stats.stats, true);
|
||||
assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json');
|
||||
assert.equal(hasExplicitCommand(stats), true);
|
||||
|
||||
@@ -1528,10 +1528,7 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
||||
assert.equal(
|
||||
config.ankiConnect.knownWords.color,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.color,
|
||||
);
|
||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||
});
|
||||
@@ -1586,7 +1583,7 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(config.ankiConnect.knownWords.decks, {
|
||||
'Mining': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
|
||||
@@ -105,7 +105,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'ankiConnect.knownWords.decks',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
||||
description: 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||
description:
|
||||
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.nPlusOne',
|
||||
|
||||
@@ -33,7 +33,10 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
|
||||
assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'), false);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes ankiConnect tags by trimming and deduping', () => {
|
||||
@@ -52,16 +55,19 @@ test('normalizes ankiConnect tags by trimming and deduping', () => {
|
||||
|
||||
test('accepts knownWords.decks object format with field arrays', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], 'Mining': ['Expression'] } },
|
||||
knownWords: { decks: { 'Core Deck': ['Word', 'Reading'], Mining: ['Expression'] } },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.deepEqual(context.resolved.ankiConnect.knownWords.decks, {
|
||||
'Core Deck': ['Word', 'Reading'],
|
||||
'Mining': ['Expression'],
|
||||
Mining: ['Expression'],
|
||||
});
|
||||
assert.equal(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'), false);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.decks'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('converts legacy knownWords.decks array to object with default fields', () => {
|
||||
|
||||
@@ -624,7 +624,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const knownWordsConfig = isObject(ac.knownWords) ? (ac.knownWords as Record<string, unknown>) : {};
|
||||
const knownWordsConfig = isObject(ac.knownWords)
|
||||
? (ac.knownWords as Record<string, unknown>)
|
||||
: {};
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||
@@ -723,8 +725,7 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
|
||||
legacyBehaviorNPlusOneRefreshMinutes > 0;
|
||||
if (hasValidLegacyRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
legacyBehaviorNPlusOneRefreshMinutes;
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
|
||||
context.warn(
|
||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||
behavior.nPlusOneRefreshMinutes,
|
||||
|
||||
@@ -24,13 +24,23 @@ export function applyStatsConfig(context: ResolveContext): void {
|
||||
if (autoStartServer !== undefined) {
|
||||
resolved.stats.autoStartServer = autoStartServer;
|
||||
} else if (src.stats.autoStartServer !== undefined) {
|
||||
warn('stats.autoStartServer', src.stats.autoStartServer, resolved.stats.autoStartServer, 'Expected boolean.');
|
||||
warn(
|
||||
'stats.autoStartServer',
|
||||
src.stats.autoStartServer,
|
||||
resolved.stats.autoStartServer,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser);
|
||||
if (autoOpenBrowser !== undefined) {
|
||||
resolved.stats.autoOpenBrowser = autoOpenBrowser;
|
||||
} else if (src.stats.autoOpenBrowser !== undefined) {
|
||||
warn('stats.autoOpenBrowser', src.stats.autoOpenBrowser, resolved.stats.autoOpenBrowser, 'Expected boolean.');
|
||||
warn(
|
||||
'stats.autoOpenBrowser',
|
||||
src.stats.autoOpenBrowser,
|
||||
resolved.stats.autoOpenBrowser,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function runGuessit(target: string): Promise<string> {
|
||||
|
||||
export interface GuessAnilistMediaInfoDeps {
|
||||
runGuessit: (target: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
function firstString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
|
||||
@@ -111,7 +111,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
|
||||
canonicalTitle: '[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv',
|
||||
canonicalTitle:
|
||||
'[Jellyfin] Little Witch Academia S02E05 - 025 - Pact of the Dragon (2020) [1080p].mkv',
|
||||
sourcePath: '/tmp/cover-fetcher-season-test.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
@@ -138,7 +139,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
id: 19,
|
||||
episodes: 24,
|
||||
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
||||
title: { romaji: 'Little Witch Academia', english: 'Little Witch Academia', native: null },
|
||||
title: {
|
||||
romaji: 'Little Witch Academia',
|
||||
english: 'Little Witch Academia',
|
||||
native: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AnilistRateLimiter } from './rate-limiter';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
|
||||
import { guessAnilistMediaInfo, runGuessit, type GuessAnilistMediaInfoDeps } from './anilist-updater';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
runGuessit,
|
||||
type GuessAnilistMediaInfoDeps,
|
||||
} from './anilist-updater';
|
||||
|
||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
||||
@@ -91,7 +95,10 @@ export function stripFilenameTags(raw: string): string {
|
||||
}
|
||||
|
||||
function removeSeasonHint(title: string): string {
|
||||
return title.replace(/\bseason\s*\d+\b/gi, '').replace(/\s{2,}/g, ' ').trim();
|
||||
return title
|
||||
.replace(/\bseason\s*\d+\b/gi, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeTitle(text: string): string {
|
||||
@@ -134,23 +141,20 @@ function pickBestSearchResult(
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
const filtered = episode === null
|
||||
? media
|
||||
: media.filter((item) => {
|
||||
const total = item.episodes;
|
||||
return total === null || total >= episode;
|
||||
});
|
||||
const filtered =
|
||||
episode === null
|
||||
? media
|
||||
: media.filter((item) => {
|
||||
const total = item.episodes;
|
||||
return total === null || total >= episode;
|
||||
});
|
||||
const candidates = filtered.length > 0 ? filtered : media;
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scored = candidates.map((item) => {
|
||||
const candidateTitles = [
|
||||
item.title?.romaji,
|
||||
item.title?.english,
|
||||
item.title?.native,
|
||||
]
|
||||
const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizeTitle(value));
|
||||
|
||||
@@ -186,7 +190,11 @@ function pickBestSearchResult(
|
||||
});
|
||||
|
||||
const selected = scored[0]!;
|
||||
const selectedTitle = selected.item.title?.english ?? selected.item.title?.romaji ?? selected.item.title?.native ?? title;
|
||||
const selectedTitle =
|
||||
selected.item.title?.english ??
|
||||
selected.item.title?.romaji ??
|
||||
selected.item.title?.native ??
|
||||
title;
|
||||
return { id: selected.item.id, title: selectedTitle };
|
||||
}
|
||||
|
||||
@@ -311,9 +319,7 @@ export function createCoverArtFetcher(
|
||||
|
||||
const parsedInfo = await resolveMediaInfo(canonicalTitle);
|
||||
const searchBase = parsedInfo?.title ?? cleaned;
|
||||
const searchCandidates = parsedInfo
|
||||
? buildSearchCandidates(parsedInfo)
|
||||
: [cleaned];
|
||||
const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
|
||||
|
||||
const effectiveCandidates = searchCandidates.includes(cleaned)
|
||||
? searchCandidates
|
||||
|
||||
@@ -513,14 +513,16 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
ORDER BY v.source_path
|
||||
`,
|
||||
)
|
||||
.all('/tmp/Little Witch Academia S02E05.mkv', '/tmp/Little Witch Academia S02E06.mkv') as
|
||||
Array<{
|
||||
source_path: string | null;
|
||||
anime_id: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
anilist_id: number | null;
|
||||
}>;
|
||||
.all(
|
||||
'/tmp/Little Witch Academia S02E05.mkv',
|
||||
'/tmp/Little Witch Academia S02E06.mkv',
|
||||
) as Array<{
|
||||
source_path: string | null;
|
||||
anime_id: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
anilist_id: number | null;
|
||||
}>;
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.ok(rows[0]?.anime_id);
|
||||
|
||||
@@ -337,11 +337,7 @@ export class ImmersionTrackerService {
|
||||
return getWordOccurrences(this.db, headword, word, reading, limit, offset);
|
||||
}
|
||||
|
||||
async getKanjiOccurrences(
|
||||
kanji: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
): Promise<KanjiOccurrenceRow[]> {
|
||||
async getKanjiOccurrences(kanji: string, limit = 100, offset = 0): Promise<KanjiOccurrenceRow[]> {
|
||||
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
||||
}
|
||||
|
||||
@@ -413,16 +409,21 @@ export class ImmersionTrackerService {
|
||||
deleteVideoQuery(this.db, videoId);
|
||||
}
|
||||
|
||||
async reassignAnimeAnilist(animeId: number, info: {
|
||||
anilistId: number;
|
||||
titleRomaji?: string | null;
|
||||
titleEnglish?: string | null;
|
||||
titleNative?: string | null;
|
||||
episodesTotal?: number | null;
|
||||
description?: string | null;
|
||||
coverUrl?: string | null;
|
||||
}): Promise<void> {
|
||||
this.db.prepare(`
|
||||
async reassignAnimeAnilist(
|
||||
animeId: number,
|
||||
info: {
|
||||
anilistId: number;
|
||||
titleRomaji?: string | null;
|
||||
titleEnglish?: string | null;
|
||||
titleNative?: string | null;
|
||||
episodesTotal?: number | null;
|
||||
description?: string | null;
|
||||
coverUrl?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET anilist_id = ?,
|
||||
title_romaji = COALESCE(?, title_romaji),
|
||||
@@ -432,39 +433,55 @@ export class ImmersionTrackerService {
|
||||
description = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`).run(
|
||||
info.anilistId,
|
||||
info.titleRomaji ?? null,
|
||||
info.titleEnglish ?? null,
|
||||
info.titleNative ?? null,
|
||||
info.episodesTotal ?? null,
|
||||
info.description ?? null,
|
||||
Date.now(),
|
||||
animeId,
|
||||
);
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
info.anilistId,
|
||||
info.titleRomaji ?? null,
|
||||
info.titleEnglish ?? null,
|
||||
info.titleNative ?? null,
|
||||
info.episodesTotal ?? null,
|
||||
info.description ?? null,
|
||||
Date.now(),
|
||||
animeId,
|
||||
);
|
||||
|
||||
// Update cover art for all videos in this anime
|
||||
if (info.coverUrl) {
|
||||
const videos = this.db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
||||
const videos = this.db
|
||||
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
|
||||
.all(animeId) as Array<{ video_id: number }>;
|
||||
let coverBlob: Buffer | null = null;
|
||||
try {
|
||||
const res = await fetch(info.coverUrl);
|
||||
if (res.ok) coverBlob = Buffer.from(await res.arrayBuffer());
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const v of videos) {
|
||||
this.db.prepare(`
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_media_art (video_id, anilist_id, cover_url, cover_blob, title_romaji, title_english, episodes_total, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(video_id) DO UPDATE SET
|
||||
anilist_id = excluded.anilist_id, cover_url = excluded.cover_url, cover_blob = COALESCE(excluded.cover_blob, cover_blob),
|
||||
title_romaji = excluded.title_romaji, title_english = excluded.title_english, episodes_total = excluded.episodes_total,
|
||||
fetched_at_ms = excluded.fetched_at_ms, LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`).run(
|
||||
v.video_id, info.anilistId, info.coverUrl, coverBlob,
|
||||
info.titleRomaji ?? null, info.titleEnglish ?? null, info.episodesTotal ?? null,
|
||||
Date.now(), Date.now(), Date.now(),
|
||||
);
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
v.video_id,
|
||||
info.anilistId,
|
||||
info.coverUrl,
|
||||
coverBlob,
|
||||
info.titleRomaji ?? null,
|
||||
info.titleEnglish ?? null,
|
||||
info.episodesTotal ?? null,
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,11 +667,7 @@ export class ImmersionTrackerService {
|
||||
if (!headword || !word) {
|
||||
continue;
|
||||
}
|
||||
const wordKey = [
|
||||
headword,
|
||||
word,
|
||||
reading,
|
||||
].join('\u0000');
|
||||
const wordKey = [headword, word, reading].join('\u0000');
|
||||
const storedPartOfSpeech = deriveStoredPartOfSpeech({
|
||||
partOfSpeech: token.partOfSpeech,
|
||||
pos1: token.pos1 ?? '',
|
||||
@@ -729,7 +742,8 @@ export class ImmersionTrackerService {
|
||||
const durationMs = Math.round(durationSec * 1000);
|
||||
const current = getVideoDurationMs(this.db, this.sessionState.videoId);
|
||||
if (current === 0 || Math.abs(current - durationMs) > 1000) {
|
||||
this.db.prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?')
|
||||
this.db
|
||||
.prepare('UPDATE imm_videos SET duration_ms = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?')
|
||||
.run(durationMs, Date.now(), this.sessionState.videoId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,16 +149,20 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er
|
||||
|
||||
test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => {
|
||||
const seenTargets: string[] = [];
|
||||
const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', {
|
||||
runGuessit: async (target) => {
|
||||
seenTargets.push(target);
|
||||
return JSON.stringify({
|
||||
title: 'Little Witch Academia',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
});
|
||||
const parsed = await guessAnimeVideoMetadata(
|
||||
'/tmp/Little Witch Academia S02E05.mkv',
|
||||
'Episode 5',
|
||||
{
|
||||
runGuessit: async (target) => {
|
||||
seenTargets.push(target);
|
||||
return JSON.stringify({
|
||||
title: 'Little Witch Academia',
|
||||
season: 2,
|
||||
episode: 5,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
assert.deepEqual(seenTargets, ['Little Witch Academia S02E05.mkv']);
|
||||
assert.deepEqual(parsed, {
|
||||
@@ -176,11 +180,15 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
|
||||
});
|
||||
|
||||
test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => {
|
||||
const parsed = await guessAnimeVideoMetadata('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5', {
|
||||
runGuessit: async () => {
|
||||
throw new Error('guessit unavailable');
|
||||
const parsed = await guessAnimeVideoMetadata(
|
||||
'/tmp/Little Witch Academia S02E05.mkv',
|
||||
'Episode 5',
|
||||
{
|
||||
runGuessit: async () => {
|
||||
throw new Error('guessit unavailable');
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
parsedBasename: 'Little Witch Academia S02E05.mkv',
|
||||
@@ -199,13 +207,9 @@ test('guessAnimeVideoMetadata falls back to parser when guessit throws', async (
|
||||
});
|
||||
|
||||
test('guessAnimeVideoMetadata falls back when guessit output is incomplete', async () => {
|
||||
const parsed = await guessAnimeVideoMetadata(
|
||||
'/tmp/[SubsPlease] Frieren - 03 (1080p).mkv',
|
||||
null,
|
||||
{
|
||||
runGuessit: async () => JSON.stringify({ episode: 3 }),
|
||||
},
|
||||
);
|
||||
const parsed = await guessAnimeVideoMetadata('/tmp/[SubsPlease] Frieren - 03 (1080p).mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ episode: 3 }),
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
parsedBasename: '[SubsPlease] Frieren - 03 (1080p).mkv',
|
||||
|
||||
@@ -133,27 +133,54 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
const activeSessions = Number((active.get() as { total?: number } | null)?.total ?? 0);
|
||||
|
||||
const now = new Date();
|
||||
const todayLocal = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000);
|
||||
const episodesToday = (db.prepare(`
|
||||
const todayLocal = Math.floor(
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
|
||||
);
|
||||
const episodesToday =
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(DISTINCT s.video_id) AS count
|
||||
FROM imm_sessions s
|
||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
||||
`).get(todayLocal) as { count: number })?.count ?? 0;
|
||||
`,
|
||||
)
|
||||
.get(todayLocal) as { count: number }
|
||||
)?.count ?? 0;
|
||||
|
||||
const thirtyDaysAgoMs = Date.now() - 30 * 86400000;
|
||||
const activeAnimeCount = (db.prepare(`
|
||||
const activeAnimeCount =
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(DISTINCT v.anime_id) AS count
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
WHERE v.anime_id IS NOT NULL
|
||||
AND s.started_at_ms >= ?
|
||||
`).get(thirtyDaysAgoMs) as { count: number })?.count ?? 0;
|
||||
`,
|
||||
)
|
||||
.get(thirtyDaysAgoMs) as { count: number }
|
||||
)?.count ?? 0;
|
||||
|
||||
const totalEpisodesWatched = (db.prepare(`
|
||||
const totalEpisodesWatched =
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) AS count FROM imm_videos WHERE watched = 1
|
||||
`).get() as { count: number })?.count ?? 0;
|
||||
`,
|
||||
)
|
||||
.get() as { count: number }
|
||||
)?.count ?? 0;
|
||||
|
||||
const totalAnimeCompleted = (db.prepare(`
|
||||
const totalAnimeCompleted =
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) AS count FROM (
|
||||
SELECT a.anime_id
|
||||
FROM imm_anime a
|
||||
@@ -163,9 +190,19 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
GROUP BY a.anime_id
|
||||
HAVING COUNT(DISTINCT CASE WHEN v.watched = 1 THEN v.video_id END) >= MAX(m.episodes_total)
|
||||
)
|
||||
`).get() as { count: number })?.count ?? 0;
|
||||
`,
|
||||
)
|
||||
.get() as { count: number }
|
||||
)?.count ?? 0;
|
||||
|
||||
return { totalSessions, activeSessions, episodesToday, activeAnimeCount, totalEpisodesWatched, totalAnimeCompleted };
|
||||
return {
|
||||
totalSessions,
|
||||
activeSessions,
|
||||
episodesToday,
|
||||
activeAnimeCount,
|
||||
totalEpisodesWatched,
|
||||
totalAnimeCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] {
|
||||
@@ -420,7 +457,9 @@ export async function cleanupVocabularyStats(
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
||||
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`,
|
||||
);
|
||||
const deleteOccurrencesStmt = db.prepare('DELETE FROM imm_word_line_occurrences WHERE word_id = ?');
|
||||
const deleteOccurrencesStmt = db.prepare(
|
||||
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
|
||||
);
|
||||
let kept = 0;
|
||||
let deleted = 0;
|
||||
let repaired = 0;
|
||||
@@ -434,18 +473,16 @@ export async function cleanupVocabularyStats(
|
||||
row.word,
|
||||
resolvedPos.reading,
|
||||
row.id,
|
||||
) as
|
||||
| {
|
||||
id: number;
|
||||
part_of_speech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
first_seen: number | null;
|
||||
last_seen: number | null;
|
||||
frequency: number | null;
|
||||
}
|
||||
| null;
|
||||
) as {
|
||||
id: number;
|
||||
part_of_speech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
first_seen: number | null;
|
||||
last_seen: number | null;
|
||||
frequency: number | null;
|
||||
} | null;
|
||||
if (duplicate) {
|
||||
moveOccurrencesStmt.run(duplicate.id, row.id);
|
||||
deleteOccurrencesStmt.run(row.id);
|
||||
@@ -493,7 +530,10 @@ export async function cleanupVocabularyStats(
|
||||
!normalizePosField(effectiveRow.pos1) &&
|
||||
!normalizePosField(effectiveRow.pos2) &&
|
||||
!normalizePosField(effectiveRow.pos3);
|
||||
if (missingPos || shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow))) {
|
||||
if (
|
||||
missingPos ||
|
||||
shouldExcludeTokenFromVocabularyPersistence(toStoredWordToken(effectiveRow))
|
||||
) {
|
||||
deleteStmt.run(row.id);
|
||||
deleted += 1;
|
||||
continue;
|
||||
@@ -605,7 +645,9 @@ export function getSessionEvents(
|
||||
}
|
||||
|
||||
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.anime_id AS animeId,
|
||||
a.canonical_title AS canonicalTitle,
|
||||
@@ -631,11 +673,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
) sm ON sm.session_id = s.session_id
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY totalActiveMs DESC, lastWatchedMs DESC, canonicalTitle ASC
|
||||
`).all() as unknown as AnimeLibraryRow[];
|
||||
`,
|
||||
)
|
||||
.all() as unknown as AnimeLibraryRow[];
|
||||
}
|
||||
|
||||
export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.anime_id AS animeId,
|
||||
a.canonical_title AS canonicalTitle,
|
||||
@@ -670,11 +716,15 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
||||
) sm ON sm.session_id = s.session_id
|
||||
WHERE a.anime_id = ?
|
||||
GROUP BY a.anime_id
|
||||
`).get(animeId) as unknown as AnimeDetailRow | null;
|
||||
`,
|
||||
)
|
||||
.get(animeId) as unknown as AnimeDetailRow | null;
|
||||
}
|
||||
|
||||
export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
m.anilist_id AS anilistId,
|
||||
m.title_romaji AS titleRomaji,
|
||||
@@ -685,11 +735,15 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime
|
||||
WHERE v.anime_id = ?
|
||||
AND m.anilist_id IS NOT NULL
|
||||
ORDER BY v.parsed_season ASC
|
||||
`).all(animeId) as unknown as AnimeAnilistEntryRow[];
|
||||
`,
|
||||
)
|
||||
.all(animeId) as unknown as AnimeAnilistEntryRow[];
|
||||
}
|
||||
|
||||
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.anime_id AS animeId,
|
||||
v.video_id AS videoId,
|
||||
@@ -723,11 +777,15 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
||||
CASE WHEN v.parsed_episode IS NULL THEN 1 ELSE 0 END,
|
||||
v.parsed_episode ASC,
|
||||
v.video_id ASC
|
||||
`).all(animeId) as unknown as AnimeEpisodeRow[];
|
||||
`,
|
||||
)
|
||||
.all(animeId) as unknown as AnimeEpisodeRow[];
|
||||
}
|
||||
|
||||
export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
v.canonical_title AS canonicalTitle,
|
||||
@@ -751,11 +809,15 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id
|
||||
GROUP BY v.video_id
|
||||
ORDER BY lastWatchedMs DESC
|
||||
`).all() as unknown as MediaLibraryRow[];
|
||||
`,
|
||||
)
|
||||
.all() as unknown as MediaLibraryRow[];
|
||||
}
|
||||
|
||||
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.video_id AS videoId,
|
||||
v.canonical_title AS canonicalTitle,
|
||||
@@ -782,11 +844,19 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
||||
) sm ON sm.session_id = s.session_id
|
||||
WHERE v.video_id = ?
|
||||
GROUP BY v.video_id
|
||||
`).get(videoId) as unknown as MediaDetailRow | null;
|
||||
`,
|
||||
)
|
||||
.get(videoId) as unknown as MediaDetailRow | null;
|
||||
}
|
||||
|
||||
export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100): SessionSummaryQueryRow[] {
|
||||
return db.prepare(`
|
||||
export function getMediaSessions(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
limit = 100,
|
||||
): SessionSummaryQueryRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
s.session_id AS sessionId,
|
||||
s.video_id AS videoId,
|
||||
@@ -808,11 +878,19 @@ export function getMediaSessions(db: DatabaseSync, videoId: number, limit = 100)
|
||||
GROUP BY s.session_id
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`).all(videoId, limit) as unknown as SessionSummaryQueryRow[];
|
||||
`,
|
||||
)
|
||||
.all(videoId, limit) as unknown as SessionSummaryQueryRow[];
|
||||
}
|
||||
|
||||
export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit = 90): ImmersionSessionRollupRow[] {
|
||||
return db.prepare(`
|
||||
export function getMediaDailyRollups(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
limit = 90,
|
||||
): ImmersionSessionRollupRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
rollup_day AS rollupDayOrMonth,
|
||||
video_id AS videoId,
|
||||
@@ -829,11 +907,15 @@ export function getMediaDailyRollups(db: DatabaseSync, videoId: number, limit =
|
||||
WHERE video_id = ?
|
||||
ORDER BY rollup_day DESC
|
||||
LIMIT ?
|
||||
`).all(videoId, limit) as unknown as ImmersionSessionRollupRow[];
|
||||
`,
|
||||
)
|
||||
.all(videoId, limit) as unknown as ImmersionSessionRollupRow[];
|
||||
}
|
||||
|
||||
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.video_id AS videoId,
|
||||
a.anilist_id AS anilistId,
|
||||
@@ -848,11 +930,15 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow
|
||||
WHERE v.anime_id = ?
|
||||
AND a.cover_blob IS NOT NULL
|
||||
LIMIT 1
|
||||
`).get(animeId) as unknown as MediaArtRow | null;
|
||||
`,
|
||||
)
|
||||
.get(animeId) as unknown as MediaArtRow | null;
|
||||
}
|
||||
|
||||
export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
video_id AS videoId,
|
||||
anilist_id AS anilistId,
|
||||
@@ -864,7 +950,9 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu
|
||||
fetched_at_ms AS fetchedAtMs
|
||||
FROM imm_media_art
|
||||
WHERE video_id = ?
|
||||
`).get(videoId) as unknown as MediaArtRow | null;
|
||||
`,
|
||||
)
|
||||
.get(videoId) as unknown as MediaArtRow | null;
|
||||
}
|
||||
|
||||
export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] {
|
||||
@@ -872,17 +960,23 @@ export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRo
|
||||
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const todayLocalDay = Math.floor(localMidnight / 86_400_000);
|
||||
const cutoffDay = todayLocalDay - days;
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT rollup_day AS epochDay, SUM(total_active_min) AS totalActiveMin
|
||||
FROM imm_daily_rollups
|
||||
WHERE rollup_day >= ?
|
||||
GROUP BY rollup_day
|
||||
ORDER BY rollup_day ASC
|
||||
`).all(cutoffDay) as StreakCalendarRow[];
|
||||
`,
|
||||
)
|
||||
.all(cutoffDay) as StreakCalendarRow[];
|
||||
}
|
||||
|
||||
export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): AnimeWordRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech,
|
||||
SUM(o.occurrence_count) AS frequency
|
||||
FROM imm_word_line_occurrences o
|
||||
@@ -892,11 +986,19 @@ export function getAnimeWords(db: DatabaseSync, animeId: number, limit = 50): An
|
||||
GROUP BY w.id
|
||||
ORDER BY frequency DESC
|
||||
LIMIT ?
|
||||
`).all(animeId, limit) as unknown as AnimeWordRow[];
|
||||
`,
|
||||
)
|
||||
.all(animeId, limit) as unknown as AnimeWordRow[];
|
||||
}
|
||||
|
||||
export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit = 90): ImmersionSessionRollupRow[] {
|
||||
return db.prepare(`
|
||||
export function getAnimeDailyRollups(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
limit = 90,
|
||||
): ImmersionSessionRollupRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT r.rollup_day AS rollupDayOrMonth, r.video_id AS videoId,
|
||||
r.total_sessions AS totalSessions, r.total_active_min AS totalActiveMin,
|
||||
r.total_lines_seen AS totalLinesSeen, r.total_words_seen AS totalWordsSeen,
|
||||
@@ -908,22 +1010,30 @@ export function getAnimeDailyRollups(db: DatabaseSync, animeId: number, limit =
|
||||
WHERE v.anime_id = ?
|
||||
ORDER BY r.rollup_day DESC
|
||||
LIMIT ?
|
||||
`).all(animeId, limit) as unknown as ImmersionSessionRollupRow[];
|
||||
`,
|
||||
)
|
||||
.all(animeId, limit) as unknown as ImmersionSessionRollupRow[];
|
||||
}
|
||||
|
||||
export function getEpisodesPerDay(db: DatabaseSync, limit = 90): EpisodesPerDayRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
|
||||
COUNT(DISTINCT s.video_id) AS episodeCount
|
||||
FROM imm_sessions s
|
||||
GROUP BY epochDay
|
||||
ORDER BY epochDay DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as EpisodesPerDayRow[];
|
||||
`,
|
||||
)
|
||||
.all(limit) as EpisodesPerDayRow[];
|
||||
}
|
||||
|
||||
export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT first_day AS epochDay, COUNT(*) AS newAnimeCount
|
||||
FROM (
|
||||
SELECT CAST(julianday(MIN(s.started_at_ms) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS first_day
|
||||
@@ -935,13 +1045,20 @@ export function getNewAnimePerDay(db: DatabaseSync, limit = 90): NewAnimePerDayR
|
||||
GROUP BY first_day
|
||||
ORDER BY first_day DESC
|
||||
LIMIT ?
|
||||
`).all(limit) as NewAnimePerDayRow[];
|
||||
`,
|
||||
)
|
||||
.all(limit) as NewAnimePerDayRow[];
|
||||
}
|
||||
|
||||
export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePerAnimeRow[] {
|
||||
const nowD = new Date();
|
||||
const cutoffDay = Math.floor(new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000) - limit;
|
||||
return db.prepare(`
|
||||
const cutoffDay =
|
||||
Math.floor(
|
||||
new Date(nowD.getFullYear(), nowD.getMonth(), nowD.getDate()).getTime() / 86_400_000,
|
||||
) - limit;
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT r.rollup_day AS epochDay, a.anime_id AS animeId,
|
||||
a.canonical_title AS animeTitle,
|
||||
SUM(r.total_active_min) AS totalActiveMin
|
||||
@@ -951,20 +1068,31 @@ export function getWatchTimePerAnime(db: DatabaseSync, limit = 90): WatchTimePer
|
||||
WHERE r.rollup_day >= ?
|
||||
GROUP BY r.rollup_day, a.anime_id
|
||||
ORDER BY r.rollup_day ASC
|
||||
`).all(cutoffDay) as WatchTimePerAnimeRow[];
|
||||
`,
|
||||
)
|
||||
.all(cutoffDay) as WatchTimePerAnimeRow[];
|
||||
}
|
||||
|
||||
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||
frequency, first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM imm_words WHERE id = ?
|
||||
`).get(wordId) as WordDetailRow | null;
|
||||
`,
|
||||
)
|
||||
.get(wordId) as WordDetailRow | null;
|
||||
}
|
||||
|
||||
export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordAnimeAppearanceRow[] {
|
||||
return db.prepare(`
|
||||
export function getWordAnimeAppearances(
|
||||
db: DatabaseSync,
|
||||
wordId: number,
|
||||
): WordAnimeAppearanceRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
|
||||
SUM(o.occurrence_count) AS occurrenceCount
|
||||
FROM imm_word_line_occurrences o
|
||||
@@ -973,37 +1101,55 @@ export function getWordAnimeAppearances(db: DatabaseSync, wordId: number): WordA
|
||||
WHERE o.word_id = ? AND sl.anime_id IS NOT NULL
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY occurrenceCount DESC
|
||||
`).all(wordId) as WordAnimeAppearanceRow[];
|
||||
`,
|
||||
)
|
||||
.all(wordId) as WordAnimeAppearanceRow[];
|
||||
}
|
||||
|
||||
export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): SimilarWordRow[] {
|
||||
const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as { headword: string; reading: string } | null;
|
||||
const word = db.prepare('SELECT headword, reading FROM imm_words WHERE id = ?').get(wordId) as {
|
||||
headword: string;
|
||||
reading: string;
|
||||
} | null;
|
||||
if (!word) return [];
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading, frequency
|
||||
FROM imm_words
|
||||
WHERE id != ?
|
||||
AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
|
||||
ORDER BY frequency DESC
|
||||
LIMIT ?
|
||||
`).all(
|
||||
wordId,
|
||||
word.reading,
|
||||
`%${word.headword.charAt(0)}%`,
|
||||
`%${word.headword.charAt(word.headword.length - 1)}%`,
|
||||
limit,
|
||||
) as SimilarWordRow[];
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
wordId,
|
||||
word.reading,
|
||||
`%${word.headword.charAt(0)}%`,
|
||||
`%${word.headword.charAt(word.headword.length - 1)}%`,
|
||||
limit,
|
||||
) as SimilarWordRow[];
|
||||
}
|
||||
|
||||
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS kanjiId, kanji, frequency, first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM imm_kanji WHERE id = ?
|
||||
`).get(kanjiId) as KanjiDetailRow | null;
|
||||
`,
|
||||
)
|
||||
.get(kanjiId) as KanjiDetailRow | null;
|
||||
}
|
||||
|
||||
export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): KanjiAnimeAppearanceRow[] {
|
||||
return db.prepare(`
|
||||
export function getKanjiAnimeAppearances(
|
||||
db: DatabaseSync,
|
||||
kanjiId: number,
|
||||
): KanjiAnimeAppearanceRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.anime_id AS animeId, a.canonical_title AS animeTitle,
|
||||
SUM(o.occurrence_count) AS occurrenceCount
|
||||
FROM imm_kanji_line_occurrences o
|
||||
@@ -1012,23 +1158,33 @@ export function getKanjiAnimeAppearances(db: DatabaseSync, kanjiId: number): Kan
|
||||
WHERE o.kanji_id = ? AND sl.anime_id IS NOT NULL
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY occurrenceCount DESC
|
||||
`).all(kanjiId) as KanjiAnimeAppearanceRow[];
|
||||
`,
|
||||
)
|
||||
.all(kanjiId) as KanjiAnimeAppearanceRow[];
|
||||
}
|
||||
|
||||
export function getKanjiWords(db: DatabaseSync, kanjiId: number, limit = 20): KanjiWordRow[] {
|
||||
const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as { kanji: string } | null;
|
||||
const kanjiRow = db.prepare('SELECT kanji FROM imm_kanji WHERE id = ?').get(kanjiId) as {
|
||||
kanji: string;
|
||||
} | null;
|
||||
if (!kanjiRow) return [];
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading, frequency
|
||||
FROM imm_words
|
||||
WHERE headword LIKE ?
|
||||
ORDER BY frequency DESC
|
||||
LIMIT ?
|
||||
`).all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[];
|
||||
`,
|
||||
)
|
||||
.all(`%${kanjiRow.kanji}%`, limit) as KanjiWordRow[];
|
||||
}
|
||||
|
||||
export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50): AnimeWordRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT w.id AS wordId, w.headword, w.word, w.reading, w.part_of_speech AS partOfSpeech,
|
||||
SUM(o.occurrence_count) AS frequency
|
||||
FROM imm_word_line_occurrences o
|
||||
@@ -1038,11 +1194,15 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50):
|
||||
GROUP BY w.id
|
||||
ORDER BY frequency DESC
|
||||
LIMIT ?
|
||||
`).all(videoId, limit) as unknown as AnimeWordRow[];
|
||||
`,
|
||||
)
|
||||
.all(videoId, limit) as unknown as AnimeWordRow[];
|
||||
}
|
||||
|
||||
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
|
||||
return db.prepare(`
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
s.session_id AS sessionId, s.video_id AS videoId,
|
||||
v.canonical_title AS canonicalTitle,
|
||||
@@ -1061,11 +1221,15 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
|
||||
WHERE s.video_id = ?
|
||||
GROUP BY s.session_id
|
||||
ORDER BY s.started_at_ms DESC
|
||||
`).all(videoId) as SessionSummaryQueryRow[];
|
||||
`,
|
||||
)
|
||||
.all(videoId) as SessionSummaryQueryRow[];
|
||||
}
|
||||
|
||||
export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
|
||||
const rows = db.prepare(`
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT e.event_id AS eventId, e.session_id AS sessionId,
|
||||
e.ts_ms AS tsMs, e.cards_delta AS cardsDelta,
|
||||
e.payload_json AS payloadJson
|
||||
@@ -1073,9 +1237,17 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
||||
JOIN imm_sessions s ON s.session_id = e.session_id
|
||||
WHERE s.video_id = ? AND e.event_type = 4
|
||||
ORDER BY e.ts_ms DESC
|
||||
`).all(videoId) as Array<{ eventId: number; sessionId: number; tsMs: number; cardsDelta: number; payloadJson: string | null }>;
|
||||
`,
|
||||
)
|
||||
.all(videoId) as Array<{
|
||||
eventId: number;
|
||||
sessionId: number;
|
||||
tsMs: number;
|
||||
cardsDelta: number;
|
||||
payloadJson: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map(row => {
|
||||
return rows.map((row) => {
|
||||
let noteIds: number[] = [];
|
||||
if (row.payloadJson) {
|
||||
try {
|
||||
@@ -1083,7 +1255,13 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
||||
if (Array.isArray(parsed.noteIds)) noteIds = parsed.noteIds;
|
||||
} catch {}
|
||||
}
|
||||
return { eventId: row.eventId, sessionId: row.sessionId, tsMs: row.tsMs, cardsDelta: row.cardsDelta, noteIds };
|
||||
return {
|
||||
eventId: row.eventId,
|
||||
sessionId: row.sessionId,
|
||||
tsMs: row.tsMs,
|
||||
cardsDelta: row.cardsDelta,
|
||||
noteIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1100,7 +1278,8 @@ export function upsertCoverArt(
|
||||
},
|
||||
): void {
|
||||
const nowMs = Date.now();
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_media_art (
|
||||
video_id, anilist_id, cover_url, cover_blob,
|
||||
title_romaji, title_english, episodes_total,
|
||||
@@ -1115,10 +1294,18 @@ export function upsertCoverArt(
|
||||
episodes_total = excluded.episodes_total,
|
||||
fetched_at_ms = excluded.fetched_at_ms,
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`).run(
|
||||
videoId, art.anilistId, art.coverUrl, art.coverBlob,
|
||||
art.titleRomaji, art.titleEnglish, art.episodesTotal,
|
||||
nowMs, nowMs, nowMs,
|
||||
`,
|
||||
).run(
|
||||
videoId,
|
||||
art.anilistId,
|
||||
art.coverUrl,
|
||||
art.coverBlob,
|
||||
art.titleRomaji,
|
||||
art.titleEnglish,
|
||||
art.episodesTotal,
|
||||
nowMs,
|
||||
nowMs,
|
||||
nowMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1138,7 +1325,8 @@ export function updateAnimeAnilistInfo(
|
||||
} | null;
|
||||
if (!row?.anime_id) return;
|
||||
|
||||
db.prepare(`
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET
|
||||
anilist_id = COALESCE(?, anilist_id),
|
||||
@@ -1148,7 +1336,8 @@ export function updateAnimeAnilistInfo(
|
||||
episodes_total = COALESCE(?, episodes_total),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`).run(
|
||||
`,
|
||||
).run(
|
||||
info.anilistId,
|
||||
info.titleRomaji,
|
||||
info.titleEnglish,
|
||||
@@ -1160,8 +1349,11 @@ export function updateAnimeAnilistInfo(
|
||||
}
|
||||
|
||||
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
|
||||
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?')
|
||||
.run(watched ? 1 : 0, Date.now(), videoId);
|
||||
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
|
||||
watched ? 1 : 0,
|
||||
Date.now(),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
|
||||
export function getVideoDurationMs(db: DatabaseSync, videoId: number): number {
|
||||
@@ -1186,7 +1378,9 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
}
|
||||
|
||||
export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
const sessions = db.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?').all(videoId) as Array<{ session_id: number }>;
|
||||
const sessions = db
|
||||
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
||||
.all(videoId) as Array<{ session_id: number }>;
|
||||
for (const s of sessions) {
|
||||
deleteSession(db, s.session_id);
|
||||
}
|
||||
|
||||
@@ -425,8 +425,9 @@ test('ensureSchema adds subtitle-line occurrence tables to schema version 6 data
|
||||
|
||||
const tableNames = new Set(
|
||||
(
|
||||
db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`).all() as
|
||||
Array<{ name: string }>
|
||||
db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((row) => row.name),
|
||||
);
|
||||
|
||||
@@ -731,8 +732,28 @@ test('word upsert replaces legacy other part_of_speech when better POS metadata
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
|
||||
stmts.wordUpsertStmt.run('知っている', '知っている', 'しっている', 'other', '動詞', '自立', '', 10, 10);
|
||||
stmts.wordUpsertStmt.run('知っている', '知っている', 'しっている', 'verb', '動詞', '自立', '', 11, 12);
|
||||
stmts.wordUpsertStmt.run(
|
||||
'知っている',
|
||||
'知っている',
|
||||
'しっている',
|
||||
'other',
|
||||
'動詞',
|
||||
'自立',
|
||||
'',
|
||||
10,
|
||||
10,
|
||||
);
|
||||
stmts.wordUpsertStmt.run(
|
||||
'知っている',
|
||||
'知っている',
|
||||
'しっている',
|
||||
'verb',
|
||||
'動詞',
|
||||
'自立',
|
||||
'',
|
||||
11,
|
||||
12,
|
||||
);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT frequency, part_of_speech, pos1, pos2 FROM imm_words WHERE headword = ?')
|
||||
|
||||
@@ -78,11 +78,7 @@ export function normalizeAnimeIdentityKey(title: string): string {
|
||||
}
|
||||
|
||||
function looksLikeEpisodeOnlyTitle(title: string): boolean {
|
||||
const normalized = title
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized);
|
||||
}
|
||||
|
||||
@@ -757,7 +753,9 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
if (currentVersion?.schema_version && currentVersion.schema_version < SCHEMA_VERSION) {
|
||||
db.exec('DELETE FROM imm_daily_rollups');
|
||||
db.exec('DELETE FROM imm_monthly_rollups');
|
||||
db.exec(`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`);
|
||||
db.exec(
|
||||
`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`,
|
||||
);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
@@ -954,7 +952,9 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
return;
|
||||
}
|
||||
if (write.kind === 'subtitleLine') {
|
||||
const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as { anime_id: number | null } | null;
|
||||
const animeRow = stmts.videoAnimeIdSelectStmt.get(write.videoId) as {
|
||||
anime_id: number | null;
|
||||
} | null;
|
||||
const lineResult = stmts.subtitleLineInsertStmt.run(
|
||||
write.sessionId,
|
||||
null,
|
||||
|
||||
@@ -29,7 +29,10 @@ export {
|
||||
} from './startup';
|
||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||
export { addYomitanNoteViaSearch, clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
|
||||
export {
|
||||
addYomitanNoteViaSearch,
|
||||
clearYomitanParserCachesForWindow,
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
export {
|
||||
deleteYomitanDictionaryByTitle,
|
||||
getYomitanDictionaryInfo,
|
||||
|
||||
@@ -313,7 +313,12 @@ test('registerIpcHandlers validates and clamps stats request limits', async () =
|
||||
calls.push(['monthly', limit]);
|
||||
return [];
|
||||
},
|
||||
getQueryHints: async () => ({ totalSessions: 0, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0 }),
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
|
||||
@@ -73,7 +73,12 @@ export interface IpcServiceDeps {
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
getDailyRollups: (limit?: number) => Promise<unknown>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<unknown>;
|
||||
getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>;
|
||||
getQueryHints: () => Promise<{
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
}>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getVocabularyStats: (limit?: number) => Promise<unknown>;
|
||||
@@ -512,13 +517,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.immersionTracker?.getMediaLibrary() ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaDetail,
|
||||
async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
|
||||
},
|
||||
);
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaSessions,
|
||||
@@ -538,11 +540,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaCover,
|
||||
async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
|
||||
},
|
||||
);
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,10 +128,7 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sid', data: '3' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
|
||||
deps,
|
||||
|
||||
@@ -51,7 +51,10 @@ function resolveStatsStaticPath(staticDir: string, requestPath: string): string
|
||||
const decodedPath = decodeURIComponent(normalizedPath);
|
||||
const absoluteStaticDir = resolve(staticDir);
|
||||
const absolutePath = resolve(absoluteStaticDir, decodedPath);
|
||||
if (absolutePath !== absoluteStaticDir && !absolutePath.startsWith(`${absoluteStaticDir}${sep}`)) {
|
||||
if (
|
||||
absolutePath !== absoluteStaticDir &&
|
||||
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!existsSync(absolutePath)) {
|
||||
@@ -71,8 +74,7 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
|
||||
}
|
||||
|
||||
const extension = extname(absolutePath).toLowerCase();
|
||||
const contentType =
|
||||
STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
|
||||
const contentType = STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream';
|
||||
const body = readFileSync(absolutePath);
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
@@ -86,7 +88,13 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
|
||||
|
||||
export function createStatsApp(
|
||||
tracker: ImmersionTrackerService,
|
||||
options?: { staticDir?: string; knownWordCachePath?: string; mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; addYomitanNote?: (word: string) => Promise<number | null> },
|
||||
options?: {
|
||||
staticDir?: string;
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
},
|
||||
) {
|
||||
const app = new Hono();
|
||||
|
||||
@@ -304,7 +312,7 @@ export function createStatsApp(
|
||||
variables: { search: query },
|
||||
}),
|
||||
});
|
||||
const json = await res.json() as { data?: { Page?: { media?: unknown[] } } };
|
||||
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
||||
return c.json(json.data?.Page?.media ?? []);
|
||||
} catch {
|
||||
return c.json([]);
|
||||
@@ -315,9 +323,14 @@ export function createStatsApp(
|
||||
const cachePath = options?.knownWordCachePath;
|
||||
if (!cachePath || !existsSync(cachePath)) return c.json([]);
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as { version?: number; words?: string[] };
|
||||
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
||||
version?: number;
|
||||
words?: string[];
|
||||
};
|
||||
if (raw.version === 1 && Array.isArray(raw.words)) return c.json(raw.words);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return c.json([]);
|
||||
});
|
||||
|
||||
@@ -377,7 +390,11 @@ export function createStatsApp(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }),
|
||||
body: JSON.stringify({
|
||||
action: 'guiBrowse',
|
||||
version: 6,
|
||||
params: { query: `nid:${noteId}` },
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return c.json(result);
|
||||
@@ -401,7 +418,9 @@ export function createStatsApp(
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }),
|
||||
});
|
||||
const result = await response.json() as { result?: Array<{ noteId: number; fields: Record<string, { value: string }> }> };
|
||||
const result = (await response.json()) as {
|
||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
return c.json(result.result ?? []);
|
||||
} catch {
|
||||
return c.json([], 502);
|
||||
@@ -445,7 +464,10 @@ export function createStatsApp(
|
||||
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
|
||||
|
||||
const highlightedSentence = word
|
||||
? sentence.replace(new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), `<b>${word}</b>`)
|
||||
? sentence.replace(
|
||||
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
`<b>${word}</b>`,
|
||||
)
|
||||
: sentence;
|
||||
|
||||
const generateAudio = ankiConfig.media?.generateAudio !== false;
|
||||
@@ -460,12 +482,18 @@ export function createStatsApp(
|
||||
if (!generateImage) {
|
||||
imagePromise = Promise.resolve(null);
|
||||
} else if (imageType === 'avif') {
|
||||
imagePromise = mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
});
|
||||
imagePromise = mediaGen.generateAnimatedImage(
|
||||
sourcePath,
|
||||
startSec,
|
||||
clampedEndSec,
|
||||
audioPadding,
|
||||
{
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||
imagePromise = mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
@@ -491,14 +519,21 @@ export function createStatsApp(
|
||||
]);
|
||||
|
||||
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||
return c.json({ error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}` }, 502);
|
||||
return c.json(
|
||||
{
|
||||
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
noteId = yomitanResult.value;
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
@@ -566,8 +601,10 @@ export function createStatsApp(
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected') errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected') errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||
@@ -684,7 +721,13 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
export function startStatsServer(config: StatsServerConfig): { close: () => void } {
|
||||
const app = createStatsApp(config.tracker, { staticDir: config.staticDir, knownWordCachePath: config.knownWordCachePath, mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, addYomitanNote: config.addYomitanNote });
|
||||
const app = createStatsApp(config.tracker, {
|
||||
staticDir: config.staticDir,
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
mpvSocketPath: config.mpvSocketPath,
|
||||
ankiConnectConfig: config.ankiConnectConfig,
|
||||
addYomitanNote: config.addYomitanNote,
|
||||
});
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -16,13 +16,9 @@ function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHideStatsWindowForInput(
|
||||
input: Electron.Input,
|
||||
toggleKey: string,
|
||||
): boolean {
|
||||
export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey: string): boolean {
|
||||
return (
|
||||
(input.type === 'keyDown' && input.key === 'Escape') ||
|
||||
isBareToggleKeyInput(input, toggleKey)
|
||||
(input.type === 'keyDown' && input.key === 'Escape') || isBareToggleKeyInput(input, toggleKey)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,13 +27,7 @@ test('parseSrtCues parses basic SRT content', () => {
|
||||
});
|
||||
|
||||
test('parseSrtCues handles multi-line subtitle text', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:01:00,000 --> 00:01:05,000',
|
||||
'これは',
|
||||
'テストです',
|
||||
'',
|
||||
].join('\n');
|
||||
const content = ['1', '00:01:00,000 --> 00:01:05,000', 'これは', 'テストです', ''].join('\n');
|
||||
|
||||
const cues = parseSrtCues(content);
|
||||
|
||||
@@ -42,12 +36,7 @@ test('parseSrtCues handles multi-line subtitle text', () => {
|
||||
});
|
||||
|
||||
test('parseSrtCues handles hours in timestamps', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'01:30:00,000 --> 01:30:05,000',
|
||||
'テスト',
|
||||
'',
|
||||
].join('\n');
|
||||
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
|
||||
|
||||
const cues = parseSrtCues(content);
|
||||
|
||||
@@ -56,12 +45,7 @@ test('parseSrtCues handles hours in timestamps', () => {
|
||||
});
|
||||
|
||||
test('parseSrtCues handles VTT-style dot separator', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:00:01.000 --> 00:00:04.000',
|
||||
'VTTスタイル',
|
||||
'',
|
||||
].join('\n');
|
||||
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTスタイル', ''].join('\n');
|
||||
|
||||
const cues = parseSrtCues(content);
|
||||
|
||||
@@ -151,10 +135,7 @@ test('parseAssCues handles \\N line breaks', () => {
|
||||
});
|
||||
|
||||
test('parseAssCues returns empty for content without Events section', () => {
|
||||
const content = [
|
||||
'[Script Info]',
|
||||
'Title: Test',
|
||||
].join('\n');
|
||||
const content = ['[Script Info]', 'Title: Test'].join('\n');
|
||||
|
||||
assert.deepEqual(parseAssCues(content), []);
|
||||
});
|
||||
@@ -202,12 +183,7 @@ test('parseAssCues respects dynamic field ordering from the Format row', () => {
|
||||
});
|
||||
|
||||
test('parseSubtitleCues auto-detects SRT format', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:00:01,000 --> 00:00:04,000',
|
||||
'SRTテスト',
|
||||
'',
|
||||
].join('\n');
|
||||
const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.srt');
|
||||
assert.equal(cues.length, 1);
|
||||
@@ -227,12 +203,7 @@ test('parseSubtitleCues auto-detects ASS format', () => {
|
||||
});
|
||||
|
||||
test('parseSubtitleCues auto-detects VTT format', () => {
|
||||
const content = [
|
||||
'1',
|
||||
'00:00:01.000 --> 00:00:04.000',
|
||||
'VTTテスト',
|
||||
'',
|
||||
].join('\n');
|
||||
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.vtt');
|
||||
assert.equal(cues.length, 1);
|
||||
|
||||
@@ -34,8 +34,18 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startTime = parseTimestamp(timingMatch[1], timingMatch[2]!, timingMatch[3]!, timingMatch[4]!);
|
||||
const endTime = parseTimestamp(timingMatch[5], timingMatch[6]!, timingMatch[7]!, timingMatch[8]!);
|
||||
const startTime = parseTimestamp(
|
||||
timingMatch[1],
|
||||
timingMatch[2]!,
|
||||
timingMatch[3]!,
|
||||
timingMatch[4]!,
|
||||
);
|
||||
const endTime = parseTimestamp(
|
||||
timingMatch[5],
|
||||
timingMatch[6]!,
|
||||
timingMatch[7]!,
|
||||
timingMatch[8]!,
|
||||
);
|
||||
|
||||
i += 1;
|
||||
const textLines: string[] = [];
|
||||
@@ -144,13 +154,14 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
||||
}
|
||||
|
||||
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
||||
const [normalizedSource = source] = (() => {
|
||||
try {
|
||||
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
})().split(/[?#]/, 1)[0] ?? '';
|
||||
const [normalizedSource = source] =
|
||||
(() => {
|
||||
try {
|
||||
return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source;
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
})().split(/[?#]/, 1)[0] ?? '';
|
||||
const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (ext === 'srt') return 'srt';
|
||||
if (ext === 'vtt') return 'vtt';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
computePriorityWindow,
|
||||
createSubtitlePrefetchService,
|
||||
} from './subtitle-prefetch';
|
||||
import { computePriorityWindow, createSubtitlePrefetchService } from './subtitle-prefetch';
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
@@ -169,7 +166,9 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
||||
service.stop();
|
||||
|
||||
// After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached
|
||||
const hasPostSeekCue = cachedTexts.some((t) => t === 'line-17' || t === 'line-18' || t === 'line-19');
|
||||
const hasPostSeekCue = cachedTexts.some(
|
||||
(t) => t === 'line-17' || t === 'line-18' || t === 'line-19',
|
||||
);
|
||||
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
||||
});
|
||||
|
||||
|
||||
@@ -3227,52 +3227,55 @@ test('tokenizeSubtitle excludes merged function/content token from frequency hig
|
||||
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'張り切ってんじゃ',
|
||||
makeDepsFromYomitanTokens([{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }], {
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null),
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: '張り切る',
|
||||
surface: '張り切っ',
|
||||
reading: 'ハリキッ',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'て',
|
||||
surface: 'て',
|
||||
reading: 'テ',
|
||||
startPos: 4,
|
||||
endPos: 5,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '接続助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'んじゃ',
|
||||
surface: 'んじゃ',
|
||||
reading: 'ンジャ',
|
||||
startPos: 5,
|
||||
endPos: 8,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '接続詞',
|
||||
pos2: '*',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
}),
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null),
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: '張り切る',
|
||||
surface: '張り切っ',
|
||||
reading: 'ハリキッ',
|
||||
startPos: 0,
|
||||
endPos: 4,
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'て',
|
||||
surface: 'て',
|
||||
reading: 'テ',
|
||||
startPos: 4,
|
||||
endPos: 5,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '接続助詞',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'んじゃ',
|
||||
surface: 'んじゃ',
|
||||
reading: 'ンジャ',
|
||||
startPos: 5,
|
||||
endPos: 8,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '接続詞',
|
||||
pos2: '*',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
|
||||
@@ -188,7 +188,9 @@ async function filterSubtitleAnnotationTokens(tokens: MergedToken[]): Promise<Me
|
||||
}
|
||||
|
||||
const annotationStage = await annotationStageModulePromise;
|
||||
return tokens.filter((token) => !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token));
|
||||
return tokens.filter(
|
||||
(token) => !annotationStage.shouldExcludeTokenFromSubtitleAnnotations(token),
|
||||
);
|
||||
}
|
||||
|
||||
export function createTokenizerDepsRuntime(
|
||||
@@ -449,7 +451,11 @@ function buildYomitanFrequencyIndex(
|
||||
reading,
|
||||
frequency: rank,
|
||||
};
|
||||
appendYomitanFrequencyEntry(byPair, makeYomitanFrequencyPairKey(term, reading), normalizedEntry);
|
||||
appendYomitanFrequencyEntry(
|
||||
byPair,
|
||||
makeYomitanFrequencyPairKey(term, reading),
|
||||
normalizedEntry,
|
||||
);
|
||||
appendYomitanFrequencyEntry(byTerm, term, normalizedEntry);
|
||||
}
|
||||
|
||||
@@ -486,11 +492,15 @@ function getYomitanFrequencyRank(
|
||||
}
|
||||
|
||||
const reading =
|
||||
typeof token.reading === 'string' && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
typeof token.reading === 'string' && token.reading.trim().length > 0
|
||||
? token.reading.trim()
|
||||
: null;
|
||||
const pairEntries =
|
||||
frequencyIndex.byPair.get(makeYomitanFrequencyPairKey(normalizedCandidateText, reading)) ?? [];
|
||||
const candidateEntries =
|
||||
pairEntries.length > 0 ? pairEntries : (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []);
|
||||
pairEntries.length > 0
|
||||
? pairEntries
|
||||
: (frequencyIndex.byTerm.get(normalizedCandidateText) ?? []);
|
||||
if (candidateEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ function resolveKnownWordText(
|
||||
return matchMode === 'surface' ? surface : headword;
|
||||
}
|
||||
|
||||
|
||||
function normalizePos1Tag(pos1: string | undefined): string {
|
||||
return typeof pos1 === 'string' ? pos1.trim() : '';
|
||||
}
|
||||
@@ -243,7 +242,6 @@ export function shouldExcludeTokenFromVocabularyPersistence(
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function getCachedJlptLevel(
|
||||
lookupText: string,
|
||||
getJlptLevel: (text: string) => JlptLevel | null,
|
||||
@@ -634,9 +632,7 @@ export function annotateTokens(
|
||||
? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeTokenJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
const jlptLevel = jlptEnabled ? computeTokenJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return {
|
||||
...token,
|
||||
|
||||
@@ -188,7 +188,9 @@ function toYomitanTermFrequency(value: unknown): YomitanTermFrequency | null {
|
||||
const rawFrequency = parsePositiveFrequencyValue(value.frequency);
|
||||
const displayValueRaw = value.displayValue;
|
||||
const parsedDisplayFrequency =
|
||||
displayValueRaw !== null && displayValueRaw !== undefined ? parseDisplayFrequencyValue(displayValueRaw) : null;
|
||||
displayValueRaw !== null && displayValueRaw !== undefined
|
||||
? parseDisplayFrequencyValue(displayValueRaw)
|
||||
: null;
|
||||
const frequency = parsedDisplayFrequency ?? rawFrequency;
|
||||
if (!term || !dictionary || frequency === null) {
|
||||
return null;
|
||||
|
||||
21
src/main.ts
21
src/main.ts
@@ -1135,7 +1135,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||
subtitlePrefetchInitController.cancelPendingInit();
|
||||
return;
|
||||
}
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos);
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
externalFilename,
|
||||
lastObservedTimePos,
|
||||
);
|
||||
} catch {
|
||||
// Track list query failed; skip subtitle prefetch refresh.
|
||||
}
|
||||
@@ -2512,11 +2515,17 @@ const ensureStatsServerStarted = (): string => {
|
||||
getYomitanExt: () => appState.yomitanExt,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => { appState.yomitanParserWindow = w; },
|
||||
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||
appState.yomitanParserWindow = w;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => { appState.yomitanParserReadyPromise = p; },
|
||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||
appState.yomitanParserReadyPromise = p;
|
||||
},
|
||||
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => { appState.yomitanParserInitPromise = p; },
|
||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||
appState.yomitanParserInitPromise = p;
|
||||
},
|
||||
};
|
||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||
statsServer = startStatsServer({
|
||||
@@ -2528,7 +2537,9 @@ const ensureStatsServerStarted = (): string => {
|
||||
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { forceOverride: true });
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: true,
|
||||
});
|
||||
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,6 +60,11 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(state.mediaGuess, { title: 'Show', season: null, episode: 1, source: 'guessit' });
|
||||
assert.deepEqual(state.mediaGuess, {
|
||||
title: 'Show',
|
||||
season: null,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
assert.equal(state.mediaGuessPromise, null);
|
||||
});
|
||||
|
||||
@@ -85,7 +85,11 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', season: null, episode: 1 });
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: 1,
|
||||
});
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
|
||||
@@ -145,7 +145,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined,
|
||||
onTimePosUpdate: deps.onTimePosUpdate
|
||||
? (time: number) => deps.onTimePosUpdate!(time)
|
||||
: undefined,
|
||||
recordPauseState: (paused: boolean) => {
|
||||
deps.appState.playbackPaused = paused;
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
|
||||
@@ -2,9 +2,14 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRunStatsCliCommandHandler } from './stats-cli-command';
|
||||
|
||||
function makeHandler(overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {}) {
|
||||
function makeHandler(
|
||||
overrides: Partial<Parameters<typeof createRunStatsCliCommandHandler>[0]> = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const responses: Array<{ responsePath: string; payload: { ok: boolean; url?: string; error?: string } }> = [];
|
||||
const responses: Array<{
|
||||
responsePath: string;
|
||||
payload: { ok: boolean; url?: string; error?: string };
|
||||
}> = [];
|
||||
|
||||
const handler = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => ({
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createRunStatsCliCommandHandler(deps: {
|
||||
getResolvedConfig: () => StatsCliConfig;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary> } | null;
|
||||
getImmersionTracker: () => {
|
||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
||||
} | null;
|
||||
ensureStatsServerStarted: () => string;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
|
||||
|
||||
@@ -27,13 +27,16 @@ test('getActiveExternalSubtitleSource returns null when the selected track is no
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => {
|
||||
const fileUrl = process.platform === 'win32'
|
||||
? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
|
||||
: 'file:///tmp/Sub%20Folder/subs.ass';
|
||||
const fileUrl =
|
||||
process.platform === 'win32'
|
||||
? 'file:///C:/Users/test/Sub%20Folder/subs.ass'
|
||||
: 'file:///tmp/Sub%20Folder/subs.ass';
|
||||
|
||||
const resolved = resolveSubtitleSourcePath(fileUrl);
|
||||
|
||||
assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'));
|
||||
assert.ok(
|
||||
resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
||||
|
||||
@@ -2,8 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
const statsAPI = {
|
||||
getOverview: (): Promise<unknown> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
|
||||
getOverview: (): Promise<unknown> => ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
|
||||
|
||||
getDailyRollups: (limit?: number): Promise<unknown> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -44,12 +44,24 @@ export function App() {
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'overview' ? (
|
||||
<section id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" key="overview" className="animate-fade-in">
|
||||
<section
|
||||
id="panel-overview"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-overview"
|
||||
key="overview"
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<OverviewTab />
|
||||
</section>
|
||||
) : null}
|
||||
{activeTab === 'anime' ? (
|
||||
<section id="panel-anime" role="tabpanel" aria-labelledby="tab-anime" key="anime" className="animate-fade-in">
|
||||
<section
|
||||
id="panel-anime"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-anime"
|
||||
key="anime"
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<AnimeTab
|
||||
initialAnimeId={selectedAnimeId}
|
||||
onClearInitialAnime={() => setSelectedAnimeId(null)}
|
||||
@@ -58,12 +70,24 @@ export function App() {
|
||||
</section>
|
||||
) : null}
|
||||
{activeTab === 'trends' ? (
|
||||
<section id="panel-trends" role="tabpanel" aria-labelledby="tab-trends" key="trends" className="animate-fade-in">
|
||||
<section
|
||||
id="panel-trends"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-trends"
|
||||
key="trends"
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<TrendsTab />
|
||||
</section>
|
||||
) : null}
|
||||
{activeTab === 'vocabulary' ? (
|
||||
<section id="panel-vocabulary" role="tabpanel" aria-labelledby="tab-vocabulary" key="vocabulary" className="animate-fade-in">
|
||||
<section
|
||||
id="panel-vocabulary"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-vocabulary"
|
||||
key="vocabulary"
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<VocabularyTab
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onOpenWordDetail={openWordDetail}
|
||||
@@ -75,7 +99,13 @@ export function App() {
|
||||
</section>
|
||||
) : null}
|
||||
{activeTab === 'sessions' ? (
|
||||
<section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" key="sessions" className="animate-fade-in">
|
||||
<section
|
||||
id="panel-sessions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-sessions"
|
||||
key="sessions"
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<SessionsTab />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -18,7 +18,12 @@ interface AnilistSelectorProps {
|
||||
onLinked: () => void;
|
||||
}
|
||||
|
||||
export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: AnilistSelectorProps) {
|
||||
export function AnilistSelector({
|
||||
animeId,
|
||||
initialQuery,
|
||||
onClose,
|
||||
onLinked,
|
||||
}: AnilistSelectorProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [results, setResults] = useState<AnilistMedia[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -32,7 +37,10 @@ export function AnilistSelector({ animeId, initialQuery, onClose, onLinked }: An
|
||||
}, []);
|
||||
|
||||
const doSearch = async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); return; }
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiClient.searchAnilist(q.trim());
|
||||
|
||||
@@ -37,7 +37,9 @@ export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
||||
{withCards.map((ep) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
|
||||
onClick={() =>
|
||||
setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)
|
||||
}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
|
||||
@@ -13,7 +13,9 @@ export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverIm
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,9 +32,15 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeRollups(animeId, 90)
|
||||
.then((data) => { if (!cancelled) setRollups(data); })
|
||||
.catch(() => { if (!cancelled) setRollups([]); });
|
||||
return () => { cancelled = true; };
|
||||
.then((data) => {
|
||||
if (!cancelled) setRollups(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRollups([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
@@ -75,8 +81,18 @@ function AnimeWatchChart({ animeId }: { animeId: number }) {
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
@@ -104,9 +120,8 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
const avgSessionMs = detail.totalSessions > 0
|
||||
? Math.round(detail.totalActiveMs / detail.totalSessions)
|
||||
: 0;
|
||||
const avgSessionMs =
|
||||
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -123,9 +138,17 @@ export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDeta
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
|
||||
<StatCard
|
||||
label="Watch Time"
|
||||
value={formatDuration(detail.totalActiveMs)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
|
||||
<StatCard label="Words" value={formatNumber(detail.totalWordsSeen)} color="text-ctp-mauve" />
|
||||
<StatCard
|
||||
label="Words"
|
||||
value={formatNumber(detail.totalWordsSeen)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
|
||||
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,10 @@ interface AnimeHeaderProps {
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
const label = entry.season != null
|
||||
? `Season ${entry.season}`
|
||||
: entry.titleEnglish ?? entry.titleRomaji ?? 'AniList';
|
||||
const label =
|
||||
entry.season != null
|
||||
? `Season ${entry.season}`
|
||||
: (entry.titleEnglish ?? entry.titleRomaji ?? 'AniList');
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -26,8 +27,9 @@ function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
}
|
||||
|
||||
export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative]
|
||||
.filter((t): t is string => t != null && t !== detail.canonicalTitle);
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
|
||||
(t): t is string => t != null && t !== detail.canonicalTitle,
|
||||
);
|
||||
const uniqueAltTitles = [...new Set(altTitles)];
|
||||
|
||||
const hasMultipleEntries = anilistEntries.length > 1;
|
||||
@@ -52,9 +54,7 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{anilistEntries.length > 0 ? (
|
||||
hasMultipleEntries ? (
|
||||
anilistEntries.map((entry) => (
|
||||
<AnilistButton key={entry.anilistId} entry={entry} />
|
||||
))
|
||||
anilistEntries.map((entry) => <AnilistButton key={entry.anilistId} entry={entry} />)
|
||||
) : (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
|
||||
@@ -82,7 +82,9 @@ export function AnimeHeader({ detail, anilistEntries, onChangeAnilist }: AnimeHe
|
||||
title="Search AniList and manually select the correct anime entry"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-overlay2 hover:bg-ctp-surface2 hover:text-ctp-subtext0 transition-colors"
|
||||
>
|
||||
{anilistEntries.length > 0 || detail.anilistId ? 'Change AniList Entry' : 'Link to AniList'}
|
||||
{anilistEntries.length > 0 || detail.anilistId
|
||||
? 'Change AniList Entry'
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,10 +23,14 @@ const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
|
||||
return [...list].sort((a, b) => {
|
||||
switch (key) {
|
||||
case 'lastWatched': return b.lastWatchedMs - a.lastWatchedMs;
|
||||
case 'watchTime': return b.totalActiveMs - a.totalActiveMs;
|
||||
case 'cards': return b.totalCards - a.totalCards;
|
||||
case 'episodes': return b.episodeCount - a.episodeCount;
|
||||
case 'lastWatched':
|
||||
return b.lastWatchedMs - a.lastWatchedMs;
|
||||
case 'watchTime':
|
||||
return b.totalActiveMs - a.totalActiveMs;
|
||||
case 'cards':
|
||||
return b.totalCards - a.totalCards;
|
||||
case 'episodes':
|
||||
return b.episodeCount - a.episodeCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -89,7 +93,9 @@ export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</option>
|
||||
<option key={opt.key} value={opt.key}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
|
||||
|
||||
@@ -18,10 +18,18 @@ export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps)
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getAnimeWords(animeId, 50)
|
||||
.then((data) => { if (!cancelled) setWords(data); })
|
||||
.catch(() => { if (!cancelled) setWords([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
.then((data) => {
|
||||
if (!cancelled) setWords(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWords([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [animeId]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;
|
||||
|
||||
@@ -6,7 +6,11 @@ interface CollapsibleSectionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ title, defaultOpen = true, children }: CollapsibleSectionProps) {
|
||||
export function CollapsibleSection({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
|
||||
@@ -20,9 +24,15 @@ export function CollapsibleSection({ title, defaultOpen = true, children }: Coll
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
|
||||
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">{open ? '▲' : '▼'}</span>
|
||||
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">
|
||||
{open ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
{open && <div id={contentId} className="px-4 pb-4">{children}</div>}
|
||||
{open && (
|
||||
<div id={contentId} className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,19 +23,28 @@ const COLOR_TO_BORDER: Record<string, string> = {
|
||||
'text-ctp-text': 'border-l-ctp-surface2',
|
||||
};
|
||||
|
||||
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
color = 'text-ctp-text',
|
||||
trend,
|
||||
}: StatCardProps) {
|
||||
const borderClass = COLOR_TO_BORDER[color] ?? 'border-l-ctp-surface2';
|
||||
|
||||
return (
|
||||
<div className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}>
|
||||
<div
|
||||
className={`bg-ctp-surface0 border border-ctp-surface1 border-l-[3px] ${borderClass} rounded-lg p-4 text-center`}
|
||||
>
|
||||
<div className={`text-2xl font-bold font-mono tabular-nums ${color}`}>{value}</div>
|
||||
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
|
||||
{subValue && (
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>
|
||||
)}
|
||||
{subValue && <div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>}
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}>
|
||||
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text}
|
||||
<div
|
||||
className={`text-xs mt-1 font-mono tabular-nums ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}
|
||||
>
|
||||
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'}{' '}
|
||||
{trend.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,12 +7,10 @@ interface MediaHeaderProps {
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail }: MediaHeaderProps) {
|
||||
const hitRate = detail.totalLookupCount > 0
|
||||
? detail.totalLookupHits / detail.totalLookupCount
|
||||
: null;
|
||||
const avgSessionMs = detail.totalSessions > 0
|
||||
? Math.round(detail.totalActiveMs / detail.totalSessions)
|
||||
: 0;
|
||||
const hitRate =
|
||||
detail.totalLookupCount > 0 ? detail.totalLookupHits / detail.totalLookupCount : null;
|
||||
const avgSessionMs =
|
||||
detail.totalSessions > 0 ? Math.round(detail.totalActiveMs / detail.totalSessions) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -58,8 +58,18 @@ export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
|
||||
@@ -10,9 +10,7 @@ interface HeroStatsProps {
|
||||
|
||||
export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
const today = todayLocalDay();
|
||||
const sessionsToday = sessions.filter(
|
||||
(s) => localDayFromMs(s.startedAtMs) === today,
|
||||
).length;
|
||||
const sessionsToday = sessions.filter((s) => localDayFromMs(s.startedAtMs) === today).length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
|
||||
@@ -36,11 +34,7 @@ export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
value={formatNumber(summary.episodesToday)}
|
||||
color="text-ctp-teal"
|
||||
/>
|
||||
<StatCard
|
||||
label="Current Streak"
|
||||
value={`${summary.streakDays}d`}
|
||||
color="text-ctp-peach"
|
||||
/>
|
||||
<StatCard label="Current Streak" value={`${summary.streakDays}d`} color="text-ctp-peach" />
|
||||
<StatCard
|
||||
label="Active Anime"
|
||||
value={formatNumber(summary.activeAnimeCount)}
|
||||
|
||||
@@ -37,7 +37,8 @@ export function OverviewTab() {
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here.
|
||||
No tracked card-add events in the current immersion DB yet. New cards mined after this
|
||||
fix will show here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 text-sm">
|
||||
@@ -72,7 +73,9 @@ export function OverviewTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Completed</div>
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">
|
||||
Episodes Completed
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold font-mono tabular-nums text-ctp-blue">
|
||||
{formatNumber(summary.totalEpisodesWatched)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { formatDuration, formatRelativeDate, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
|
||||
import {
|
||||
formatDuration,
|
||||
formatRelativeDate,
|
||||
formatNumber,
|
||||
todayLocalDay,
|
||||
localDayFromMs,
|
||||
} from '../../lib/formatters';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
@@ -50,11 +56,12 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
const map = new Map<string, AnimeGroup>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.animeId != null
|
||||
? `anime-${session.animeId}`
|
||||
: session.videoId != null
|
||||
? `video-${session.videoId}`
|
||||
: `session-${session.sessionId}`;
|
||||
const key =
|
||||
session.animeId != null
|
||||
? `anime-${session.animeId}`
|
||||
: session.videoId != null
|
||||
? `video-${session.videoId}`
|
||||
: `session-${session.sessionId}`;
|
||||
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
@@ -99,7 +106,8 @@ function CoverThumbnail({ videoId, title }: { videoId: number | null; title: str
|
||||
const target = e.currentTarget;
|
||||
target.style.display = 'none';
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
|
||||
placeholder.className =
|
||||
'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
|
||||
placeholder.textContent = fallbackChar;
|
||||
target.parentElement?.insertBefore(placeholder, target);
|
||||
}}
|
||||
@@ -116,16 +124,21 @@ function SessionItem({ session }: { session: SessionSummary }) {
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(session.cardsMined)}</div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(session.wordsSeen)}</div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(session.wordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,20 +165,22 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
|
||||
>
|
||||
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{displayTitle}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(group.totalCards)}</div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalCards)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(group.totalWords)}</div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(group.totalWords)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,11 +208,15 @@ function AnimeGroupRow({ group }: { group: AnimeGroup }) {
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">{formatNumber(s.cardsMined)}</div>
|
||||
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.cardsMined)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">{formatNumber(s.wordsSeen)}</div>
|
||||
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
||||
{formatNumber(s.wordsSeen)}
|
||||
</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {
|
||||
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
ReferenceArea, ReferenceLine,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useSessionDetail } from '../../hooks/useSessions';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
@@ -28,7 +35,10 @@ function formatTime(ms: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
interface PauseRegion { startMs: number; endMs: number }
|
||||
interface PauseRegion {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
function buildPauseRegions(events: SessionEvent[]): PauseRegion[] {
|
||||
const regions: PauseRegion[] = [];
|
||||
@@ -216,7 +226,13 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))' }} />
|
||||
<span
|
||||
className="inline-block w-3 h-2 rounded-sm"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(198,160,246,0.5), rgba(198,160,246,0.05))',
|
||||
}}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">New words</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -225,19 +241,35 @@ export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
|
||||
</span>
|
||||
{pauseCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-2 rounded-sm" style={{ background: 'rgba(245,169,127,0.2)', border: '1px solid rgba(245,169,127,0.5)' }} />
|
||||
<span className="text-ctp-overlay2">{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
|
||||
<span
|
||||
className="inline-block w-3 h-2 rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(245,169,127,0.2)',
|
||||
border: '1px solid rgba(245,169,127,0.5)',
|
||||
}}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{pauseCount} pause{pauseCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{seekCount > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-3 h-0.5 rounded" style={{ background: '#91d7e3', opacity: 0.7 }} />
|
||||
<span className="text-ctp-overlay2">{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
|
||||
<span
|
||||
className="inline-block w-3 h-0.5 rounded"
|
||||
style={{ background: '#91d7e3', opacity: 0.7 }}
|
||||
/>
|
||||
<span className="text-ctp-overlay2">
|
||||
{seekCount} seek{seekCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[12px]">⛏</span>
|
||||
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
|
||||
<span className="text-ctp-green">
|
||||
{Math.max(cardEventCount, cardsMined)} card
|
||||
{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function DateRangeSelector({
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
value={range}
|
||||
onChange={onRangeChange}
|
||||
formatLabel={(r) => r === 'all' ? 'All' : r}
|
||||
formatLabel={(r) => (r === 'all' ? 'All' : r)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Group by"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
@@ -15,8 +13,14 @@ interface StackedTrendChartProps {
|
||||
}
|
||||
|
||||
const LINE_COLORS = [
|
||||
'#8aadf4', '#c6a0f6', '#a6da95', '#f5a97f', '#f5bde6',
|
||||
'#91d7e3', '#ee99a0', '#f4dbd6',
|
||||
'#8aadf4',
|
||||
'#c6a0f6',
|
||||
'#a6da95',
|
||||
'#f5a97f',
|
||||
'#f5bde6',
|
||||
'#91d7e3',
|
||||
'#ee99a0',
|
||||
'#f4dbd6',
|
||||
];
|
||||
|
||||
function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
@@ -41,7 +45,10 @@ function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => {
|
||||
const row: Record<string, string | number> = {
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
};
|
||||
for (const title of topTitles) {
|
||||
row[title] = values[title] ?? 0;
|
||||
@@ -56,7 +63,11 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
@@ -73,8 +84,18 @@ export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<AreaChart data={points}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Area
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TrendChartProps {
|
||||
@@ -14,10 +20,14 @@ interface TrendChartProps {
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter, onBarClick }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
background: '#363a4f',
|
||||
border: '1px solid #494d64',
|
||||
borderRadius: 6,
|
||||
color: '#cad3f5',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const formatValue = (v: number) => formatter ? [formatter(v), title] : [String(v), title];
|
||||
const formatValue = (v: number) => (formatter ? [formatter(v), title] : [String(v), title]);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
@@ -25,21 +35,43 @@ export function TrendChart({ title, data, color, type, formatter, onBarClick }:
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={color}
|
||||
radius={[2, 2, 0, 0]}
|
||||
cursor={onBarClick ? 'pointer' : undefined}
|
||||
onClick={onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined}
|
||||
onClick={
|
||||
onBarClick ? (entry: { label: string }) => onBarClick(entry.label) : undefined
|
||||
}
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: '#a5adcb' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
|
||||
@@ -129,7 +129,9 @@ export function TrendsTab() {
|
||||
const watchByHour = buildWatchTimeByHour(data.sessions);
|
||||
|
||||
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
|
||||
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin,
|
||||
epochDay: e.epochDay,
|
||||
animeTitle: e.animeTitle,
|
||||
value: e.totalActiveMin,
|
||||
}));
|
||||
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
|
||||
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
|
||||
@@ -149,7 +151,12 @@ export function TrendsTab() {
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" />
|
||||
<TrendChart
|
||||
title="Watch Time (min)"
|
||||
data={dashboard.watchTime}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
|
||||
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
|
||||
@@ -172,8 +179,18 @@ export function TrendsTab() {
|
||||
<StackedTrendChart title="Words Seen Progress" data={wordsProgress} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
|
||||
<TrendChart
|
||||
title="Watch Time by Day of Week (min)"
|
||||
data={watchByDow}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="Watch Time by Hour (min)"
|
||||
data={watchByHour}
|
||||
color="#c6a0f6"
|
||||
type="bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,12 @@ interface ExclusionManagerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: ExclusionManagerProps) {
|
||||
export function ExclusionManager({
|
||||
excluded,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
onClose,
|
||||
}: ExclusionManagerProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
@@ -44,11 +49,12 @@ export function ExclusionManager({ excluded, onRemove, onClearAll, onClose }: Ex
|
||||
<div className="max-h-80 overflow-y-auto px-5 py-3">
|
||||
{excluded.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-ctp-overlay2">
|
||||
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from stats.
|
||||
No excluded words yet. Use the Exclude button on a word's detail panel to hide it from
|
||||
stats.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{excluded.map(w => (
|
||||
{excluded.map((w) => (
|
||||
<div
|
||||
key={`${w.headword}\0${w.word}\0${w.reading}`}
|
||||
className="flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2"
|
||||
|
||||
@@ -56,7 +56,8 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-2">Most Common Words Seen</h3>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
No frequency rank data available. Run the frequency backfill script or install a frequency dictionary.
|
||||
No frequency rank data available. Run the frequency backfill script or install a frequency
|
||||
dictionary.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -73,14 +74,21 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-ctp-text hover:text-ctp-subtext1 transition-colors"
|
||||
>
|
||||
<span className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}>{'\u25B6'}</span>
|
||||
<span
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${collapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{hasKnownData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setHideKnown(!hideKnown); setPage(0); }}
|
||||
onClick={() => {
|
||||
setHideKnown(!hideKnown);
|
||||
setPage(0);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||
hideKnown
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||
@@ -90,9 +98,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
Hide Known
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-ctp-overlay2">
|
||||
{ranked.length} words
|
||||
</span>
|
||||
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
@@ -122,9 +128,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
<td className="py-1.5 pr-3 font-mono tabular-nums text-ctp-peach text-xs">
|
||||
#{w.frequencyRank!.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">
|
||||
{w.headword}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-text font-medium">{w.headword}</td>
|
||||
<td className="py-1.5 pr-3 text-ctp-subtext0">
|
||||
{fullReading(w.headword, w.reading) || w.headword}
|
||||
</td>
|
||||
@@ -149,7 +153,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-ctp-overlay2">{page + 1} / {totalPages}</span>
|
||||
<span className="text-ctp-overlay2">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
|
||||
@@ -21,7 +21,12 @@ function formatSegment(ms: number | null): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToAnime }: KanjiDetailPanelProps) {
|
||||
export function KanjiDetailPanel({
|
||||
kanjiId,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onNavigateToAnime,
|
||||
}: KanjiDetailPanelProps) {
|
||||
const { data, loading, error } = useKanjiDetail(kanjiId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
@@ -44,7 +49,7 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
try {
|
||||
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
@@ -83,7 +88,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Kanji Detail</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
Kanji Detail
|
||||
</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
@@ -109,28 +116,39 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-teal">{formatNumber(data.detail.frequency)}</div>
|
||||
<div className="text-lg font-bold text-ctp-teal">
|
||||
{formatNumber(data.detail.frequency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
|
||||
<div className="text-sm font-medium text-ctp-green">
|
||||
{formatRelativeDate(data.detail.firstSeen)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
|
||||
<div className="text-sm font-medium text-ctp-mauve">
|
||||
{formatRelativeDate(data.detail.lastSeen)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Anime Appearances
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map(a => (
|
||||
{data.animeAppearances.map((a) => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToAnime?.(a.animeId);
|
||||
}}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
@@ -145,9 +163,11 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
|
||||
{data.words.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Words Using This Kanji</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Words Using This Kanji
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.words.map(w => (
|
||||
{data.words.map((w) => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
@@ -163,7 +183,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Example Lines
|
||||
</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -173,7 +195,9 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||
{occLoading && (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
)}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
@@ -199,7 +223,8 @@ export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToA
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} ·
|
||||
session {occ.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occ.text}
|
||||
|
||||
@@ -90,7 +90,9 @@ export function VocabularyOccurrencesDrawer({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading ? <div className="text-sm text-ctp-overlay2">Loading occurrences...</div> : null}
|
||||
{loading ? (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
) : null}
|
||||
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
|
||||
{!loading && !error && occurrences.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
@@ -116,8 +118,8 @@ export function VocabularyOccurrencesDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occurrence.segmentStartMs)}-{formatSegment(occurrence.segmentEndMs)} · session{' '}
|
||||
{occurrence.sessionId}
|
||||
{formatSegment(occurrence.segmentStartMs)}-
|
||||
{formatSegment(occurrence.segmentEndMs)} · session {occurrence.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occurrence.text}
|
||||
|
||||
@@ -26,7 +26,14 @@ function isProperNoun(w: VocabularyEntry): boolean {
|
||||
return w.pos2 === '固有名詞';
|
||||
}
|
||||
|
||||
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, isExcluded, onRemoveExclusion, onClearExclusions }: VocabularyTabProps) {
|
||||
export function VocabularyTab({
|
||||
onNavigateToAnime,
|
||||
onOpenWordDetail,
|
||||
excluded,
|
||||
isExcluded,
|
||||
onRemoveExclusion,
|
||||
onClearExclusions,
|
||||
}: VocabularyTabProps) {
|
||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -63,7 +70,7 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
|
||||
};
|
||||
|
||||
const handleBarClick = (headword: string): void => {
|
||||
const match = filteredWords.find(w => w.headword === headword);
|
||||
const match = filteredWords.find((w) => w.headword === headword);
|
||||
if (match) onOpenWordDetail?.(match.wordId);
|
||||
};
|
||||
|
||||
@@ -74,8 +81,16 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" />
|
||||
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" />
|
||||
<StatCard
|
||||
label="Unique Words"
|
||||
value={formatNumber(summary.uniqueWords)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Unique Kanji"
|
||||
value={formatNumber(summary.uniqueKanji)}
|
||||
color="text-ctp-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="New This Week"
|
||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
||||
@@ -133,9 +148,17 @@ export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail, excluded, i
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrequencyRankTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
||||
<FrequencyRankTable
|
||||
words={filteredWords}
|
||||
knownWords={knownWords}
|
||||
onSelectWord={handleSelectWord}
|
||||
/>
|
||||
|
||||
<CrossAnimeWordsTable words={filteredWords} knownWords={knownWords} onSelectWord={handleSelectWord} />
|
||||
<CrossAnimeWordsTable
|
||||
words={filteredWords}
|
||||
knownWords={knownWords}
|
||||
onSelectWord={handleSelectWord}
|
||||
/>
|
||||
|
||||
<WordList
|
||||
words={filteredWords}
|
||||
|
||||
@@ -24,15 +24,22 @@ function highlightWord(text: string, words: string[]): React.ReactNode {
|
||||
const needles = words.filter(Boolean);
|
||||
if (needles.length === 0) return text;
|
||||
|
||||
const escaped = needles.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const escaped = needles.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
|
||||
const parts = text.split(pattern);
|
||||
const needleSet = new Set(needles);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
needleSet.has(part)
|
||||
? <mark key={i} className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2">{part}</mark>
|
||||
: part
|
||||
needleSet.has(part) ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-transparent text-ctp-blue underline decoration-ctp-blue/40 underline-offset-2"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +51,14 @@ function formatSegment(ms: number | null): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime, isExcluded, onToggleExclusion }: WordDetailPanelProps) {
|
||||
export function WordDetailPanel({
|
||||
wordId,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onNavigateToAnime,
|
||||
isExcluded,
|
||||
onToggleExclusion,
|
||||
}: WordDetailPanelProps) {
|
||||
const { data, loading, error } = useWordDetail(wordId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
@@ -68,7 +82,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
|
||||
if (wordId === null) return null;
|
||||
|
||||
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, limit: number, append: boolean) => {
|
||||
const loadOccurrences = async (
|
||||
detail: NonNullable<typeof data>['detail'],
|
||||
offset: number,
|
||||
limit: number,
|
||||
append: boolean,
|
||||
) => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
@@ -78,11 +97,14 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
}
|
||||
try {
|
||||
const rows = await apiClient.getWordOccurrences(
|
||||
detail.headword, detail.word, detail.reading,
|
||||
limit, offset,
|
||||
detail.headword,
|
||||
detail.word,
|
||||
detail.reading,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||
setOccurrences((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setHasMore(rows.length === limit);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
@@ -109,9 +131,12 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
void loadOccurrences(data.detail, occurrences.length, LOAD_MORE_SIZE, true);
|
||||
};
|
||||
|
||||
const handleMine = async (occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio') => {
|
||||
const handleMine = async (
|
||||
occ: VocabularyOccurrenceEntry,
|
||||
mode: 'word' | 'sentence' | 'audio',
|
||||
) => {
|
||||
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
|
||||
setMineStatus(prev => ({ ...prev, [key]: { loading: true } }));
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||
try {
|
||||
const result = await apiClient.mineCard({
|
||||
sourcePath: occ.sourcePath!,
|
||||
@@ -124,20 +149,28 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
mode,
|
||||
});
|
||||
if (result.error) {
|
||||
setMineStatus(prev => ({ ...prev, [key]: { error: result.error } }));
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
|
||||
} else {
|
||||
setMineStatus(prev => ({ ...prev, [key]: { success: true } }));
|
||||
const label = mode === 'audio' ? 'Audio card' : mode === 'word' ? data!.detail.headword : occ.text.slice(0, 30);
|
||||
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
||||
const label =
|
||||
mode === 'audio'
|
||||
? 'Audio card'
|
||||
: mode === 'word'
|
||||
? data!.detail.headword
|
||||
: occ.text.slice(0, 30);
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
|
||||
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then(p => {
|
||||
Notification.requestPermission().then((p) => {
|
||||
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setMineStatus(prev => ({ ...prev, [key]: { error: err instanceof Error ? err.message : String(err) } }));
|
||||
setMineStatus((prev) => ({
|
||||
...prev,
|
||||
[key]: { error: err instanceof Error ? err.message : String(err) },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,23 +186,35 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Word Detail</div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
Word Detail
|
||||
</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">{data.detail.headword}</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}</div>
|
||||
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">
|
||||
{data.detail.headword}
|
||||
</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">
|
||||
{fullReading(data.detail.headword, data.detail.reading) || data.detail.word}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
|
||||
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos1}</span>
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos1}
|
||||
</span>
|
||||
)}
|
||||
{data.detail.pos2 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos2}</span>
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos2}
|
||||
</span>
|
||||
)}
|
||||
{data.detail.pos3 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos3}</span>
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">
|
||||
{data.detail.pos3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -204,28 +249,39 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-blue">{formatNumber(data.detail.frequency)}</div>
|
||||
<div className="text-lg font-bold text-ctp-blue">
|
||||
{formatNumber(data.detail.frequency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
|
||||
<div className="text-sm font-medium text-ctp-green">
|
||||
{formatRelativeDate(data.detail.firstSeen)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
|
||||
<div className="text-sm font-medium text-ctp-mauve">
|
||||
{formatRelativeDate(data.detail.lastSeen)}
|
||||
</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Anime Appearances
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map(a => (
|
||||
{data.animeAppearances.map((a) => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToAnime?.(a.animeId);
|
||||
}}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
@@ -240,9 +296,11 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
|
||||
{data.similarWords.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Similar Words</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Similar Words
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.similarWords.map(sw => (
|
||||
{data.similarWords.map((sw) => (
|
||||
<button
|
||||
key={sw.wordId}
|
||||
type="button"
|
||||
@@ -258,7 +316,9 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||
Example Lines
|
||||
</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -268,10 +328,15 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||
{occLoading && (
|
||||
<div className="text-sm text-ctp-overlay2">Loading occurrences...</div>
|
||||
)}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">No example lines tracked yet. Lines are stored for sessions recorded after the subtitle tracking update.</div>
|
||||
<div className="text-sm text-ctp-overlay2">
|
||||
No example lines tracked yet. Lines are stored for sessions recorded after the
|
||||
subtitle tracking update.
|
||||
</div>
|
||||
)}
|
||||
{occurrences.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
@@ -294,48 +359,68 @@ export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAni
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-ctp-overlay1">
|
||||
<span>{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}</span>
|
||||
{occ.sourcePath && occ.segmentStartMs != null && occ.segmentEndMs != null && (() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={wordStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'word')}
|
||||
>
|
||||
{wordStatus?.loading ? 'Mining...' : wordStatus?.success ? 'Mined!' : 'Mine Word'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
>
|
||||
{sentenceStatus?.loading ? 'Mining...' : sentenceStatus?.success ? 'Mined!' : 'Mine Sentence'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={audioStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'audio')}
|
||||
>
|
||||
{audioStatus?.loading ? 'Mining...' : audioStatus?.success ? 'Mined!' : 'Mine Audio'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<span>
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)}{' '}
|
||||
· session {occ.sessionId}
|
||||
</span>
|
||||
{occ.sourcePath &&
|
||||
occ.segmentStartMs != null &&
|
||||
occ.segmentEndMs != null &&
|
||||
(() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||
const audioStatus = mineStatus[`${baseKey}-audio`];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-mauve hover:text-ctp-mauve disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={wordStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'word')}
|
||||
>
|
||||
{wordStatus?.loading
|
||||
? 'Mining...'
|
||||
: wordStatus?.success
|
||||
? 'Mined!'
|
||||
: 'Mine Word'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-green hover:text-ctp-green disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={sentenceStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'sentence')}
|
||||
>
|
||||
{sentenceStatus?.loading
|
||||
? 'Mining...'
|
||||
: sentenceStatus?.success
|
||||
? 'Mined!'
|
||||
: 'Mine Sentence'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-ctp-surface2 px-1.5 py-0.5 text-[10px] font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={audioStatus?.loading}
|
||||
onClick={() => void handleMine(occ, 'audio')}
|
||||
>
|
||||
{audioStatus?.loading
|
||||
? 'Mining...'
|
||||
: audioStatus?.success
|
||||
? 'Mined!'
|
||||
: 'Mine Audio'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||
const errors = ['word', 'sentence', 'audio']
|
||||
.map(m => mineStatus[`${baseKey}-${m}`]?.error)
|
||||
.map((m) => mineStatus[`${baseKey}-${m}`]?.error)
|
||||
.filter(Boolean);
|
||||
return errors.length > 0 ? <div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div> : null;
|
||||
return errors.length > 0 ? (
|
||||
<div className="mt-1 text-[10px] text-ctp-red">{errors[0]}</div>
|
||||
) : null;
|
||||
})()}
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{highlightWord(occ.text, [data!.detail.headword, data!.detail.word])}
|
||||
|
||||
@@ -31,9 +31,10 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return words;
|
||||
return words.filter(
|
||||
w => w.headword.toLowerCase().includes(needle)
|
||||
|| w.word.toLowerCase().includes(needle)
|
||||
|| w.reading.toLowerCase().includes(needle),
|
||||
(w) =>
|
||||
w.headword.toLowerCase().includes(needle) ||
|
||||
w.word.toLowerCase().includes(needle) ||
|
||||
w.reading.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [words, search]);
|
||||
|
||||
@@ -61,11 +62,16 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">
|
||||
{titleBySort[sortBy]}
|
||||
{search && <span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>}
|
||||
{search && (
|
||||
<span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>
|
||||
)}
|
||||
</h3>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => { setSortBy(e.target.value as SortKey); setPage(0); }}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortKey);
|
||||
setPage(0);
|
||||
}}
|
||||
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
|
||||
>
|
||||
<option value="frequency">Frequency</option>
|
||||
@@ -78,9 +84,9 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
<button
|
||||
type="button"
|
||||
key={toWordKey(w)}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${
|
||||
getFrequencyColor(w.frequency)
|
||||
} ${
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${getFrequencyColor(
|
||||
w.frequency,
|
||||
)} ${
|
||||
selectedKey === toWordKey(w)
|
||||
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
|
||||
: 'hover:ring-1 hover:ring-ctp-surface2'
|
||||
@@ -89,9 +95,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
onClick={() => onSelectWord?.(w)}
|
||||
>
|
||||
{w.headword}
|
||||
{w.partOfSpeech && (
|
||||
<PosBadge pos={w.partOfSpeech} />
|
||||
)}
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
<span className="opacity-60">({w.frequency})</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -102,7 +106,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
@@ -113,7 +117,7 @@ export function WordList({ words, selectedKey = null, onSelectWord, search = ''
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,7 @@ const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
|
||||
|
||||
export function isFilterable(entry: VocabularyEntry): boolean {
|
||||
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
|
||||
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword)) return true;
|
||||
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ export function useAnimeLibrary() {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeLibrary()
|
||||
.then((data) => { if (!cancelled) setAnime(data); })
|
||||
.catch((err: Error) => { if (!cancelled) setError(err.message); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnime(data);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { anime, loading, error };
|
||||
|
||||
@@ -57,25 +57,19 @@ export function useExcludedWords() {
|
||||
[excluded],
|
||||
);
|
||||
|
||||
const toggleExclusion = useCallback(
|
||||
(w: ExcludedWord) => {
|
||||
const key = toKey(w);
|
||||
const current = load();
|
||||
if (getKeySet().has(key)) {
|
||||
persist(current.filter(e => toKey(e) !== key));
|
||||
} else {
|
||||
persist([...current, w]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const toggleExclusion = useCallback((w: ExcludedWord) => {
|
||||
const key = toKey(w);
|
||||
const current = load();
|
||||
if (getKeySet().has(key)) {
|
||||
persist(current.filter((e) => toKey(e) !== key));
|
||||
} else {
|
||||
persist([...current, w]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeExclusion = useCallback(
|
||||
(w: ExcludedWord) => {
|
||||
persist(load().filter(e => toKey(e) !== toKey(w)));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const removeExclusion = useCallback((w: ExcludedWord) => {
|
||||
persist(load().filter((e) => toKey(e) !== toKey(w)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => persist([]), []);
|
||||
|
||||
|
||||
@@ -11,10 +11,18 @@ export function useStreakCalendar(days = 90) {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getStreakCalendar(days)
|
||||
.then((data) => { if (!cancelled) setCalendar(data); })
|
||||
.catch((err: Error) => { if (!cancelled) setError(err.message); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
.then((data) => {
|
||||
if (!cancelled) setCalendar(data);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [days]);
|
||||
|
||||
return { calendar, loading, error };
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { DailyRollup, MonthlyRollup, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, SessionSummary, AnimeLibraryItem } from '../types/stats';
|
||||
import type {
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
SessionSummary,
|
||||
AnimeLibraryItem,
|
||||
} from '../types/stats';
|
||||
|
||||
export type TimeRange = '7d' | '30d' | '90d' | 'all';
|
||||
export type GroupBy = 'day' | 'month';
|
||||
@@ -35,9 +43,7 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||
const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
|
||||
|
||||
const rollupFetcher =
|
||||
groupBy === 'month'
|
||||
? client.getMonthlyRollups(monthlyLimit)
|
||||
: client.getDailyRollups(limit);
|
||||
groupBy === 'month' ? client.getMonthlyRollups(monthlyLimit) : client.getDailyRollups(limit);
|
||||
|
||||
Promise.all([
|
||||
rollupFetcher,
|
||||
@@ -47,9 +53,18 @@ export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||
client.getSessions(500),
|
||||
client.getAnimeLibrary(),
|
||||
])
|
||||
.then(([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
|
||||
setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary });
|
||||
})
|
||||
.then(
|
||||
([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
|
||||
setData({
|
||||
rollups,
|
||||
episodesPerDay,
|
||||
newAnimePerDay,
|
||||
watchTimePerAnime,
|
||||
sessions,
|
||||
animeLibrary,
|
||||
});
|
||||
},
|
||||
)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [range, groupBy]);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { DailyRollup, OverviewData, SessionSummary, StreakCalendarDay, VocabularyEntry } from '../types/stats';
|
||||
import type {
|
||||
DailyRollup,
|
||||
OverviewData,
|
||||
SessionSummary,
|
||||
StreakCalendarDay,
|
||||
VocabularyEntry,
|
||||
} from '../types/stats';
|
||||
import {
|
||||
buildOverviewSummary,
|
||||
buildStreakCalendar,
|
||||
@@ -49,7 +55,14 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
hints: { totalSessions: 1, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3, totalEpisodesWatched: 5, totalAnimeCompleted: 1 },
|
||||
hints: {
|
||||
totalSessions: 1,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { DailyRollup, KanjiEntry, OverviewData, StreakCalendarDay, VocabularyEntry } from '../types/stats';
|
||||
import type {
|
||||
DailyRollup,
|
||||
KanjiEntry,
|
||||
OverviewData,
|
||||
StreakCalendarDay,
|
||||
VocabularyEntry,
|
||||
} from '../types/stats';
|
||||
import { epochDayToDate, localDayFromMs } from './formatters';
|
||||
|
||||
export interface ChartPoint {
|
||||
@@ -110,7 +116,9 @@ function buildAggregatedDailyRows(rollups: DailyRollup[]) {
|
||||
averageSessionMinutes:
|
||||
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
|
||||
lookupHitRate:
|
||||
value.lookupWeight > 0 ? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100) : 0,
|
||||
value.lookupWeight > 0
|
||||
? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100)
|
||||
: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -142,7 +150,10 @@ export function buildOverviewSummary(
|
||||
|
||||
return {
|
||||
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
|
||||
todayCards: Math.max(todayRow?.cards ?? 0, sumBy(todaySessions, (session) => session.cardsMined)),
|
||||
todayCards: Math.max(
|
||||
todayRow?.cards ?? 0,
|
||||
sumBy(todaySessions, (session) => session.cardsMined),
|
||||
),
|
||||
streakDays,
|
||||
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
|
||||
totalTrackedCards: Math.max(sessionCards, rollupCards),
|
||||
@@ -152,17 +163,21 @@ export function buildOverviewSummary(
|
||||
totalAnimeCompleted: overview.hints.totalAnimeCompleted ?? 0,
|
||||
averageSessionMinutes:
|
||||
overview.sessions.length > 0
|
||||
? Math.round(sumBy(overview.sessions, (session) => session.activeWatchedMs) / overview.sessions.length / 60_000)
|
||||
? Math.round(
|
||||
sumBy(overview.sessions, (session) => session.activeWatchedMs) /
|
||||
overview.sessions.length /
|
||||
60_000,
|
||||
)
|
||||
: 0,
|
||||
totalSessions: overview.hints.totalSessions,
|
||||
activeDays: daysWithActivity.size,
|
||||
recentWatchTime: aggregated.slice(-14).map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
recentWatchTime: aggregated
|
||||
.slice(-14)
|
||||
.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTrendDashboard(
|
||||
rollups: DailyRollup[],
|
||||
): TrendDashboard {
|
||||
export function buildTrendDashboard(rollups: DailyRollup[]): TrendDashboard {
|
||||
const aggregated = buildAggregatedDailyRows(rollups);
|
||||
return {
|
||||
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type {
|
||||
OverviewData, DailyRollup, MonthlyRollup,
|
||||
SessionSummary, SessionTimelinePoint, SessionEvent,
|
||||
VocabularyEntry, KanjiEntry,
|
||||
OverviewData,
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
SessionSummary,
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem, MediaDetailData,
|
||||
AnimeLibraryItem, AnimeDetailData, AnimeWord,
|
||||
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime,
|
||||
WordDetailData, KanjiDetailData,
|
||||
MediaLibraryItem,
|
||||
MediaDetailData,
|
||||
AnimeLibraryItem,
|
||||
AnimeDetailData,
|
||||
AnimeWord,
|
||||
StreakCalendarDay,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
} from '../types/stats';
|
||||
|
||||
@@ -47,7 +59,9 @@ interface StatsElectronAPI {
|
||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
||||
ankiNotesInfo: (
|
||||
noteIds: number[],
|
||||
) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
||||
hideOverlay: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-ctp-base: #24273a;
|
||||
@@ -28,7 +28,8 @@
|
||||
--color-ctp-maroon: #ee99a0;
|
||||
--color-ctp-pink: #f5bde6;
|
||||
|
||||
--font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-sans:
|
||||
'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user