mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
feat(character-dictionary): add manager modal and scope name matching to current media (#86)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user