Add inline character portraits and dictionary search workflow (#83)

This commit is contained in:
2026-05-25 03:16:25 -07:00
committed by GitHub
parent 7e6f9672cf
commit 807c0ff3db
54 changed files with 2306 additions and 178 deletions
+31 -8
View File
@@ -195,22 +195,45 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
assert.equal(nameDiv.tag, 'div');
assert.equal(nameDiv.content, 'アレクシア・ミドガル');
const secondaryNameDiv = children[1] as { tag: string; content: string };
assert.equal(secondaryNameDiv.tag, 'div');
assert.equal(secondaryNameDiv.content, 'Alexia Midgar');
assert.equal(
children.some((child) => (child as { content?: unknown }).content === 'Alexia Midgar'),
false,
);
const imageWrap = children[2] as { tag: string; content: Record<string, unknown> };
const imageWrap = children.find((child) => {
const content = (child as { content?: unknown }).content;
return (
content &&
typeof content === 'object' &&
!Array.isArray(content) &&
(content as { path?: unknown }).path === 'img/m130298-c123.png'
);
}) as { tag: string; content: Record<string, unknown> } | undefined;
assert.ok(imageWrap);
assert.equal(imageWrap.tag, 'div');
const image = imageWrap.content as Record<string, unknown>;
assert.equal(image.tag, 'img');
assert.equal(image.path, 'img/m130298-c123.png');
assert.equal(image.sizeUnits, 'em');
const sourceDiv = children[3] as { tag: string; content: string };
const sourceDiv = children.find((child) => {
const content = (child as { content?: unknown }).content;
return typeof content === 'string' && content.includes('The Eminence in Shadow');
}) as { tag: string; content: string } | undefined;
assert.ok(sourceDiv);
assert.equal(sourceDiv.tag, 'div');
assert.ok(sourceDiv.content.includes('The Eminence in Shadow'));
const roleBadgeDiv = children[4] as { tag: string; content: Record<string, unknown> };
const roleBadgeDiv = children.find((child) => {
const content = (child as { content?: unknown }).content;
return (
content &&
typeof content === 'object' &&
!Array.isArray(content) &&
(content as { content?: unknown }).content === 'Main Character'
);
}) as { tag: string; content: Record<string, unknown> } | undefined;
assert.ok(roleBadgeDiv);
assert.equal(roleBadgeDiv.tag, 'div');
const badge = roleBadgeDiv.content as { tag: string; content: string };
assert.equal(badge.tag, 'span');
@@ -1882,9 +1905,9 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
'[dictionary] snapshot miss for AniList 130298, fetching characters',
'[dictionary] downloaded AniList character page 1 for AniList 130298',
'[dictionary] downloading 1 images for AniList 130298',
'[dictionary] stored snapshot for AniList 130298: 32 terms',
'[dictionary] stored snapshot for AniList 130298: 16 terms',
'[dictionary] building ZIP for AniList 130298',
'[dictionary] generated AniList 130298: 32 terms -> ' +
'[dictionary] generated AniList 130298: 16 terms -> ' +
path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'),
]);
} finally {
+53 -17
View File
@@ -37,6 +37,7 @@ import {
buildCharacterDictionarySeriesKey,
createCharacterDictionaryManualSelectionStore,
} from './character-dictionary-runtime/manual-selection';
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
import type {
AniListMediaCandidate,
CharacterDictionaryBuildResult,
@@ -151,6 +152,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
getManualSelectionSnapshot: (
targetPath?: string,
searchTitle?: string,
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
setManualSelection: (request: {
targetPath?: string;
@@ -168,6 +170,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
userDataPath: deps.userDataPath,
});
const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => {
if (deps.getNameMatchImagesEnabled?.() !== true) {
return false;
}
return !snapshotHasCharacterNameImages(snapshot);
};
const createAniListRequestSlot = (): (() => Promise<void>) => {
let hasAniListRequest = false;
return async (): Promise<void> => {
@@ -205,12 +214,19 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
mediaTitle: guessInput.mediaTitle,
guess: guessed,
}),
unscopedSeriesKey: buildCharacterDictionarySeriesKey({
mediaPath: null,
mediaTitle: guessInput.mediaTitle,
guess: guessed,
}),
};
};
const findCachedSnapshotForSeriesKey = (
seriesKey: string,
fallbackSeriesKey?: string,
): CharacterDictionarySnapshot | null => {
const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean));
return (
readCachedSnapshots(outputDir).find((snapshot) => {
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
@@ -223,7 +239,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
source: 'fallback',
},
});
return snapshotSeriesKey === seriesKey;
return acceptedKeys.has(snapshotSeriesKey);
}) ?? null
);
};
@@ -233,7 +249,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
beforeRequest?: () => Promise<void>,
): Promise<ResolvedAniListMedia> => {
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
const { guessed, seriesKey, unscopedSeriesKey } = await guessCurrentMedia(targetPath);
deps.logInfo?.(
`[dictionary] current anime guess: ${guessed.title.trim()}${
typeof guessed.episode === 'number' && guessed.episode > 0
@@ -267,7 +283,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}
}
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
if (cachedSnapshot) {
writeCachedMediaResolution(outputDir, {
seriesKey,
@@ -301,7 +317,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
): Promise<CharacterDictionarySnapshotResult> => {
const snapshotPath = getSnapshotPath(outputDir, mediaId);
const cachedSnapshot = readSnapshot(snapshotPath);
if (cachedSnapshot) {
if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) {
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
return {
mediaId: cachedSnapshot.mediaId,
@@ -311,6 +327,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
updatedAt: cachedSnapshot.updatedAt,
};
}
if (cachedSnapshot) {
deps.logInfo?.(
`[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`,
);
}
progress?.onGenerating?.({
mediaId,
@@ -455,28 +476,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
entryCount,
};
},
getManualSelectionSnapshot: async (targetPath?: string) => {
getManualSelectionSnapshot: async (targetPath?: string, searchTitle?: string) => {
const waitForAniListRequestSlot = createAniListRequestSlot();
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
const [candidates, override] = await Promise.all([
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
const normalizedSearchTitle = searchTitle?.trim();
const shouldUseExplicitSearch = searchTitle !== undefined;
const candidateSearchTitle = shouldUseExplicitSearch ? normalizedSearchTitle : guessed.title;
const candidates = candidateSearchTitle
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
: [];
const [override, current] = await Promise.all([
manualSelectionStore.getOverride(seriesKey),
shouldUseExplicitSearch
? Promise.resolve(null)
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
.then(
(entry): AniListMediaCandidate => ({
id: entry.id,
title: entry.title,
episodes:
candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
}),
)
.catch(() => null),
]);
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
.then(
(entry): AniListMediaCandidate => ({
id: entry.id,
title: entry.title,
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
}),
)
.catch(() => null);
const overrideCandidate = override
? candidates.find((candidate) => candidate.id === override.mediaId)
: null;
return {
seriesKey,
guessTitle: guessed.title,
current,
override: override
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
? {
id: override.mediaId,
title: override.mediaTitle,
episodes: overrideCandidate?.episodes ?? null,
}
: null,
candidates,
};
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyCollapsibleOpenStatesToTermEntries } from './build';
import type { CharacterDictionaryTermEntry } from './types';
import { applyCollapsibleOpenStatesToTermEntries, buildSnapshotFromCharacters } from './build';
import type { CharacterDictionaryTermEntry, CharacterRecord } from './types';
test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open states', () => {
const termEntries: CharacterDictionaryTermEntry[] = [
@@ -56,3 +56,66 @@ test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open
assert.equal(glossaryEntry.content.content[0]?.open, true);
assert.equal(glossaryEntry.content.content[1]?.open, false);
});
test('buildSnapshotFromCharacters shows Japanese aliases without adding romanized names as lookup entries', () => {
const character: CharacterRecord = {
id: 1,
role: 'main',
firstNameHint: '',
fullName: 'Aqua',
lastNameHint: '',
nativeName: 'アクア',
alternativeNames: ['阿久亜'],
bloodType: '',
birthday: null,
description: '',
imageUrl: null,
age: '',
sex: '',
voiceActors: [],
};
const snapshot = buildSnapshotFromCharacters(
100,
'KonoSuba',
[character],
new Map(),
new Map(),
1_700_000_000_000,
() => false,
);
const aquaEntry = snapshot.termEntries.find(([term]) => term === 'アクア');
assert.ok(aquaEntry);
const glossaryEntry = aquaEntry[5][0] as {
content: {
content: Array<{ content?: unknown }>;
};
};
const wholeGlossary = JSON.stringify(glossaryEntry);
const knownNames = glossaryEntry.content.content.find((node) => {
const content = node.content;
return (
Array.isArray(content) &&
content.some(
(child) =>
child &&
typeof child === 'object' &&
(child as { content?: unknown }).content === 'Known names',
)
);
}) as { content: Array<{ content?: unknown }> } | undefined;
assert.ok(knownNames, 'expected a Known names block in the character glossary');
const knownNameItems = JSON.stringify(knownNames.content);
const terms = snapshot.termEntries.map(([term]) => term);
assert.match(knownNameItems, /アクア/);
assert.match(knownNameItems, /阿久亜/);
assert.doesNotMatch(wholeGlossary, /Aqua/);
assert.doesNotMatch(knownNameItems, /Aqua/);
assert.doesNotMatch(knownNameItems, /アクア様/);
assert.equal(terms.includes('Aqua'), false);
assert.equal(terms.includes('アクア'), true);
assert.equal(terms.includes('阿久亜'), true);
});
@@ -52,3 +52,18 @@ test('readSnapshot ignores snapshots written with an older format version', () =
assert.equal(readSnapshot(snapshotPath), null);
});
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', () => {
const outputDir = makeTempDir();
const snapshotPath = getSnapshotPath(outputDir, 130298);
const staleSnapshot = {
...createSnapshot(),
formatVersion: 15,
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
};
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
assert.equal(readSnapshot(snapshotPath), null);
});
@@ -1,7 +1,7 @@
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export const ANILIST_REQUEST_DELAY_MS = 2000;
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 15;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
export const HONORIFIC_SUFFIXES = [
+42 -2
View File
@@ -191,11 +191,51 @@ function mapRole(input: string | null | undefined): CharacterDictionaryRole {
return 'side';
}
function inferImageExt(contentType: string | null): string {
function inferImageExtFromBytes(bytes: Buffer): string | null {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
return 'png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'jpg';
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF89a') {
return 'gif';
}
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF87a') {
return 'gif';
}
if (
bytes.length >= 12 &&
bytes.subarray(4, 8).toString('ascii') === 'ftyp' &&
bytes.subarray(8, 12).toString('ascii') === 'avif'
) {
return 'avif';
}
return null;
}
function inferImageExt(contentType: string | null, bytes: Buffer): string {
const extFromBytes = inferImageExtFromBytes(bytes);
if (extFromBytes) return extFromBytes;
const normalized = (contentType || '').toLowerCase();
if (normalized.includes('png')) return 'png';
if (normalized.includes('gif')) return 'gif';
if (normalized.includes('webp')) return 'webp';
if (normalized.includes('avif')) return 'avif';
return 'jpg';
}
@@ -462,7 +502,7 @@ export async function downloadCharacterImage(
if (!response.ok) return null;
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) return null;
const ext = inferImageExt(response.headers.get('content-type'));
const ext = inferImageExt(response.headers.get('content-type'), bytes);
return {
filename: `c${charId}.${ext}`,
ext,
@@ -117,20 +117,44 @@ function buildVoicedByContent(
return { tag: 'ul', style: { marginTop: '0.15em' }, content: items };
}
function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | null {
const visibleTerms = [...new Set(nameTerms.map((term) => term.trim()).filter(Boolean))];
if (visibleTerms.length <= 1) {
return null;
}
return {
tag: 'div',
style: { fontSize: '0.85em', marginBottom: '0.25em' },
content: [
{
tag: 'div',
style: { fontWeight: 'bold', color: '#d0d0d0', marginBottom: '0.1em' },
content: 'Known names',
},
{
tag: 'ul',
style: { marginTop: '0', marginBottom: '0', paddingLeft: '1.2em' },
content: visibleTerms.map((term) => ({
tag: 'li',
content: term,
})),
},
],
};
}
export function createDefinitionGlossary(
character: CharacterRecord,
mediaTitle: string,
imagePath: string | null,
vaImagePaths: Map<number, string>,
nameTerms: string[],
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionaryGlossaryEntry[] {
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
const secondaryName =
character.nativeName && character.fullName && character.fullName !== character.nativeName
? character.fullName
: null;
const { fields, text: descriptionText } = parseCharacterDescription(character.description);
const content: Array<string | Record<string, unknown>> = [
@@ -141,12 +165,9 @@ export function createDefinitionGlossary(
},
];
if (secondaryName) {
content.push({
tag: 'div',
style: { fontSize: '0.85em', fontStyle: 'italic', color: '#b0b0b0', marginBottom: '0.2em' },
content: secondaryName,
});
const knownNamesBlock = buildKnownNamesBlock(nameTerms);
if (knownNamesBlock) {
content.push(knownNamesBlock);
}
if (imagePath) {
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
import type { CharacterDictionarySnapshot } from './types';
const PNG_1X1_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-'));
}
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'アレクシア',
'あれくしあ',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'div',
content: [
{ tag: 'div', content: 'アレクシア・ミドガル' },
{
tag: 'div',
content: {
tag: 'img',
path: 'img/m130298-c123.png',
alt: 'アレクシア・ミドガル',
},
},
{
tag: 'details',
content: [
{ tag: 'summary', content: 'Voiced by' },
{
tag: 'div',
content: {
tag: 'img',
path: 'img/m130298-va456.png',
alt: 'VA',
},
},
],
},
],
},
},
],
0,
'',
],
],
images: [
{ path: 'img/m130298-c123.png', dataBase64: 'AAAA' },
{ path: 'img/m130298-va456.png', dataBase64: 'BBBB' },
],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
assert.deepEqual(index.get('アレクシア'), {
src: 'data:image/png;base64,AAAA',
alt: 'アレクシア・ミドガル',
});
});
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'アレクシア',
'あれくしあ',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m130298-c123.jpg',
alt: 'アレクシア・ミドガル',
},
},
],
0,
'',
],
],
images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
});
@@ -0,0 +1,249 @@
import * as fs from 'fs';
import * as path from 'path';
import type { CharacterNameImage } from '../../types';
import { readCachedSnapshots } from './cache';
import type {
CharacterDictionaryGlossaryEntry,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage,
CharacterDictionaryTermEntry,
} from './types';
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
type StructuredContentNode = {
tag?: unknown;
path?: unknown;
alt?: unknown;
title?: unknown;
content?: unknown;
};
function normalizeLookupTerm(term: string): string {
return term.trim();
}
function getSnapshotsDir(outputDir: string): string {
return path.join(outputDir, 'snapshots');
}
function getImageMimeType(imagePath: string, dataBase64: string): string {
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
if (
signature.length >= 8 &&
signature[0] === 0x89 &&
signature[1] === 0x50 &&
signature[2] === 0x4e &&
signature[3] === 0x47
) {
return 'image/png';
}
if (
signature.length >= 12 &&
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
signature.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp';
}
if (
signature.length >= 6 &&
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
) {
return 'image/gif';
}
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
return 'image/jpeg';
}
if (
signature.length >= 12 &&
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
signature.subarray(8, 12).toString('ascii') === 'avif'
) {
return 'image/avif';
}
const ext = path.extname(imagePath).toLowerCase();
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
if (ext === '.avif') return 'image/avif';
return 'image/jpeg';
}
function buildImageByPath(
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
): Map<string, CharacterDictionarySnapshotImage> {
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
for (const image of images) {
if (image.path && image.dataBase64) {
imageByPath.set(image.path, image);
}
}
return imageByPath;
}
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
if (Array.isArray(value)) {
for (const item of value) {
const found = findCharacterImageNode(item);
if (found) return found;
}
return null;
}
if (!value || typeof value !== 'object') {
return null;
}
const node = value as StructuredContentNode;
if (
node.tag === 'img' &&
typeof node.path === 'string' &&
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
) {
return node;
}
return findCharacterImageNode(node.content);
}
function findCharacterImageNodeInGlossary(
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
): StructuredContentNode | null {
for (const entry of glossary) {
const found = findCharacterImageNode(entry);
if (found) return found;
}
return null;
}
function createCharacterNameImage(
entry: CharacterDictionaryTermEntry,
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
): CharacterNameImage | null {
const term = normalizeLookupTerm(entry[0]);
if (!term) {
return null;
}
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
const image = imageByPath.get(imagePath);
if (!image) {
return null;
}
const rawAlt =
typeof imageNode?.alt === 'string'
? imageNode.alt
: typeof imageNode?.title === 'string'
? imageNode.title
: term;
const alt = rawAlt.trim() || term;
return {
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
alt,
};
}
function appendSnapshotImages(
index: Map<string, CharacterNameImage>,
snapshot: CharacterDictionarySnapshot,
): void {
const imageByPath = buildImageByPath(snapshot.images);
for (const entry of snapshot.termEntries) {
const term = normalizeLookupTerm(entry[0]);
if (!term || index.has(term)) {
continue;
}
const image = createCharacterNameImage(entry, imageByPath);
if (image) {
index.set(term, image);
}
}
}
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
const imageByPath = buildImageByPath(snapshot.images);
return snapshot.termEntries.some(
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
);
}
function getSnapshotDirectorySignature(outputDir: string): string {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return '';
}
const parts: string[] = [];
for (const entry of entries) {
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
continue;
}
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
try {
const stat = fs.statSync(snapshotPath);
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
} catch {
// Ignore files that disappear during refresh; next lookup will rebuild.
}
}
return parts.sort().join('|');
}
export function buildCharacterNameImageIndexFromSnapshots(
outputDir: string,
): Map<string, CharacterNameImage> {
const index = new Map<string, CharacterNameImage>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
}
return index;
}
export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
}): {
get: (term: string) => CharacterNameImage | null;
invalidate: () => void;
} {
const outputDir =
deps.outputDir ??
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
let signature: string | null = null;
let index = new Map<string, CharacterNameImage>();
function refreshIfNeeded(): void {
if (!outputDir) {
index = new Map<string, CharacterNameImage>();
signature = '';
return;
}
const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature) {
return;
}
signature = nextSignature;
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
}
return {
get(term: string): CharacterNameImage | null {
const normalizedTerm = normalizeLookupTerm(term);
if (!normalizedTerm) {
return null;
}
refreshIfNeeded();
return index.get(normalizedTerm) ?? null;
},
invalidate(): void {
signature = null;
},
};
}
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { buildCharacterDictionarySeriesKey } from './manual-selection';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
}
test('getManualSelectionSnapshot waits for explicit search text before fetching candidates', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}')) as {
variables?: { search?: string };
};
searchTerms.push(String(body.variables?.search ?? ''));
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 154587,
episodes: 28,
title: {
romaji: 'Sousou no Frieren',
english: 'Frieren: Beyond Journeys End',
native: '葬送のフリーレン',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
getCurrentMediaTitle: () => '[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'Kage no Jitsuryokusha ni Naritakute!',
season: null,
episode: 5,
source: 'guessit',
}),
now: () => 1_700_000_000_000,
});
const initial = await runtime.getManualSelectionSnapshot(undefined, '');
assert.equal(initial.guessTitle, 'Kage no Jitsuryokusha ni Naritakute!');
assert.deepEqual(initial.candidates, []);
assert.deepEqual(searchTerms, []);
const searched = await runtime.getManualSelectionSnapshot(undefined, 'Frieren');
assert.deepEqual(searchTerms, ['Frieren']);
assert.deepEqual(searched.candidates, [
{ id: 154587, title: 'Frieren: Beyond Journeys End', episodes: 28 },
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('getManualSelectionSnapshot hydrates override episode count from searched candidates', async () => {
const userDataPath = makeTempDir();
const overrideSeriesKey = buildCharacterDictionarySeriesKey({
mediaPath: '/tmp/KonoSuba - 01.mkv',
mediaTitle: 'KonoSuba - 01.mkv',
guess: {
title: "KonoSuba - God's blessing on this wonderful world!",
year: 2016,
season: null,
episode: 1,
source: 'guessit',
},
});
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: overrideSeriesKey,
mediaId: 21202,
mediaTitle: "KONOSUBA -God's blessing on this wonderful world!",
staleMediaIds: [],
},
],
}),
'utf8',
);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_input: string | URL | Request) => {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 21202,
episodes: 10,
title: {
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
english: "KONOSUBA -God's blessing on this wonderful world!",
native: 'この素晴らしい世界に祝福を!',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/KonoSuba - 01.mkv',
getCurrentMediaTitle: () => 'KonoSuba - 01.mkv',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: "KonoSuba - God's blessing on this wonderful world!",
year: 2016,
season: null,
episode: 1,
source: 'guessit',
}),
now: () => 1_700_000_000_000,
});
const snapshot = await runtime.getManualSelectionSnapshot(undefined, 'KonoSuba');
assert.deepEqual(snapshot.override, {
id: 21202,
title: "KONOSUBA -God's blessing on this wonderful world!",
episodes: 10,
});
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -10,15 +10,17 @@ import {
} from './manual-selection';
const REZERO_EP1 =
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
const REZERO_EP2 =
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
const REZERO_S2_EP1 =
'/anime/ReZERO/Season 2/Re - ZERO, Starting Life in Another World (2016) - S02E01 - Each Ones Promise [Bluray-1080p][x265][JA]-SCY.mkv';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
}
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
test('buildCharacterDictionarySeriesKey scopes guessit title and year by media directory', () => {
const key = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
@@ -32,10 +34,10 @@ test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, a
},
});
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
assert.equal(key, 'anime-rezero-season-1--re-zero-starting-life-in-another-world-2016');
});
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
test('manual selection store persists overrides and matches later episodes in the same directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const firstKey = buildCharacterDictionarySeriesKey({
@@ -79,3 +81,131 @@ test('manual selection store persists overrides and matches later episodes in th
staleMediaIds: [10607],
});
});
test('manual selection store resolves legacy unscoped override keys', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: 're-zero-starting-life-in-another-world-2016',
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
},
],
}),
'utf8',
);
const scopedKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
assert.deepEqual(await store.getOverride(scopedKey), {
seriesKey: 're-zero-starting-life-in-another-world-2016',
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
});
test('manual selection store prefers exact scoped override over legacy fallback', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
const scopedKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: 're-zero-starting-life-in-another-world-2016',
mediaId: 10607,
mediaTitle: 'Legacy Re:ZERO',
staleMediaIds: [],
},
{
seriesKey: scopedKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
},
],
}),
'utf8',
);
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
assert.deepEqual(await store.getOverride(scopedKey), {
seriesKey: scopedKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
});
test('manual selection store keeps overrides separate for different season directories', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const firstSeasonKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
await store.setOverride({
seriesKey: firstSeasonKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [],
});
const secondSeasonKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_S2_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 2,
episode: 1,
source: 'guessit',
},
});
assert.notEqual(secondSeasonKey, firstSeasonKey);
assert.equal(await store.getOverride(secondSeasonKey), null);
});
@@ -31,6 +31,29 @@ function normalizeSeriesKeyPart(value: string): string {
.toLowerCase();
}
function getMediaDirectoryKey(mediaPath: string | null): string {
const rawPath = mediaPath?.trim();
if (!rawPath) return '';
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(rawPath) || rawPath.startsWith('file:')) {
try {
const url = new URL(rawPath);
const directoryPath = path.posix.dirname(
decodeURIComponent(url.pathname).replace(/\\/g, '/'),
);
const scopedPath = `${url.hostname}${directoryPath === '/' ? '' : directoryPath}`;
return normalizeSeriesKeyPart(scopedPath);
} catch {
return '';
}
}
const normalizedPath = rawPath.replace(/\\/g, '/');
const directoryPath = path.posix.dirname(normalizedPath);
if (!directoryPath || directoryPath === '.') return '';
return normalizeSeriesKeyPart(directoryPath);
}
function dedupeNumbers(values: number[]): number[] {
const seen = new Set<number>();
const result: number[] = [];
@@ -78,6 +101,12 @@ function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSe
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
}
function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
const scopedSeparatorIndex = seriesKey.indexOf('--');
if (scopedSeparatorIndex < 0) return [seriesKey];
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
@@ -94,7 +123,9 @@ export function buildCharacterDictionarySeriesKey(input: {
.replace(/\bepisode\s+\d+\b/gi, ' ')
.trim();
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
return input.guess?.year ? `${base}-${input.guess.year}` : base;
const directoryKey = getMediaDirectoryKey(input.mediaPath);
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
}
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
@@ -102,7 +133,13 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
for (const candidate of candidates) {
const match = overrides.find((entry) => entry.seriesKey === candidate);
if (match) return match;
}
return null;
},
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
const normalized = normalizeOverride(selection);
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types';
const GRAPHQL_URL = 'https://graphql.anilist.co';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
'base64',
);
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
}
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
return {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
images: [],
};
}
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
const originalFetch = globalThis.fetch;
const fetchUrls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
fetchUrls.push(url);
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
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',
node: {
id: 123,
description: 'Alexia Midgar.',
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' },
});
}
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',
season: null,
episode: 5,
source: 'fallback',
}),
getNameMatchImagesEnabled: () => true,
now: () => 1_700_000_000_500,
});
const result = await runtime.generateForCurrentMedia();
const refreshedSnapshot = JSON.parse(
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
) as CharacterDictionarySnapshot;
assert.equal(result.fromCache, false);
assert.ok(fetchUrls.includes(GRAPHQL_URL));
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries');
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
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',
season: null,
episode: 5,
source: 'fallback',
}),
getNameMatchImagesEnabled: () => false,
now: () => 1_700_000_000_500,
});
const result = await runtime.generateForCurrentMedia();
assert.equal(result.fromCache, true);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -2,7 +2,12 @@ import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../../type
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import { createDefinitionGlossary } from './glossary';
import { generateNameReadings, splitJapaneseName } from './name-reading';
import { buildNameTerms, buildReadingForTerm, buildTermEntry } from './term-building';
import {
buildNameTerms,
buildReadingForTerm,
buildTermEntry,
buildVisibleNameTerms,
} from './term-building';
import type {
CharacterDictionaryGlossaryEntry,
CharacterDictionarySnapshot,
@@ -40,14 +45,15 @@ export function buildSnapshotFromCharacters(
const vaImg = imagesByVaId.get(va.id);
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
}
const candidateTerms = buildNameTerms(character);
const glossary = createDefinitionGlossary(
character,
mediaTitle,
imagePath,
vaImagePaths,
buildVisibleNameTerms(candidateTerms),
getCollapsibleSectionOpenState,
);
const candidateTerms = buildNameTerms(character);
const nameParts = splitJapaneseName(
character.nativeName,
character.firstNameHint,
@@ -41,25 +41,27 @@ function expandRawNameVariants(rawName: string): string[] {
export function buildNameTerms(character: CharacterRecord): string[] {
const base = new Set<string>();
const romanizedBase = new Set<string>();
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
for (const rawName of rawNames) {
for (const name of expandRawNameVariants(rawName)) {
base.add(name);
const target = isRomanizedName(name) ? romanizedBase : base;
target.add(name);
const compact = name.replace(/[\s\u3000]+/g, '');
if (compact && compact !== name) {
base.add(compact);
target.add(compact);
}
const noMiddleDots = compact.replace(/[・・·•]/g, '');
if (noMiddleDots && noMiddleDots !== compact) {
base.add(noMiddleDots);
target.add(noMiddleDots);
}
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
base.add(split[0]!);
base.add(split[1]!);
target.add(split[0]!);
target.add(split[1]!);
}
const splitByMiddleDot = name
@@ -68,12 +70,16 @@ export function buildNameTerms(character: CharacterRecord): string[] {
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
base.add(part);
target.add(part);
}
}
}
}
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
base.add(alias);
}
const nativeParts = splitJapaneseName(
character.nativeName,
character.firstNameHint,
@@ -94,16 +100,24 @@ export function buildNameTerms(character: CharacterRecord): string[] {
}
}
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
withHonorifics.add(alias);
for (const suffix of HONORIFIC_SUFFIXES) {
withHonorifics.add(`${alias}${suffix.term}`);
}
}
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
}
export function buildVisibleNameTerms(nameTerms: string[]): string[] {
const allTerms = new Set(nameTerms);
return nameTerms.filter((term) => {
for (const suffix of HONORIFIC_SUFFIXES) {
if (!term.endsWith(suffix.term) || term.length <= suffix.term.length) {
continue;
}
if (allTerms.has(term.slice(0, -suffix.term.length))) {
return false;
}
}
return true;
});
}
export function buildReadingForTerm(
term: string,
character: CharacterRecord,
@@ -147,6 +147,7 @@ export interface CharacterDictionaryRuntimeDeps {
sleep?: (ms: number) => Promise<void>;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
getNameMatchImagesEnabled?: () => boolean;
getCollapsibleSectionOpenState?: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean;
@@ -124,6 +124,8 @@ function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
'ankiConnect.knownWords',
'ankiConnect.nPlusOne',
'ankiConnect.fields.word',
'subtitleStyle.nameMatchEnabled',
'subtitleStyle.nameMatchImagesEnabled',
]);
}
@@ -36,6 +36,9 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getNameMatchEnabled: () => false,
getNameMatchImagesEnabled: () => true,
getCharacterNameImage: (term) =>
term === 'name' ? { src: 'data:image/png;base64,AAAA', alt: 'Name' } : null,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5,
@@ -52,6 +55,11 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
assert.equal(deps.getNPlusOneEnabled?.(), true);
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
assert.equal(deps.getNameMatchEnabled?.(), false);
assert.equal(deps.getNameMatchImagesEnabled?.(), true);
assert.deepEqual(deps.getCharacterNameImage?.('name'), {
src: 'data:image/png;base64,AAAA',
alt: 'Name',
});
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});
@@ -74,6 +82,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
getJlptEnabled: () => true,
getCharacterDictionaryEnabled: () => false,
getNameMatchEnabled: () => true,
getNameMatchImagesEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5,
@@ -82,6 +91,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
})();
assert.equal(deps.getNameMatchEnabled?.(), false);
assert.equal(deps.getNameMatchImagesEnabled?.(), false);
});
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
@@ -4,6 +4,8 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getCharacterDictionaryEnabled?: () => boolean;
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
@@ -57,6 +59,17 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
}
: {}),
...(deps.getNameMatchImagesEnabled
? {
getNameMatchImagesEnabled: () =>
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(),
}
: {}),
...(deps.getCharacterNameImage
? {
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),