feat(character-dictionary): add manager modal and scope name matching to current media (#86)

This commit is contained in:
2026-05-25 18:29:20 -07:00
committed by GitHub
parent 097b619d71
commit 3932e53ced
71 changed files with 1896 additions and 127 deletions
@@ -119,3 +119,48 @@ test('buildSnapshotFromCharacters shows Japanese aliases without adding romanize
assert.equal(terms.includes('アクア'), true);
assert.equal(terms.includes('阿久亜'), true);
});
test('buildSnapshotFromCharacters stores media id in Yomitan structured-content data', () => {
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(
21699,
"KONOSUBA -God's blessing on this wonderful world! 2",
[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: {
data?: Record<string, string>;
content: Array<Record<string, unknown>>;
};
};
assert.equal(glossaryEntry.content.data?.subminerMediaId, '21699');
assert.equal(
glossaryEntry.content.content.some((node) =>
Object.prototype.hasOwnProperty.call(node, 'subminerMediaId'),
),
false,
);
});
@@ -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 = 16;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 17;
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
export const HONORIFIC_SUFFIXES = [
@@ -146,6 +146,7 @@ function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | nu
export function createDefinitionGlossary(
character: CharacterRecord,
mediaId: number,
mediaTitle: string,
imagePath: string | null,
vaImagePaths: Map<number, string>,
@@ -258,7 +259,7 @@ export function createDefinitionGlossary(
return [
{
type: 'structured-content',
content: { tag: 'div', content },
content: { tag: 'div', data: { subminerMediaId: String(mediaId) }, content },
},
];
}
@@ -5,7 +5,10 @@ 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 {
buildCharacterNameImageIndexFromSnapshots,
createCharacterDictionaryImageLookup,
} from './image-lookup';
import type { CharacterDictionarySnapshot } from './types';
const PNG_1X1_BASE64 =
@@ -119,3 +122,96 @@ test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes bef
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
});
test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', () => {
const outputDir = makeTempDir();
const towerSnapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 115230,
mediaTitle: 'Tower of God',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' },
},
],
0,
'',
],
],
images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }],
};
const konosubaSnapshot: CharacterDictionarySnapshot = {
...towerSnapshot,
mediaId: 21202,
mediaTitle: 'KonoSuba',
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m21202-c2.png', alt: 'Kazuma' },
},
],
0,
'',
],
],
images: [{ path: 'img/m21202-c2.png', dataBase64: 'KONOSUBA' }],
};
writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot);
writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
assert.equal(lookup.get('カズ', 21202)?.alt, 'Kazuma');
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 115230,
mediaTitle: 'Tower of God',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' },
},
],
0,
'',
],
],
images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
assert.equal(lookup.get('カズ', 21202), null);
assert.equal(lookup.get('カズ')?.alt, 'Kaz');
});
@@ -23,6 +23,14 @@ function normalizeLookupTerm(term: string): string {
return term.trim();
}
function normalizeLookupMediaId(mediaId: unknown): number | null {
if (typeof mediaId !== 'number' || !Number.isFinite(mediaId)) {
return null;
}
const normalized = Math.floor(mediaId);
return normalized > 0 ? normalized : null;
}
function getSnapshotsDir(outputDir: string): string {
return path.join(outputDir, 'snapshots');
}
@@ -209,8 +217,9 @@ export function buildCharacterNameImageIndexFromSnapshots(
export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
}): {
get: (term: string) => CharacterNameImage | null;
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void;
} {
const outputDir =
@@ -218,10 +227,12 @@ export function createCharacterDictionaryImageLookup(deps: {
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
let signature: string | null = null;
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
function refreshIfNeeded(): void {
if (!outputDir) {
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
signature = '';
return;
}
@@ -230,16 +241,29 @@ export function createCharacterDictionaryImageLookup(deps: {
return;
}
signature = nextSignature;
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
const mediaIndex = new Map<string, CharacterNameImage>();
appendSnapshotImages(mediaIndex, snapshot);
if (mediaIndex.size > 0) {
indexByMediaId.set(snapshot.mediaId, mediaIndex);
}
}
}
return {
get(term: string): CharacterNameImage | null {
get(term: string, mediaId?: number | null): CharacterNameImage | null {
const normalizedTerm = normalizeLookupTerm(term);
if (!normalizedTerm) {
return null;
}
refreshIfNeeded();
const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null);
if (scopedMediaId !== null) {
return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null;
}
return index.get(normalizedTerm) ?? null;
},
invalidate(): void {
@@ -48,6 +48,7 @@ export function buildSnapshotFromCharacters(
const candidateTerms = buildNameTerms(character);
const glossary = createDefinitionGlossary(
character,
mediaId,
mediaTitle,
imagePath,
vaImagePaths,