mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-07 03:22:17 -08:00
fix: index AniList character aliases in dictionary
This commit is contained in:
@@ -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 -->
|
||||
@@ -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<Record<string, unknown>> } | undefined;
|
||||
) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | 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<Record<string, unknown>> } | undefined;
|
||||
) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | 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<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 () => {
|
||||
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<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 () => {
|
||||
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<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 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<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 () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -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<string | null> | 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<void>;
|
||||
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,12 +497,31 @@ function addRomanizedKanaAliases(values: Iterable<string>): string[] {
|
||||
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[] {
|
||||
const base = new Set<string>();
|
||||
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;
|
||||
for (const name of expandRawNameVariants(rawName)) {
|
||||
base.add(name);
|
||||
|
||||
const compact = name.replace(/[\s\u3000]+/g, '');
|
||||
@@ -524,6 +550,7 @@ function buildNameTerms(character: CharacterRecord): string[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const withHonorifics = new Set<string>();
|
||||
for (const entry of base) {
|
||||
@@ -758,11 +785,12 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
|
||||
|
||||
function buildCollapsibleSection(
|
||||
title: string,
|
||||
open: boolean,
|
||||
body: Array<string | Record<string, unknown>> | string | Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
tag: 'details',
|
||||
open: true,
|
||||
open,
|
||||
style: { marginTop: '0.4em' },
|
||||
content: [
|
||||
{
|
||||
@@ -849,6 +877,9 @@ function createDefinitionGlossary(
|
||||
mediaTitle: string,
|
||||
imagePath: string | null,
|
||||
vaImagePaths: Map<number, string>,
|
||||
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', {
|
||||
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<number, CharacterDictionarySnapshotImage>,
|
||||
imagesByVaId: Map<number, CharacterDictionarySnapshotImage>,
|
||||
updatedAt: number,
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean,
|
||||
): CharacterDictionarySnapshot {
|
||||
const termEntries: CharacterDictionaryTermEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -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<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(
|
||||
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,
|
||||
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,
|
||||
applyCollapsibleOpenStatesToTermEntries(
|
||||
storedSnapshot.termEntries,
|
||||
getCollapsibleSectionOpenState,
|
||||
),
|
||||
storedSnapshot.images,
|
||||
);
|
||||
deps.logInfo?.(
|
||||
|
||||
Reference in New Issue
Block a user