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' &&
|
(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 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 () => {
|
test('generateForCurrentMedia paces AniList requests and character image downloads', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -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?.(
|
||||||
|
|||||||
Reference in New Issue
Block a user