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
+39
View File
@@ -64,6 +64,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.ankiConnect.media.audioPadding, 0);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
@@ -740,6 +741,44 @@ test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () =>
);
});
test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchImagesEnabled": true
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"nameMatchImagesEnabled": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled,
DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'),
);
});
test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -11,6 +11,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: false,
nameMatchImagesEnabled: false,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
@@ -76,6 +76,13 @@ export function buildSubtitleConfigOptionRegistry(
description:
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
},
{
path: 'subtitleStyle.nameMatchImagesEnabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.nameMatchImagesEnabled,
description:
'Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary.',
},
{
path: 'subtitleStyle.nameMatchColor',
kind: 'string',
+20
View File
@@ -190,6 +190,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor;
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
const fallbackSubtitleStyleNameMatchImagesEnabled =
resolved.subtitleStyle.nameMatchImagesEnabled;
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
@@ -390,6 +392,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const nameMatchImagesEnabled = asBoolean(
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
);
if (nameMatchImagesEnabled !== undefined) {
resolved.subtitleStyle.nameMatchImagesEnabled = nameMatchImagesEnabled;
} else if (
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled !==
undefined
) {
resolved.subtitleStyle.nameMatchImagesEnabled = fallbackSubtitleStyleNameMatchImagesEnabled;
warn(
'subtitleStyle.nameMatchImagesEnabled',
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
resolved.subtitleStyle.nameMatchImagesEnabled,
'Expected boolean.',
);
}
if (nameMatchColor !== undefined) {
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
+25
View File
@@ -172,6 +172,31 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
);
});
test('subtitleStyle nameMatchImagesEnabled accepts boolean and warns on invalid', () => {
const valid = createResolveContext({
subtitleStyle: {
nameMatchImagesEnabled: true,
},
});
applySubtitleDomainConfig(valid.context);
assert.equal(valid.context.resolved.subtitleStyle.nameMatchImagesEnabled, true);
const invalid = createResolveContext({
subtitleStyle: {
nameMatchImagesEnabled: 'yes' as unknown as boolean,
},
});
applySubtitleDomainConfig(invalid.context);
assert.equal(invalid.context.resolved.subtitleStyle.nameMatchImagesEnabled, false);
assert.ok(
invalid.warnings.some(
(warning) =>
warning.path === 'subtitleStyle.nameMatchImagesEnabled' &&
warning.message === 'Expected boolean.',
),
);
});
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
const { context } = createResolveContext({});
+1
View File
@@ -173,6 +173,7 @@ test('settings registry exposes css declaration editor for primary and secondary
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchImagesEnabled').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
+6 -1
View File
@@ -345,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchImagesEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'appearance', section: 'Annotation Display' };
@@ -524,7 +525,11 @@ function subsectionForPath(path: string): string | undefined {
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
if (
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchImagesEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return 'Character Names';
}
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
+15 -10
View File
@@ -1191,18 +1191,22 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: number[] = [];
const searches: Array<string | undefined> = [];
registerIpcHandlers(
createRegisterIpcDeps({
getCharacterDictionarySelection: async () => ({
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
}),
getCharacterDictionarySelection: async (searchTitle) => {
searches.push(searchTitle);
return {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
};
},
setCharacterDictionarySelection: async (mediaId) => {
calls.push(mediaId);
return {
@@ -1223,7 +1227,7 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
assert.deepEqual(await getHandler!({}), {
assert.deepEqual(await getHandler!({}, ' Re:ZERO '), {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
@@ -1241,4 +1245,5 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
staleMediaIds: [10607],
});
assert.deepEqual(calls, [21355]);
assert.deepEqual(searches, ['Re:ZERO']);
});
+5 -4
View File
@@ -95,7 +95,7 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
@@ -223,7 +223,7 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
@@ -615,8 +615,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.retryAnilistQueueNow();
});
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
return await (deps.getCharacterDictionarySelection?.() ??
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async (_event, searchTitle) => {
const normalizedSearchTitle = typeof searchTitle === 'string' ? searchTitle.trim() : undefined;
return await (deps.getCharacterDictionarySelection?.(normalizedSearchTitle) ??
Promise.resolve({
seriesKey: '',
guessTitle: null,
+64
View File
@@ -149,6 +149,70 @@ test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async (
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
});
test('tokenizeSubtitle attaches character image metadata to name matches when enabled', async () => {
const result = await tokenizeSubtitle(
'アクアです',
makeDepsFromYomitanTokens(
[
{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true },
{ surface: 'です', reading: 'です', headword: 'です' },
],
{
getNameMatchImagesEnabled: () => true,
getCharacterNameImage: (term) =>
term === 'アクア'
? {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
}
: null,
} as Partial<TokenizerServiceDeps>,
),
);
assert.deepEqual(result.tokens?.[0]?.characterImage, {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
});
assert.equal(result.tokens?.[1]?.characterImage, undefined);
});
test('tokenizeSubtitle keeps tokens when character image lookup throws', async () => {
const result = await tokenizeSubtitle(
'アクア',
makeDepsFromYomitanTokens(
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
{
getNameMatchImagesEnabled: () => true,
getCharacterNameImage: () => {
throw new Error('image lookup failed');
},
} as Partial<TokenizerServiceDeps>,
),
);
assert.equal(result.tokens?.[0]?.surface, 'アクア');
assert.equal(result.tokens?.[0]?.characterImage, undefined);
});
test('tokenizeSubtitle omits character image metadata when name-match images are disabled', async () => {
const result = await tokenizeSubtitle(
'アクア',
makeDepsFromYomitanTokens(
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
{
getNameMatchImagesEnabled: () => false,
getCharacterNameImage: () => ({
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
}),
} as Partial<TokenizerServiceDeps>,
),
);
assert.equal(result.tokens?.[0]?.characterImage, undefined);
});
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
let lookupCalls = 0;
const result = await tokenizeSubtitle(
+58 -1
View File
@@ -3,6 +3,7 @@ import { mergeTokens } from '../../token-merger';
import { createLogger } from '../../logger';
import {
FrequencyDictionaryMatchMode,
CharacterNameImage,
MergedToken,
NPlusOneMatchMode,
SubtitleData,
@@ -48,6 +49,8 @@ export interface TokenizerServiceDeps {
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -80,6 +83,8 @@ export interface TokenizerDepsRuntimeOptions {
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -94,6 +99,7 @@ interface TokenizerAnnotationOptions {
nPlusOneEnabled: boolean;
jlptEnabled: boolean;
nameMatchEnabled: boolean;
nameMatchImagesEnabled: boolean;
frequencyEnabled: boolean;
frequencyMatchMode: FrequencyDictionaryMatchMode;
minSentenceWordsForNPlusOne: number | undefined;
@@ -229,6 +235,8 @@ export function createTokenizerDepsRuntime(
getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled,
getNameMatchEnabled: options.getNameMatchEnabled,
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
getCharacterNameImage: options.getCharacterNameImage,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank,
@@ -684,6 +692,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
nPlusOneEnabled,
jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
nameMatchImagesEnabled: deps.getNameMatchImagesEnabled?.() === true,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
@@ -780,6 +789,53 @@ async function parseWithYomitanInternalParser(
return enrichedTokens;
}
function resolveCharacterNameImageForToken(
token: MergedToken,
getCharacterNameImage: (term: string) => CharacterNameImage | null,
): CharacterNameImage | null {
const terms = [token.headword, token.surface]
.map((term) => term.trim())
.filter((term, index, list) => term.length > 0 && list.indexOf(term) === index);
for (const term of terms) {
const image = getCharacterNameImage(term);
if (image) {
return image;
}
}
return null;
}
function applyCharacterNameImages(
tokens: MergedToken[],
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): MergedToken[] {
if (
!options.nameMatchEnabled ||
!options.nameMatchImagesEnabled ||
typeof deps.getCharacterNameImage !== 'function'
) {
return tokens.map((token) => ({ ...token, characterImage: undefined }));
}
const getCharacterNameImage = deps.getCharacterNameImage;
return tokens.map((token) => {
if (token.isNameMatch !== true) {
return { ...token, characterImage: undefined };
}
let characterImage: CharacterNameImage | undefined;
try {
characterImage = resolveCharacterNameImageForToken(token, getCharacterNameImage) ?? undefined;
} catch (err) {
logger.warn('Failed to resolve character name image:', (err as Error).message);
}
return {
...token,
characterImage,
};
});
}
export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
@@ -805,9 +861,10 @@ export async function tokenizeSubtitle(
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
if (yomitanTokens && yomitanTokens.length > 0) {
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
return {
text: displayText,
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
tokens: renderedTokens.length > 0 ? renderedTokens : null,
};
}
@@ -788,6 +788,30 @@ test('stripSubtitleAnnotationMetadata keeps known hover data while clearing non-
});
});
test('stripSubtitleAnnotationMetadata clears character image metadata from excluded name matches', () => {
const token = makeToken({
surface: 'は',
headword: 'は',
reading: 'ハ',
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
isNameMatch: true,
});
token.characterImage = {
src: 'data:image/png;base64,AAAA',
alt: 'は',
};
assert.deepEqual(stripSubtitleAnnotationMetadata(token), {
...token,
isNPlusOneTarget: false,
isNameMatch: false,
characterImage: undefined,
jlptLevel: undefined,
frequencyRank: undefined,
});
});
test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => {
const token = makeToken({
surface: '猫',
@@ -508,11 +508,17 @@ export function stripSubtitleAnnotationMetadata(
return token;
}
return {
const strippedToken = {
...token,
isNPlusOneTarget: false,
isNameMatch: false,
jlptLevel: undefined,
frequencyRank: undefined,
};
if ('characterImage' in strippedToken) {
strippedToken.characterImage = undefined;
}
return strippedToken;
}
@@ -1577,18 +1577,24 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
assert.match(upsertScript ?? '', /"enabled":true/);
});
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const scripts: string[] = [];
const servedArchives: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
if (urlMatch) {
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
servedArchives.push(await response.text());
}
return true;
},
},
@@ -1611,15 +1617,103 @@ test('importYomitanDictionaryFromZip uses settings automation bridge instead of
true,
);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
scripts.some((script) => script.includes('importDictionaryArchiveUrl')),
true,
);
assert.deepEqual(servedArchives, ['zip-bytes']);
assert.equal(
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
false,
);
assert.equal(
scripts.some((script) => script.includes('subminerImportDictionary')),
false,
);
});
test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const scripts: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
if (
script.includes(
'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl',
)
) {
return false;
}
return true;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
error: () => undefined,
});
assert.equal(imported, true);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
true,
);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveUrl(')),
false,
);
assert.equal(
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
true,
);
});
test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
if (!urlMatch) return true;
fs.unlinkSync(zipPath);
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
return response.ok;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
error: () => undefined,
});
assert.equal(imported, false);
});
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
const scripts: string[] = [];
const settingsWindow = {
@@ -1,5 +1,6 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import { selectYomitanParseTokens } from './parser-selection-stage';
@@ -705,6 +706,70 @@ async function invokeYomitanSettingsAutomation<T>(
}
}
async function serveDictionaryZipOnce<T>(
zipPath: string,
callback: (url: string) => Promise<T>,
): Promise<T> {
const fileName = path.basename(zipPath);
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const requestPath = `/${token}/${encodeURIComponent(fileName)}`;
let served = false;
const server = http.createServer((request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, OPTIONS',
});
response.end();
return;
}
if (request.method !== 'GET' || request.url !== requestPath || served) {
response.writeHead(404, { 'access-control-allow-origin': '*' });
response.end();
return;
}
served = true;
let size = 0;
try {
size = fs.statSync(zipPath).size;
} catch {
response.writeHead(500, { 'access-control-allow-origin': '*' });
response.end();
return;
}
response.writeHead(200, {
'access-control-allow-origin': '*',
'content-length': String(size),
'content-type': 'application/zip',
});
const stream = fs.createReadStream(zipPath);
stream.on('error', () => {
if (!response.headersSent) {
response.writeHead(500, { 'access-control-allow-origin': '*' });
response.end();
return;
}
response.destroy();
});
stream.pipe(response);
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Dictionary import server did not bind to a TCP port.');
}
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
const YOMITAN_SCANNING_HELPERS = String.raw`
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
@@ -1863,17 +1928,43 @@ export async function importYomitanDictionaryFromZip(
return false;
}
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
const script = `
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
${JSON.stringify(archiveBase64)},
${JSON.stringify(path.basename(normalizedZipPath))}
);
return true;
})();
`;
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
`
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
`,
deps,
logger,
);
const result =
supportsUrlImport === true
? await serveDictionaryZipOnce(normalizedZipPath, async (archiveUrl) =>
invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl(
${JSON.stringify(archiveUrl)}
);
return true;
})();
`,
deps,
logger,
),
)
: await invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
${JSON.stringify(fs.readFileSync(normalizedZipPath).toString('base64'))},
${JSON.stringify(path.basename(normalizedZipPath))}
);
return true;
})();
`,
deps,
logger,
);
return result === true;
}
+10 -2
View File
@@ -518,6 +518,7 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
@@ -2178,6 +2179,7 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
getCollapsibleSectionOpenState: (section) =>
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
now: () => Date.now(),
@@ -2185,6 +2187,10 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
logWarn: (message) => logger.warn(message),
});
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH,
});
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: USER_DATA_PATH,
getConfig: () => {
@@ -4728,6 +4734,8 @@ const {
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
!isYoutubePlaybackActiveNow(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.frequency',
@@ -5967,8 +5975,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
getCharacterDictionarySelection: () =>
characterDictionaryRuntime.getManualSelectionSnapshot(),
getCharacterDictionarySelection: (searchTitle?: string) =>
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
setCharacterDictionarySelection: async (mediaId: number) =>
applyCharacterDictionarySelection(
{ mediaId },
+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),
+2 -2
View File
@@ -413,8 +413,8 @@ const electronAPI: ElectronAPI = {
request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
getCharacterDictionarySelection: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection),
getCharacterDictionarySelection: (searchTitle?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
setCharacterDictionarySelection: (mediaId: number) =>
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
notifyOverlayModalClosed: (modal) => {
+1
View File
@@ -681,6 +681,7 @@ test('numeric selection start focuses overlay for follow-up digit keys', async (
assert.equal(testGlobals.windowFocusCalls() > 0, true);
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
} finally {
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
testGlobals.restore();
}
});
+17 -1
View File
@@ -22,7 +22,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; img-src 'self' data: blob: chrome-extension:; worker-src 'self' blob:;"
/>
<title>SubMiner</title>
<link rel="stylesheet" href="style.css" />
@@ -205,6 +205,22 @@
</div>
<div class="modal-body">
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
<div class="character-dictionary-search">
<input
id="characterDictionarySearchInput"
class="character-dictionary-search-input"
type="text"
aria-label="Search character dictionary"
autocomplete="off"
/>
<button
id="characterDictionarySearchButton"
class="character-dictionary-use"
type="button"
>
Search
</button>
</div>
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
@@ -28,6 +28,8 @@ function createElementStub() {
className: '',
textContent: '',
type: '',
value: '',
disabled: false,
children: [] as unknown[],
classList: createClassList(),
append(...children: unknown[]) {
@@ -38,17 +40,25 @@ function createElementStub() {
}
function createNodeStub(hidden = false) {
const listeners = new Map<string, Array<() => void>>();
const listeners = new Map<string, Array<(event?: { preventDefault?: () => void }) => void>>();
return {
textContent: '',
value: '',
disabled: false,
children: [] as unknown[],
classList: createClassList(hidden ? ['hidden'] : []),
setAttribute: () => {},
addEventListener: (event: string, listener: () => void) => {
addEventListener: (
event: string,
listener: (event?: { preventDefault?: () => void }) => void,
) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string) => {
for (const listener of listeners.get(event) ?? []) listener();
dispatchEvent: (event: string, payload?: { preventDefault?: () => void }) => {
for (const listener of listeners.get(event) ?? []) listener(payload);
},
append(...children: unknown[]) {
this.children.push(...children);
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
@@ -207,6 +217,8 @@ test('character dictionary modal loads candidates and applies selected override'
characterDictionaryClose: closeButton,
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
@@ -283,6 +295,8 @@ test('character dictionary modal shows refresh errors without rejecting open', a
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
},
@@ -302,3 +316,255 @@ test('character dictionary modal shows refresh errors without rejecting open', a
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
test('character dictionary modal seeds search input and waits for manual search', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const initialSnapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 'kage-no-jitsuryokusha-ni-naritakute-2022',
guessTitle: 'Kage no Jitsuryokusha ni Naritakute!',
current: null,
override: null,
candidates: [],
};
const searchedSnapshot: CharacterDictionarySelectionSnapshot = {
...initialSnapshot,
candidates: [{ id: 130298, title: 'The Eminence in Shadow', episodes: 20 }],
};
const searches: Array<string | undefined> = [];
const overlay = createNodeStub();
const searchInput = createNodeStub();
const searchButton = createNodeStub();
const candidates = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async (searchText?: string) => {
searches.push(searchText);
return searchText ? searchedSnapshot : initialSnapshot;
},
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: initialSnapshot.seriesKey,
selected: searchedSnapshot.candidates[0]!,
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: searchInput,
characterDictionarySearchButton: searchButton,
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.wireDomEvents();
await modal.openCharacterDictionaryModal();
assert.deepEqual(searches, ['']);
assert.equal(searchInput.value, 'Kage no Jitsuryokusha ni Naritakute!');
assert.equal(candidates.children.length, 1);
assert.match(status.textContent, /Enter a title/);
searchInput.value = 'Eminence in Shadow';
searchButton.dispatchEvent('click');
await flushAsyncWork();
assert.deepEqual(searches, ['', 'Eminence in Shadow']);
assert.equal(candidates.children.length, 1);
assert.match(status.textContent, /Select the correct AniList entry/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal marks override candidate as selected', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 'konosuba-gods-blessing-on-this-wonderful-world-2016',
guessTitle: "KonoSuba - God's blessing on this wonderful world!",
current: null,
override: {
id: 21202,
title: "KONOSUBA -God's blessing on this wonderful world!",
episodes: 10,
},
candidates: [
{ id: 21202, title: "KONOSUBA -God's blessing on this wonderful world!", episodes: 10 },
],
};
const state = createRendererState();
const candidates = createNodeStub();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay: createNodeStub(),
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: createNodeStub(),
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
const item = candidates.children[0] as { children: unknown[] };
const button = item.children[1] as { textContent: string; disabled: boolean };
assert.equal(button.textContent, 'Selected');
assert.equal(button.disabled, true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal does not resave the active override from keyboard apply', async () => {
const previousWindow = globalThis.window;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
override: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
};
const calls: number[] = [];
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async (mediaId: number) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [],
};
},
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
| 'notifyOverlayModalOpened'
>,
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay: createNodeStub(),
characterDictionaryModal: createNodeStub(true),
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: createNodeStub(),
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
modal.handleCharacterDictionaryKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.deepEqual(calls, []);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});
+64 -12
View File
@@ -27,17 +27,25 @@ export function createCharacterDictionaryModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let hasSearched = false;
function setStatus(message: string, isError = false): void {
ctx.state.characterDictionaryStatus = message;
ctx.dom.characterDictionaryStatus.textContent = message;
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
}
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
function setSelection(
snapshot: CharacterDictionarySelectionSnapshot,
seedSearchInput = false,
): void {
const previousId =
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
?.id;
ctx.state.characterDictionarySelection = snapshot;
if (seedSearchInput) {
ctx.dom.characterDictionarySearchInput.value = snapshot.guessTitle ?? '';
}
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
ctx.state.characterDictionarySelectedIndex = clampIndex(
nextIndex >= 0 ? nextIndex : 0,
@@ -47,6 +55,7 @@ export function createCharacterDictionaryModal(
}
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
const item = document.createElement('li');
item.className = 'character-dictionary-candidate';
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
@@ -63,9 +72,11 @@ export function createCharacterDictionaryModal(
const button = document.createElement('button');
button.className = 'character-dictionary-use';
button.type = 'button';
button.textContent = 'Use';
button.textContent = isOverride ? 'Selected' : 'Use';
button.disabled = isOverride;
button.addEventListener('click', (event) => {
event.stopPropagation();
if (isOverride) return;
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
@@ -104,7 +115,9 @@ export function createCharacterDictionaryModal(
if (snapshot.candidates.length === 0) {
const empty = document.createElement('li');
empty.className = 'character-dictionary-empty';
empty.textContent = 'No AniList candidates found.';
empty.textContent = hasSearched
? 'No AniList candidates found.'
: 'Search AniList to show candidates.';
ctx.dom.characterDictionaryCandidates.append(empty);
return;
}
@@ -114,20 +127,41 @@ export function createCharacterDictionaryModal(
);
}
async function refreshSelection(): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
setSelection(snapshot);
async function refreshSelection(searchTitle?: string): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
hasSearched = searchTitle !== '';
setSelection(snapshot, searchTitle === '');
setStatus(
snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
searchTitle === ''
? 'Enter a title to search AniList.'
: snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
);
}
async function searchCandidates(): Promise<void> {
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
if (!searchTitle) {
setStatus('Enter a title to search AniList.', true);
return;
}
ctx.dom.characterDictionarySearchButton.disabled = true;
setStatus(`Searching AniList for ${searchTitle}...`);
try {
await refreshSelection(searchTitle);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
} finally {
ctx.dom.characterDictionarySearchButton.disabled = false;
}
}
async function applySelectedCandidate(): Promise<void> {
const snapshot = ctx.state.characterDictionarySelection;
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
if (!candidate) return;
if (candidate.id === snapshot?.override?.id) return;
setStatus(`Saving override for ${candidate.title}...`);
try {
@@ -136,7 +170,7 @@ export function createCharacterDictionaryModal(
setStatus('Failed to save override', true);
return;
}
await refreshSelection();
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
@@ -154,7 +188,7 @@ export function createCharacterDictionaryModal(
ctx.dom.characterDictionaryModal.classList.remove('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
setStatus('Loading AniList candidates...');
setStatus('Loading character dictionary selector...');
}
async function openCharacterDictionaryModal(): Promise<void> {
@@ -165,7 +199,7 @@ export function createCharacterDictionaryModal(
setStatus('Refreshing AniList candidates...');
}
try {
await refreshSelection();
await refreshSelection('');
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -179,6 +213,7 @@ export function createCharacterDictionaryModal(
ctx.dom.characterDictionaryModal.classList.add('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
ctx.dom.characterDictionaryCandidates.replaceChildren();
hasSearched = false;
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
setStatus('');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
@@ -202,6 +237,14 @@ export function createCharacterDictionaryModal(
closeCharacterDictionaryModal();
return true;
}
if (e.target === ctx.dom.characterDictionarySearchInput) {
if (e.key === 'Enter') {
e.preventDefault();
void searchCandidates();
return true;
}
return false;
}
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
moveSelection(1);
@@ -222,6 +265,15 @@ export function createCharacterDictionaryModal(
function wireDomEvents(): void {
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
void searchCandidates();
});
ctx.dom.characterDictionarySearchInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void searchCandidates();
}
});
}
return {
+48
View File
@@ -809,6 +809,28 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-name-match-color, #f5bde6);
}
#subtitleRoot .word.word-character-image-token {
display: inline-block;
position: relative;
padding-left: 1.08em;
vertical-align: baseline;
}
#subtitleRoot .word-character-image {
position: absolute;
left: 0;
top: 50%;
width: 0.9em;
height: 0.9em;
border-radius: 50%;
object-fit: cover;
transform: translateY(calc(-50% + 0.05em));
pointer-events: none;
box-shadow:
0 0 0 0.06em rgba(255, 255, 255, 0.32),
0 0.08em 0.2em rgba(0, 0, 0, 0.45);
}
#subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
@@ -1551,6 +1573,27 @@ iframe[id^='yomitan-popup'],
color: var(--ctp-subtext1);
}
.character-dictionary-search {
display: flex;
gap: 8px;
margin: 10px 0;
}
.character-dictionary-search-input {
min-width: 0;
flex: 1 1 auto;
border: 1px solid rgba(110, 115, 141, 0.28);
border-radius: 6px;
background: rgba(24, 25, 38, 0.88);
color: var(--ctp-text);
padding: 7px 9px;
}
.character-dictionary-search-input:focus {
border-color: rgba(138, 173, 244, 0.75);
outline: none;
}
.character-dictionary-candidates {
list-style: none;
margin: 0;
@@ -1602,6 +1645,11 @@ iframe[id^='yomitan-popup'],
background: rgba(91, 96, 120, 0.9);
}
.character-dictionary-use:disabled {
cursor: default;
opacity: 0.72;
}
.character-dictionary-empty {
color: var(--ctp-overlay1);
font-size: 13px;
+110
View File
@@ -259,6 +259,103 @@ test('applySubtitleStyle sets subtitle name-match color variable', () => {
}
});
test('renderSubtitle injects circular character image for annotated name matches', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: {
...createRendererState(),
nameMatchEnabled: true,
},
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [
{
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
isNameMatch: true,
characterImage: {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
},
} as MergedToken,
],
});
const [word] = collectWordNodes(subtitleRoot);
assert.ok(word);
assert.equal(word.className, 'word word-name-match word-character-image-token');
assert.equal(word.textContent, 'アクア');
const image = word.childNodes[0] as FakeElement & { src?: string; alt?: string };
assert.equal(image.tagName, 'img');
assert.equal(image.className, 'word-character-image');
assert.equal(image.src, 'data:image/png;base64,AAAA');
assert.equal(image.alt, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle skips character image when name-match rendering is disabled', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: {
...createRendererState(),
nameMatchEnabled: false,
},
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [
{
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
isNameMatch: true,
characterImage: {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
},
} as MergedToken,
],
});
const [word] = collectWordNodes(subtitleRoot);
assert.ok(word);
assert.equal(word.className, 'word');
assert.equal(word.textContent, 'アクア');
assert.equal(word.childNodes.length, 0);
} finally {
restoreDocument();
}
});
test('renderer content security policy allows data URL character images', () => {
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
const cspMatch = htmlText.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]+)"/);
assert.ok(cspMatch, 'renderer CSP meta tag should exist');
assert.match(cspMatch[1] ?? '', /(?:^|;)\s*img-src\s+[^;]*\bdata:/);
});
test('applySubtitleStyle stores secondary background styles in hover-aware css variables', () => {
const restoreDocument = installFakeDocument();
try {
@@ -869,6 +966,19 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
const characterImageTokenBlock = extractClassBlock(
cssText,
'#subtitleRoot .word.word-character-image-token',
);
assert.match(characterImageTokenBlock, /display:\s*inline-block;/);
assert.match(characterImageTokenBlock, /position:\s*relative;/);
assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/);
const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image');
assert.match(characterImageBlock, /position:\s*absolute;/);
assert.match(characterImageBlock, /top:\s*50%;/);
assert.match(characterImageBlock, /transform:\s*translateY\(calc\(-50%\s*\+\s*0\.05em\)\);/);
const frequencyTooltipBaseBlock = extractClassBlock(
cssText,
'#subtitleRoot .word[data-frequency-rank]::before',
+40 -2
View File
@@ -105,6 +105,40 @@ function hasPrioritizedNameMatch(
);
}
function hasTokenCharacterImage(token: MergedToken): boolean {
return (
typeof token.characterImage?.src === 'string' && token.characterImage.src.trim().length > 0
);
}
function shouldRenderTokenCharacterImage(
token: MergedToken,
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
): boolean {
return hasPrioritizedNameMatch(token, tokenRenderSettings) && hasTokenCharacterImage(token);
}
function appendTokenSurface(
span: HTMLSpanElement,
token: MergedToken,
surface: string,
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
): void {
if (!shouldRenderTokenCharacterImage(token, tokenRenderSettings)) {
span.textContent = surface;
return;
}
const image = document.createElement('img');
image.className = 'word-character-image';
image.src = token.characterImage!.src;
image.alt = token.characterImage!.alt || token.headword || surface;
image.decoding = 'async';
image.loading = 'eager';
span.appendChild(image);
span.appendChild(document.createTextNode(surface));
}
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback;
@@ -393,7 +427,7 @@ function renderWithTokens(
const token = segment.token;
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = token.surface;
appendTokenSurface(span, token, token.surface, resolvedTokenRenderSettings);
span.dataset.tokenIndex = String(segment.tokenIndex);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
@@ -429,7 +463,7 @@ function renderWithTokens(
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = surface;
appendTokenSurface(span, token, surface, resolvedTokenRenderSettings);
span.dataset.tokenIndex = String(index);
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
@@ -572,6 +606,10 @@ export function computeWordClass(
}
}
if (shouldRenderTokenCharacterImage(token, resolvedTokenRenderSettings)) {
classes.push('word-character-image-token');
}
return classes.join(' ');
}
+8
View File
@@ -60,6 +60,8 @@ export type RendererDom = {
characterDictionaryModal: HTMLDivElement;
characterDictionaryClose: HTMLButtonElement;
characterDictionarySummary: HTMLDivElement;
characterDictionarySearchInput: HTMLInputElement;
characterDictionarySearchButton: HTMLButtonElement;
characterDictionaryCurrent: HTMLDivElement;
characterDictionaryCandidates: HTMLUListElement;
characterDictionaryStatus: HTMLDivElement;
@@ -187,6 +189,12 @@ export function resolveRendererDom(): RendererDom {
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
'characterDictionarySearchInput',
),
characterDictionarySearchButton: getRequiredElement<HTMLButtonElement>(
'characterDictionarySearchButton',
),
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
'characterDictionaryCandidates',
+3 -1
View File
@@ -474,7 +474,9 @@ export interface ElectronAPI {
youtubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getCharacterDictionarySelection: () => Promise<CharacterDictionarySelectionSnapshot>;
getCharacterDictionarySelection: (
searchTitle?: string,
) => Promise<CharacterDictionarySelectionSnapshot>;
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
notifyOverlayModalClosed: (
modal:
+7
View File
@@ -39,10 +39,16 @@ export interface MergedToken {
isKnown: boolean;
isNPlusOneTarget: boolean;
isNameMatch?: boolean;
characterImage?: CharacterNameImage;
jlptLevel?: JlptLevel;
frequencyRank?: number;
}
export interface CharacterNameImage {
src: string;
alt: string;
}
export type FrequencyDictionaryLookup = (term: string) => number | null;
export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5';
@@ -78,6 +84,7 @@ export interface SubtitleStyleConfig {
hoverTokenColor?: string;
hoverTokenBackgroundColor?: string;
nameMatchEnabled?: boolean;
nameMatchImagesEnabled?: boolean;
nameMatchColor?: string;
fontFamily?: string;
fontSize?: number;