diff --git a/backlog/tasks/task-101 - Index-AniList-character-alternative-names-in-the-character-dictionary.md b/backlog/tasks/task-101 - Index-AniList-character-alternative-names-in-the-character-dictionary.md new file mode 100644 index 0000000..a2fef58 --- /dev/null +++ b/backlog/tasks/task-101 - Index-AniList-character-alternative-names-in-the-character-dictionary.md @@ -0,0 +1,39 @@ +--- +id: TASK-101 +title: Index AniList character alternative names in the character dictionary +status: Done +assignee: [] +created_date: '2026-03-07 00:00' +updated_date: '2026-03-07 00:00' +labels: + - dictionary + - anilist +priority: high +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.ts + - /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.test.ts +--- + +## Description + + +Index AniList character alternative names in generated character dictionaries so aliases like Shadow resolve during subtitle lookup instead of falling through to unrelated generic dictionary entries. + + +## Acceptance Criteria + +- [x] #1 Character fetch reads AniList alternative character names needed for lookup coverage +- [x] #2 Generated term banks include alias-derived terms for subtitle lookups like シャドウ +- [x] #3 Regression coverage proves alternative-name indexing works end to end + + +## Final Summary + + +Character dictionary generation now requests AniList `name.alternative`, indexes those aliases as term candidates, and expands mixed aliases like `Minoru Kagenou (影野ミノル)` into usable outer/inner variants. Also extended kana alias synthesis so the AniList alias `Shadow` emits `シャドウ`, which matches the subtitle token the user hit in The Eminence in Shadow. + +Bumped the character-dictionary snapshot format to invalidate stale cached snapshots, and updated merged-dictionary rebuilds to refresh invalid snapshots before composing the ZIP so old cache files do not hard-fail the merge path. + +Verified with `bun test src/main/character-dictionary-runtime.test.ts` and `bun run tsc --noEmit`. + diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index 032a0f2..79bd82c 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -220,8 +220,9 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w (c as { tag?: string }).tag === 'details' && Array.isArray((c as { content?: unknown[] }).content) && (c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description', - ) as { tag: string; content: Array> } | undefined; + ) as { tag: string; open?: boolean; content: Array> } | undefined; assert.ok(descSection, 'expected Description collapsible section'); + assert.equal(descSection.open, false); const descBody = descSection.content[1] as { content: string }; assert.ok( descBody.content.includes('Alexia Midgar is the second princess of the Kingdom of Midgar.'), @@ -233,11 +234,12 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w Array.isArray((c as { content?: unknown[] }).content) && (c as { content: Array<{ content?: string }> }).content[0]?.content === 'Character Information', - ) as { tag: string; content: Array> } | undefined; + ) as { tag: string; open?: boolean; content: Array> } | undefined; assert.ok( infoSection, 'expected Character Information collapsible section with parsed __Race:__ field', ); + assert.equal(infoSection.open, false); const topLevelImageGlossaryEntry = glossary.find( (item) => @@ -249,6 +251,328 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w } }); +test('generateForCurrentMedia applies configured open states to character dictionary sections', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + }; + + if (body.query?.includes('Page(perPage: 10)')) { + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 130298, + episodes: 20, + title: { + romaji: 'The Eminence in Shadow', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (body.query?.includes('characters(page: $page')) { + return new Response( + JSON.stringify({ + data: { + Media: { + title: { + romaji: 'The Eminence in Shadow', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'SUPPORTING', + voiceActors: [ + { + id: 456, + name: { + full: 'Rina Hidaka', + native: '日高里菜', + }, + image: { + medium: 'https://cdn.example.com/va-456.jpg', + }, + }, + ], + node: { + id: 123, + description: + 'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human', + image: { + large: 'https://cdn.example.com/character-123.png', + medium: 'https://cdn.example.com/character-123-small.png', + }, + name: { + full: 'Alexia Midgar', + native: 'アレクシア・ミドガル', + }, + }, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + } + + if (url === 'https://cdn.example.com/character-123.png') { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + + if (url === 'https://cdn.example.com/va-456.jpg') { + return new Response(Buffer.from([0xff, 0xd8, 0xff, 0xd9]), { + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }); + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof globalThis.fetch; + + try { + const runtime = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + episode: 5, + source: 'fallback', + }), + getCollapsibleSectionOpenState: (section) => + section === 'description' || section === 'voicedBy', + now: () => 1_700_000_000_000, + }); + + const result = await runtime.generateForCurrentMedia(); + const termBank = JSON.parse( + readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] + >; + const alexia = termBank.find(([term]) => term === 'アレクシア'); + assert.ok(alexia); + + const glossary = alexia[5]; + const entry = glossary[0] as { + type: string; + content: { tag: string; content: Array> }; + }; + const children = entry.content.content; + + const getSection = (title: string) => + children.find( + (c) => + (c as { tag?: string }).tag === 'details' && + Array.isArray((c as { content?: unknown[] }).content) && + (c as { content: Array<{ content?: string }> }).content[0]?.content === title, + ) as { open?: boolean } | undefined; + + assert.equal(getSection('Description')?.open, true); + assert.equal(getSection('Character Information')?.open, false); + assert.equal(getSection('Voiced by')?.open, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('generateForCurrentMedia reapplies collapsible open states when using cached snapshot data', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + }; + + if (body.query?.includes('Page(perPage: 10)')) { + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 130298, + episodes: 20, + title: { + romaji: 'The Eminence in Shadow', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (body.query?.includes('characters(page: $page')) { + return new Response( + JSON.stringify({ + data: { + Media: { + title: { + english: 'The Eminence in Shadow', + }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'SUPPORTING', + voiceActors: [ + { + id: 456, + name: { + full: 'Rina Hidaka', + native: '日高里菜', + }, + image: { + medium: 'https://cdn.example.com/va-456.jpg', + }, + }, + ], + node: { + id: 123, + description: + 'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human', + image: { + large: 'https://cdn.example.com/character-123.png', + medium: null, + }, + name: { + full: 'Alexia Midgar', + native: 'アレクシア・ミドガル', + }, + }, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + } + + if (url === 'https://cdn.example.com/character-123.png') { + return new Response(PNG_1X1, { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + + if (url === 'https://cdn.example.com/va-456.jpg') { + return new Response(PNG_1X1, { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof globalThis.fetch; + + try { + const runtimeOpen = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + episode: 5, + source: 'fallback', + }), + getCollapsibleSectionOpenState: () => true, + now: () => 1_700_000_000_000, + }); + await runtimeOpen.generateForCurrentMedia(); + + const runtimeClosed = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + episode: 5, + source: 'fallback', + }), + getCollapsibleSectionOpenState: () => false, + now: () => 1_700_000_000_500, + }); + const result = await runtimeClosed.generateForCurrentMedia(); + + const termBank = JSON.parse( + readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] + >; + const alexia = termBank.find(([term]) => term === 'アレクシア'); + assert.ok(alexia); + + const children = ( + alexia[5][0] as { + content: { content: Array> }; + } + ).content.content; + const sections = children.filter((item) => (item as { tag?: string }).tag === 'details') as Array<{ + open?: boolean; + }>; + assert.ok(sections.length >= 2); + assert.ok(sections.every((section) => section.open === false)); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('generateForCurrentMedia adds kana aliases for romanized names when native name is kanji', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; @@ -369,6 +693,123 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native } }); +test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + }; + + if (body.query?.includes('Page(perPage: 10)')) { + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 130298, + episodes: 20, + title: { + romaji: 'Kage no Jitsuryokusha ni Naritakute!', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + }, + ], + }, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + } + + if (body.query?.includes('characters(page: $page')) { + return new Response( + JSON.stringify({ + data: { + Media: { + title: { + romaji: 'Kage no Jitsuryokusha ni Naritakute!', + english: 'The Eminence in Shadow', + native: '陰の実力者になりたくて!', + }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + node: { + id: 321, + description: 'Leader of Shadow Garden.', + image: null, + name: { + full: 'Cid Kagenou', + native: 'シド・カゲノー', + alternative: ['Shadow', 'Minoru Kagenou'], + }, + }, + }, + ], + }, + }, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ); + } + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof globalThis.fetch; + + try { + const runtime = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', + getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05', + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: 'The Eminence in Shadow', + episode: 5, + source: 'fallback', + }), + now: () => 1_700_000_000_000, + }); + + const result = await runtime.generateForCurrentMedia(); + const termBank = JSON.parse( + readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] + >; + + const shadowKana = termBank.find(([term]) => term === 'シャドウ'); + assert.ok(shadowKana, 'expected katakana alias from AniList alternative name'); + assert.equal(shadowKana[1], 'しゃどう'); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; @@ -1158,6 +1599,306 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary } }); +test('buildMergedDictionary rebuilds snapshots written with an older format version', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + let characterQueryCount = 0; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + variables?: Record; + }; + + if (body.query?.includes('characters(page: $page')) { + characterQueryCount += 1; + assert.equal(body.variables?.id, 130298); + return new Response( + JSON.stringify({ + data: { + Media: { + title: { + english: 'The Eminence in Shadow', + }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + node: { + id: 111, + description: 'Leader of Shadow Garden.', + image: null, + name: { + full: 'Cid Kagenou', + native: 'シド・カゲノー', + alternative: ['Shadow'], + }, + }, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof globalThis.fetch; + + try { + const snapshotsDir = path.join(userDataPath, 'character-dictionaries', 'snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + fs.writeFileSync( + path.join(snapshotsDir, 'anilist-130298.json'), + JSON.stringify({ + formatVersion: 12, + mediaId: 130298, + mediaTitle: 'The Eminence in Shadow', + entryCount: 1, + updatedAt: 1_700_000_000_000, + termEntries: [['stale', '', 'name main', '', 100, ['stale'], 0, '']], + images: [], + }), + 'utf8', + ); + + const runtime = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => null, + getCurrentMediaTitle: () => null, + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => null, + now: () => 1_700_000_000_100, + }); + + const merged = await runtime.buildMergedDictionary([130298]); + const termBank = JSON.parse( + readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] + >; + + assert.equal(characterQueryCount, 1); + assert.ok(termBank.find(([term]) => term === 'シャドウ')); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('buildMergedDictionary reapplies collapsible open states from current config', async () => { + const userDataPath = makeTempDir(); + const originalFetch = globalThis.fetch; + const current = { title: 'The Eminence in Shadow', episode: 5 }; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === GRAPHQL_URL) { + const body = JSON.parse(String(init?.body ?? '{}')) as { + query?: string; + variables?: Record; + }; + + if (body.query?.includes('Page(perPage: 10)')) { + if (body.variables?.search === 'The Eminence in Shadow') { + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 130298, + episodes: 20, + title: { + english: 'The Eminence in Shadow', + }, + }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ + data: { + Page: { + media: [ + { + id: 21, + episodes: 28, + title: { + english: 'Frieren: Beyond Journey’s End', + }, + }, + ], + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + if (body.query?.includes('characters(page: $page')) { + const mediaId = Number(body.variables?.id); + if (mediaId === 130298) { + return new Response( + JSON.stringify({ + data: { + Media: { + title: { english: 'The Eminence in Shadow' }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + node: { + id: 111, + description: 'Leader of Shadow Garden.', + image: { + large: 'https://example.com/alpha.png', + medium: null, + }, + name: { + full: 'Alpha', + native: 'アルファ', + }, + }, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ + data: { + Media: { + title: { english: 'Frieren: Beyond Journey’s End' }, + characters: { + pageInfo: { hasNextPage: false }, + edges: [ + { + role: 'MAIN', + node: { + id: 222, + description: 'Elven mage.', + image: { + large: 'https://example.com/frieren.png', + medium: null, + }, + name: { + full: 'Frieren', + native: 'フリーレン', + }, + }, + }, + ], + }, + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + } + + if (url === 'https://example.com/alpha.png' || url === 'https://example.com/frieren.png') { + return new Response(PNG_1X1, { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof globalThis.fetch; + + try { + const runtimeOpen = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/current.mkv', + getCurrentMediaTitle: () => current.title, + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: current.title, + episode: current.episode, + source: 'fallback', + }), + getCollapsibleSectionOpenState: () => true, + now: () => 1_700_000_000_100, + }); + + await runtimeOpen.getOrCreateCurrentSnapshot(); + current.title = 'Frieren: Beyond Journey’s End'; + current.episode = 1; + await runtimeOpen.getOrCreateCurrentSnapshot(); + + const runtimeClosed = createCharacterDictionaryRuntimeService({ + userDataPath, + getCurrentMediaPath: () => '/tmp/current.mkv', + getCurrentMediaTitle: () => current.title, + resolveMediaPathForJimaku: (mediaPath) => mediaPath, + guessAnilistMediaInfo: async () => ({ + title: current.title, + episode: current.episode, + source: 'fallback', + }), + getCollapsibleSectionOpenState: () => false, + now: () => 1_700_000_000_200, + }); + + const merged = await runtimeClosed.buildMergedDictionary([21, 130298]); + const termBank = JSON.parse( + readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'), + ) as Array< + [ + string, + string, + string, + string, + number, + Array>, + number, + string, + ] + >; + const alpha = termBank.find(([term]) => term === 'アルファ'); + assert.ok(alpha); + const children = ( + alpha[5][0] as { + content: { content: Array> }; + } + ).content.content; + const sections = children.filter((item) => (item as { tag?: string }).tag === 'details') as Array<{ + open?: boolean; + }>; + assert.ok(sections.length >= 1); + assert.ok(sections.every((section) => section.open === false)); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('generateForCurrentMedia paces AniList requests and character image downloads', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 3d8fc6f..25df043 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { createHash } from 'node:crypto'; import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; +import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../types'; import { hasVideoExtension } from '../shared/video-extensions'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; @@ -54,7 +55,7 @@ export type CharacterDictionarySnapshot = { images: CharacterDictionarySnapshotImage[]; }; -const CHARACTER_DICTIONARY_FORMAT_VERSION = 12; +const CHARACTER_DICTIONARY_FORMAT_VERSION = 14; const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary'; type AniListSearchResponse = { @@ -105,6 +106,7 @@ type AniListCharacterPageResponse = { name?: { full?: string | null; native?: string | null; + alternative?: Array | null; } | null; } | null; } | null>; @@ -124,6 +126,7 @@ type CharacterRecord = { role: CharacterDictionaryRole; fullName: string; nativeName: string; + alternativeNames: string[]; description: string; imageUrl: string | null; voiceActors: VoiceActorRecord[]; @@ -178,6 +181,9 @@ export interface CharacterDictionaryRuntimeDeps { sleep?: (ms: number) => Promise; logInfo?: (message: string) => void; logWarn?: (message: string) => void; + getCollapsibleSectionOpenState?: ( + section: AnilistCharacterDictionaryCollapsibleSectionKey, + ) => boolean; } type ResolvedAniListMedia = { @@ -423,6 +429,7 @@ const ROMANIZED_KANA_MONOGRAPHS: ReadonlyArray<[string, string]> = [ ['re', 'レ'], ['ro', 'ロ'], ['wa', 'ワ'], + ['w', 'ウ'], ['wo', 'ヲ'], ['n', 'ン'], ]; @@ -490,37 +497,57 @@ function addRomanizedKanaAliases(values: Iterable): string[] { return [...aliases]; } +function expandRawNameVariants(rawName: string): string[] { + const trimmed = rawName.trim(); + if (!trimmed) return []; + + const variants = new Set([trimmed]); + const outer = trimmed.replace(/[((][^()()]+[))]/g, ' ').replace(/\s+/g, ' ').trim(); + if (outer && outer !== trimmed) { + variants.add(outer); + } + + for (const match of trimmed.matchAll(/[((]([^()()]+)[))]/g)) { + const inner = match[1]?.trim() || ''; + if (inner) { + variants.add(inner); + } + } + + return [...variants]; +} + function buildNameTerms(character: CharacterRecord): string[] { const base = new Set(); - const rawNames = [character.nativeName, character.fullName]; + const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames]; for (const rawName of rawNames) { - const name = rawName.trim(); - if (!name) continue; - base.add(name); + for (const name of expandRawNameVariants(rawName)) { + base.add(name); - const compact = name.replace(/[\s\u3000]+/g, ''); - if (compact && compact !== name) { - base.add(compact); - } + const compact = name.replace(/[\s\u3000]+/g, ''); + if (compact && compact !== name) { + base.add(compact); + } - const noMiddleDots = compact.replace(/[・・·•]/g, ''); - if (noMiddleDots && noMiddleDots !== compact) { - base.add(noMiddleDots); - } + const noMiddleDots = compact.replace(/[・・·•]/g, ''); + if (noMiddleDots && noMiddleDots !== compact) { + base.add(noMiddleDots); + } - const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0); - if (split.length === 2) { - base.add(split[0]!); - base.add(split[1]!); - } + const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0); + if (split.length === 2) { + base.add(split[0]!); + base.add(split[1]!); + } - const splitByMiddleDot = name - .split(/[・・·•]/) - .map((part) => part.trim()) - .filter((part) => part.length > 0); - if (splitByMiddleDot.length >= 2) { - for (const part of splitByMiddleDot) { - base.add(part); + const splitByMiddleDot = name + .split(/[・・·•]/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (splitByMiddleDot.length >= 2) { + for (const part of splitByMiddleDot) { + base.add(part); + } } } } @@ -758,11 +785,12 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record { function buildCollapsibleSection( title: string, + open: boolean, body: Array> | string | Record, ): Record { return { tag: 'details', - open: true, + open, style: { marginTop: '0.4em' }, content: [ { @@ -849,6 +877,9 @@ function createDefinitionGlossary( mediaTitle: string, imagePath: string | null, vaImagePaths: Map, + getCollapsibleSectionOpenState: ( + section: AnilistCharacterDictionaryCollapsibleSectionKey, + ) => boolean, ): CharacterDictionaryGlossaryEntry[] { const displayName = character.nativeName || character.fullName || `Character ${character.id}`; const secondaryName = @@ -910,7 +941,13 @@ function createDefinitionGlossary( }); if (descriptionText) { - content.push(buildCollapsibleSection('Description', descriptionText)); + content.push( + buildCollapsibleSection( + 'Description', + getCollapsibleSectionOpenState('description'), + descriptionText, + ), + ); } if (fields.length > 0) { @@ -919,11 +956,15 @@ function createDefinitionGlossary( content: `${f.key}: ${f.value}`, })); content.push( - buildCollapsibleSection('Character Information', { - tag: 'ul', - style: { marginTop: '0.15em' }, - content: fieldItems, - }), + buildCollapsibleSection( + 'Character Information', + getCollapsibleSectionOpenState('characterInformation'), + { + tag: 'ul', + style: { marginTop: '0.15em' }, + content: fieldItems, + }, + ), ); } @@ -931,6 +972,7 @@ function createDefinitionGlossary( content.push( buildCollapsibleSection( 'Voiced by', + getCollapsibleSectionOpenState('voicedBy'), buildVoicedByContent(character.voiceActors, vaImagePaths), ), ); @@ -1210,6 +1252,7 @@ async function fetchCharactersForMedia( name { full native + alternative } } } @@ -1243,7 +1286,13 @@ async function fetchCharactersForMedia( if (!node || typeof node.id !== 'number') continue; const fullName = node.name?.full?.trim() || ''; const nativeName = node.name?.native?.trim() || ''; - if (!fullName && !nativeName) continue; + const alternativeNames = [...new Set( + (node.name?.alternative ?? []) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + )]; + if (!fullName && !nativeName && alternativeNames.length === 0) continue; const voiceActors: VoiceActorRecord[] = []; for (const va of edge?.voiceActors ?? []) { if (!va || typeof va.id !== 'number') continue; @@ -1262,6 +1311,7 @@ async function fetchCharactersForMedia( role: mapRole(edge?.role), fullName, nativeName, + alternativeNames, description: node.description || '', imageUrl: node.image?.large || node.image?.medium || null, voiceActors, @@ -1340,6 +1390,9 @@ function buildSnapshotFromCharacters( imagesByCharacterId: Map, imagesByVaId: Map, updatedAt: number, + getCollapsibleSectionOpenState: ( + section: AnilistCharacterDictionaryCollapsibleSectionKey, + ) => boolean, ): CharacterDictionarySnapshot { const termEntries: CharacterDictionaryTermEntry[] = []; const seen = new Set(); @@ -1351,7 +1404,13 @@ function buildSnapshotFromCharacters( const vaImg = imagesByVaId.get(va.id); if (vaImg) vaImagePaths.set(va.id, vaImg.path); } - const glossary = createDefinitionGlossary(character, mediaTitle, imagePath, vaImagePaths); + const glossary = createDefinitionGlossary( + character, + mediaTitle, + imagePath, + vaImagePaths, + getCollapsibleSectionOpenState, + ); const candidateTerms = buildNameTerms(character); for (const term of candidateTerms) { const reading = buildReading(term); @@ -1377,6 +1436,67 @@ function buildSnapshotFromCharacters( }; } +function getCollapsibleSectionKeyFromTitle( + title: string, +): AnilistCharacterDictionaryCollapsibleSectionKey | null { + if (title === 'Description') return 'description'; + if (title === 'Character Information') return 'characterInformation'; + if (title === 'Voiced by') return 'voicedBy'; + return null; +} + +function applyCollapsibleOpenStatesToStructuredValue( + value: unknown, + getCollapsibleSectionOpenState: ( + section: AnilistCharacterDictionaryCollapsibleSectionKey, + ) => boolean, +): unknown { + if (Array.isArray(value)) { + return value.map((item) => + applyCollapsibleOpenStatesToStructuredValue(item, getCollapsibleSectionOpenState), + ); + } + if (!value || typeof value !== 'object') { + return value; + } + + const record = value as Record; + const next: Record = {}; + for (const [key, child] of Object.entries(record)) { + next[key] = applyCollapsibleOpenStatesToStructuredValue(child, getCollapsibleSectionOpenState); + } + + if (record.tag === 'details') { + const content = Array.isArray(record.content) ? record.content : []; + const summary = content[0]; + if (summary && typeof summary === 'object' && !Array.isArray(summary)) { + const summaryContent = (summary as Record).content; + if (typeof summaryContent === 'string') { + const section = getCollapsibleSectionKeyFromTitle(summaryContent); + if (section) { + next.open = getCollapsibleSectionOpenState(section); + } + } + } + } + + return next; +} + +function applyCollapsibleOpenStatesToTermEntries( + termEntries: CharacterDictionaryTermEntry[], + getCollapsibleSectionOpenState: ( + section: AnilistCharacterDictionaryCollapsibleSectionKey, + ) => boolean, +): CharacterDictionaryTermEntry[] { + return termEntries.map((entry) => { + const glossary = entry[5].map((item) => + applyCollapsibleOpenStatesToStructuredValue(item, getCollapsibleSectionOpenState), + ) as CharacterDictionaryGlossaryEntry[]; + return [...entry.slice(0, 5), glossary, ...entry.slice(6)] as CharacterDictionaryTermEntry; + }); +} + function buildDictionaryZip( outputPath: string, dictionaryTitle: string, @@ -1444,6 +1564,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar } { const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const sleepMs = deps.sleep ?? sleep; + const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false); const resolveCurrentMedia = async ( targetPath?: string, @@ -1557,6 +1678,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar imagesByCharacterId, imagesByVaId, deps.now(), + getCollapsibleSectionOpenState, ); writeSnapshot(snapshotPath, snapshot); deps.logInfo?.( @@ -1589,7 +1711,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar const normalizedMediaIds = mediaIds .filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0) .map((mediaId) => Math.floor(mediaId)); - const snapshots = normalizedMediaIds.map((mediaId) => { + const snapshotResults = await Promise.all( + normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)), + ); + const snapshots = snapshotResults.map(({ mediaId }) => { const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId)); if (!snapshot) { throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`); @@ -1606,7 +1731,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar CHARACTER_DICTIONARY_MERGED_TITLE, description, revision, - snapshots.flatMap((snapshot) => snapshot.termEntries), + applyCollapsibleOpenStatesToTermEntries( + snapshots.flatMap((snapshot) => snapshot.termEntries), + getCollapsibleSectionOpenState, + ), snapshots.flatMap((snapshot) => snapshot.images), ); deps.logInfo?.( @@ -1651,7 +1779,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar dictionaryTitle, description, revision, - storedSnapshot.termEntries, + applyCollapsibleOpenStatesToTermEntries( + storedSnapshot.termEntries, + getCollapsibleSectionOpenState, + ), storedSnapshot.images, ); deps.logInfo?.(