fix: index AniList character aliases in dictionary

This commit is contained in:
2026-03-06 22:02:04 -08:00
parent 1fd3f0575b
commit 78cd99a2d0
3 changed files with 950 additions and 39 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -220,8 +220,9 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
(c as { tag?: string }).tag === 'details' && (c as { tag?: string }).tag === 'details' &&
Array.isArray((c as { content?: unknown[] }).content) && Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description', (c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description',
) as { tag: string; content: Array<Record<string, unknown>> } | undefined; ) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | undefined;
assert.ok(descSection, 'expected Description collapsible section'); assert.ok(descSection, 'expected Description collapsible section');
assert.equal(descSection.open, false);
const descBody = descSection.content[1] as { content: string }; const descBody = descSection.content[1] as { content: string };
assert.ok( assert.ok(
descBody.content.includes('Alexia Midgar is the second princess of the Kingdom of Midgar.'), 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) && Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content === (c as { content: Array<{ content?: string }> }).content[0]?.content ===
'Character Information', 'Character Information',
) as { tag: string; content: Array<Record<string, unknown>> } | undefined; ) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | undefined;
assert.ok( assert.ok(
infoSection, infoSection,
'expected Character Information collapsible section with parsed __Race:__ field', 'expected Character Information collapsible section with parsed __Race:__ field',
); );
assert.equal(infoSection.open, false);
const topLevelImageGlossaryEntry = glossary.find( const topLevelImageGlossaryEntry = glossary.find(
(item) => (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<string | Record<string, unknown>>,
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<Record<string, unknown>> };
};
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<string | Record<string, unknown>>,
number,
string,
]
>;
const alexia = termBank.find(([term]) => term === 'アレクシア');
assert.ok(alexia);
const children = (
alexia[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).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 () => { test('generateForCurrentMedia adds kana aliases for romanized names when native name is kanji', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; 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<string | Record<string, unknown>>,
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 () => { test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; 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<string, unknown>;
};
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<string | Record<string, unknown>>,
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<string, unknown>;
};
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 Journeys 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 Journeys 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 Journeys 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<string | Record<string, unknown>>,
number,
string,
]
>;
const alpha = termBank.find(([term]) => term === 'アルファ');
assert.ok(alpha);
const children = (
alpha[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).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 () => { test('generateForCurrentMedia paces AniList requests and character image downloads', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;

View File

@@ -3,6 +3,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater'; import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../types';
import { hasVideoExtension } from '../shared/video-extensions'; import { hasVideoExtension } from '../shared/video-extensions';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -54,7 +55,7 @@ export type CharacterDictionarySnapshot = {
images: CharacterDictionarySnapshotImage[]; images: CharacterDictionarySnapshotImage[];
}; };
const CHARACTER_DICTIONARY_FORMAT_VERSION = 12; const CHARACTER_DICTIONARY_FORMAT_VERSION = 14;
const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary'; const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
type AniListSearchResponse = { type AniListSearchResponse = {
@@ -105,6 +106,7 @@ type AniListCharacterPageResponse = {
name?: { name?: {
full?: string | null; full?: string | null;
native?: string | null; native?: string | null;
alternative?: Array<string | null> | null;
} | null; } | null;
} | null; } | null;
} | null>; } | null>;
@@ -124,6 +126,7 @@ type CharacterRecord = {
role: CharacterDictionaryRole; role: CharacterDictionaryRole;
fullName: string; fullName: string;
nativeName: string; nativeName: string;
alternativeNames: string[];
description: string; description: string;
imageUrl: string | null; imageUrl: string | null;
voiceActors: VoiceActorRecord[]; voiceActors: VoiceActorRecord[];
@@ -178,6 +181,9 @@ export interface CharacterDictionaryRuntimeDeps {
sleep?: (ms: number) => Promise<void>; sleep?: (ms: number) => Promise<void>;
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
logWarn?: (message: string) => void; logWarn?: (message: string) => void;
getCollapsibleSectionOpenState?: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean;
} }
type ResolvedAniListMedia = { type ResolvedAniListMedia = {
@@ -423,6 +429,7 @@ const ROMANIZED_KANA_MONOGRAPHS: ReadonlyArray<[string, string]> = [
['re', 'レ'], ['re', 'レ'],
['ro', 'ロ'], ['ro', 'ロ'],
['wa', 'ワ'], ['wa', 'ワ'],
['w', 'ウ'],
['wo', 'ヲ'], ['wo', 'ヲ'],
['n', 'ン'], ['n', 'ン'],
]; ];
@@ -490,37 +497,57 @@ function addRomanizedKanaAliases(values: Iterable<string>): string[] {
return [...aliases]; return [...aliases];
} }
function expandRawNameVariants(rawName: string): string[] {
const trimmed = rawName.trim();
if (!trimmed) return [];
const variants = new Set<string>([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[] { function buildNameTerms(character: CharacterRecord): string[] {
const base = new Set<string>(); const base = new Set<string>();
const rawNames = [character.nativeName, character.fullName]; const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
for (const rawName of rawNames) { for (const rawName of rawNames) {
const name = rawName.trim(); for (const name of expandRawNameVariants(rawName)) {
if (!name) continue; base.add(name);
base.add(name);
const compact = name.replace(/[\s\u3000]+/g, ''); const compact = name.replace(/[\s\u3000]+/g, '');
if (compact && compact !== name) { if (compact && compact !== name) {
base.add(compact); base.add(compact);
} }
const noMiddleDots = compact.replace(/[・・·•]/g, ''); const noMiddleDots = compact.replace(/[・・·•]/g, '');
if (noMiddleDots && noMiddleDots !== compact) { if (noMiddleDots && noMiddleDots !== compact) {
base.add(noMiddleDots); base.add(noMiddleDots);
} }
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0); const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) { if (split.length === 2) {
base.add(split[0]!); base.add(split[0]!);
base.add(split[1]!); base.add(split[1]!);
} }
const splitByMiddleDot = name const splitByMiddleDot = name
.split(/[・・·•]/) .split(/[・・·•]/)
.map((part) => part.trim()) .map((part) => part.trim())
.filter((part) => part.length > 0); .filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) { if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) { for (const part of splitByMiddleDot) {
base.add(part); base.add(part);
}
} }
} }
} }
@@ -758,11 +785,12 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
function buildCollapsibleSection( function buildCollapsibleSection(
title: string, title: string,
open: boolean,
body: Array<string | Record<string, unknown>> | string | Record<string, unknown>, body: Array<string | Record<string, unknown>> | string | Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
tag: 'details', tag: 'details',
open: true, open,
style: { marginTop: '0.4em' }, style: { marginTop: '0.4em' },
content: [ content: [
{ {
@@ -849,6 +877,9 @@ function createDefinitionGlossary(
mediaTitle: string, mediaTitle: string,
imagePath: string | null, imagePath: string | null,
vaImagePaths: Map<number, string>, vaImagePaths: Map<number, string>,
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionaryGlossaryEntry[] { ): CharacterDictionaryGlossaryEntry[] {
const displayName = character.nativeName || character.fullName || `Character ${character.id}`; const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
const secondaryName = const secondaryName =
@@ -910,7 +941,13 @@ function createDefinitionGlossary(
}); });
if (descriptionText) { if (descriptionText) {
content.push(buildCollapsibleSection('Description', descriptionText)); content.push(
buildCollapsibleSection(
'Description',
getCollapsibleSectionOpenState('description'),
descriptionText,
),
);
} }
if (fields.length > 0) { if (fields.length > 0) {
@@ -919,11 +956,15 @@ function createDefinitionGlossary(
content: `${f.key}: ${f.value}`, content: `${f.key}: ${f.value}`,
})); }));
content.push( content.push(
buildCollapsibleSection('Character Information', { buildCollapsibleSection(
tag: 'ul', 'Character Information',
style: { marginTop: '0.15em' }, getCollapsibleSectionOpenState('characterInformation'),
content: fieldItems, {
}), tag: 'ul',
style: { marginTop: '0.15em' },
content: fieldItems,
},
),
); );
} }
@@ -931,6 +972,7 @@ function createDefinitionGlossary(
content.push( content.push(
buildCollapsibleSection( buildCollapsibleSection(
'Voiced by', 'Voiced by',
getCollapsibleSectionOpenState('voicedBy'),
buildVoicedByContent(character.voiceActors, vaImagePaths), buildVoicedByContent(character.voiceActors, vaImagePaths),
), ),
); );
@@ -1210,6 +1252,7 @@ async function fetchCharactersForMedia(
name { name {
full full
native native
alternative
} }
} }
} }
@@ -1243,7 +1286,13 @@ async function fetchCharactersForMedia(
if (!node || typeof node.id !== 'number') continue; if (!node || typeof node.id !== 'number') continue;
const fullName = node.name?.full?.trim() || ''; const fullName = node.name?.full?.trim() || '';
const nativeName = node.name?.native?.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[] = []; const voiceActors: VoiceActorRecord[] = [];
for (const va of edge?.voiceActors ?? []) { for (const va of edge?.voiceActors ?? []) {
if (!va || typeof va.id !== 'number') continue; if (!va || typeof va.id !== 'number') continue;
@@ -1262,6 +1311,7 @@ async function fetchCharactersForMedia(
role: mapRole(edge?.role), role: mapRole(edge?.role),
fullName, fullName,
nativeName, nativeName,
alternativeNames,
description: node.description || '', description: node.description || '',
imageUrl: node.image?.large || node.image?.medium || null, imageUrl: node.image?.large || node.image?.medium || null,
voiceActors, voiceActors,
@@ -1340,6 +1390,9 @@ function buildSnapshotFromCharacters(
imagesByCharacterId: Map<number, CharacterDictionarySnapshotImage>, imagesByCharacterId: Map<number, CharacterDictionarySnapshotImage>,
imagesByVaId: Map<number, CharacterDictionarySnapshotImage>, imagesByVaId: Map<number, CharacterDictionarySnapshotImage>,
updatedAt: number, updatedAt: number,
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionarySnapshot { ): CharacterDictionarySnapshot {
const termEntries: CharacterDictionaryTermEntry[] = []; const termEntries: CharacterDictionaryTermEntry[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -1351,7 +1404,13 @@ function buildSnapshotFromCharacters(
const vaImg = imagesByVaId.get(va.id); const vaImg = imagesByVaId.get(va.id);
if (vaImg) vaImagePaths.set(va.id, vaImg.path); 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); const candidateTerms = buildNameTerms(character);
for (const term of candidateTerms) { for (const term of candidateTerms) {
const reading = buildReading(term); 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<string, unknown>;
const next: Record<string, unknown> = {};
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<string, unknown>).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( function buildDictionaryZip(
outputPath: string, outputPath: string,
dictionaryTitle: string, dictionaryTitle: string,
@@ -1444,6 +1564,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} { } {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries'); const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const sleepMs = deps.sleep ?? sleep; const sleepMs = deps.sleep ?? sleep;
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
const resolveCurrentMedia = async ( const resolveCurrentMedia = async (
targetPath?: string, targetPath?: string,
@@ -1557,6 +1678,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
imagesByCharacterId, imagesByCharacterId,
imagesByVaId, imagesByVaId,
deps.now(), deps.now(),
getCollapsibleSectionOpenState,
); );
writeSnapshot(snapshotPath, snapshot); writeSnapshot(snapshotPath, snapshot);
deps.logInfo?.( deps.logInfo?.(
@@ -1589,7 +1711,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const normalizedMediaIds = mediaIds const normalizedMediaIds = mediaIds
.filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0) .filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0)
.map((mediaId) => Math.floor(mediaId)); .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)); const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId));
if (!snapshot) { if (!snapshot) {
throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`); throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`);
@@ -1606,7 +1731,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
CHARACTER_DICTIONARY_MERGED_TITLE, CHARACTER_DICTIONARY_MERGED_TITLE,
description, description,
revision, revision,
snapshots.flatMap((snapshot) => snapshot.termEntries), applyCollapsibleOpenStatesToTermEntries(
snapshots.flatMap((snapshot) => snapshot.termEntries),
getCollapsibleSectionOpenState,
),
snapshots.flatMap((snapshot) => snapshot.images), snapshots.flatMap((snapshot) => snapshot.images),
); );
deps.logInfo?.( deps.logInfo?.(
@@ -1651,7 +1779,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
dictionaryTitle, dictionaryTitle,
description, description,
revision, revision,
storedSnapshot.termEntries, applyCollapsibleOpenStatesToTermEntries(
storedSnapshot.termEntries,
getCollapsibleSectionOpenState,
),
storedSnapshot.images, storedSnapshot.images,
); );
deps.logInfo?.( deps.logInfo?.(