mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
Add inline character portraits and dictionary search workflow (#83)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 Journey’s 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 Journey’s 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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user