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

This commit is contained in:
2026-05-25 03:16:25 -07:00
committed by GitHub
parent 7e6f9672cf
commit 807c0ff3db
54 changed files with 2306 additions and 178 deletions
+15 -10
View File
@@ -1191,18 +1191,22 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: number[] = [];
const searches: Array<string | undefined> = [];
registerIpcHandlers(
createRegisterIpcDeps({
getCharacterDictionarySelection: async () => ({
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
}),
getCharacterDictionarySelection: async (searchTitle) => {
searches.push(searchTitle);
return {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
};
},
setCharacterDictionarySelection: async (mediaId) => {
calls.push(mediaId);
return {
@@ -1223,7 +1227,7 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
assert.deepEqual(await getHandler!({}), {
assert.deepEqual(await getHandler!({}, ' Re:ZERO '), {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
@@ -1241,4 +1245,5 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
staleMediaIds: [10607],
});
assert.deepEqual(calls, [21355]);
assert.deepEqual(searches, ['Re:ZERO']);
});
+5 -4
View File
@@ -95,7 +95,7 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
@@ -223,7 +223,7 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: () => Promise<unknown>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
@@ -615,8 +615,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.retryAnilistQueueNow();
});
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
return await (deps.getCharacterDictionarySelection?.() ??
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async (_event, searchTitle) => {
const normalizedSearchTitle = typeof searchTitle === 'string' ? searchTitle.trim() : undefined;
return await (deps.getCharacterDictionarySelection?.(normalizedSearchTitle) ??
Promise.resolve({
seriesKey: '',
guessTitle: null,
+64
View File
@@ -149,6 +149,70 @@ test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async (
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
});
test('tokenizeSubtitle attaches character image metadata to name matches when enabled', async () => {
const result = await tokenizeSubtitle(
'アクアです',
makeDepsFromYomitanTokens(
[
{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true },
{ surface: 'です', reading: 'です', headword: 'です' },
],
{
getNameMatchImagesEnabled: () => true,
getCharacterNameImage: (term) =>
term === 'アクア'
? {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
}
: null,
} as Partial<TokenizerServiceDeps>,
),
);
assert.deepEqual(result.tokens?.[0]?.characterImage, {
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
});
assert.equal(result.tokens?.[1]?.characterImage, undefined);
});
test('tokenizeSubtitle keeps tokens when character image lookup throws', async () => {
const result = await tokenizeSubtitle(
'アクア',
makeDepsFromYomitanTokens(
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
{
getNameMatchImagesEnabled: () => true,
getCharacterNameImage: () => {
throw new Error('image lookup failed');
},
} as Partial<TokenizerServiceDeps>,
),
);
assert.equal(result.tokens?.[0]?.surface, 'アクア');
assert.equal(result.tokens?.[0]?.characterImage, undefined);
});
test('tokenizeSubtitle omits character image metadata when name-match images are disabled', async () => {
const result = await tokenizeSubtitle(
'アクア',
makeDepsFromYomitanTokens(
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
{
getNameMatchImagesEnabled: () => false,
getCharacterNameImage: () => ({
src: 'data:image/png;base64,AAAA',
alt: 'アクア',
}),
} as Partial<TokenizerServiceDeps>,
),
);
assert.equal(result.tokens?.[0]?.characterImage, undefined);
});
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
let lookupCalls = 0;
const result = await tokenizeSubtitle(
+58 -1
View File
@@ -3,6 +3,7 @@ import { mergeTokens } from '../../token-merger';
import { createLogger } from '../../logger';
import {
FrequencyDictionaryMatchMode,
CharacterNameImage,
MergedToken,
NPlusOneMatchMode,
SubtitleData,
@@ -48,6 +49,8 @@ export interface TokenizerServiceDeps {
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -80,6 +83,8 @@ export interface TokenizerDepsRuntimeOptions {
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -94,6 +99,7 @@ interface TokenizerAnnotationOptions {
nPlusOneEnabled: boolean;
jlptEnabled: boolean;
nameMatchEnabled: boolean;
nameMatchImagesEnabled: boolean;
frequencyEnabled: boolean;
frequencyMatchMode: FrequencyDictionaryMatchMode;
minSentenceWordsForNPlusOne: number | undefined;
@@ -229,6 +235,8 @@ export function createTokenizerDepsRuntime(
getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled,
getNameMatchEnabled: options.getNameMatchEnabled,
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
getCharacterNameImage: options.getCharacterNameImage,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank,
@@ -684,6 +692,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
nPlusOneEnabled,
jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
nameMatchImagesEnabled: deps.getNameMatchImagesEnabled?.() === true,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
@@ -780,6 +789,53 @@ async function parseWithYomitanInternalParser(
return enrichedTokens;
}
function resolveCharacterNameImageForToken(
token: MergedToken,
getCharacterNameImage: (term: string) => CharacterNameImage | null,
): CharacterNameImage | null {
const terms = [token.headword, token.surface]
.map((term) => term.trim())
.filter((term, index, list) => term.length > 0 && list.indexOf(term) === index);
for (const term of terms) {
const image = getCharacterNameImage(term);
if (image) {
return image;
}
}
return null;
}
function applyCharacterNameImages(
tokens: MergedToken[],
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): MergedToken[] {
if (
!options.nameMatchEnabled ||
!options.nameMatchImagesEnabled ||
typeof deps.getCharacterNameImage !== 'function'
) {
return tokens.map((token) => ({ ...token, characterImage: undefined }));
}
const getCharacterNameImage = deps.getCharacterNameImage;
return tokens.map((token) => {
if (token.isNameMatch !== true) {
return { ...token, characterImage: undefined };
}
let characterImage: CharacterNameImage | undefined;
try {
characterImage = resolveCharacterNameImageForToken(token, getCharacterNameImage) ?? undefined;
} catch (err) {
logger.warn('Failed to resolve character name image:', (err as Error).message);
}
return {
...token,
characterImage,
};
});
}
export async function tokenizeSubtitle(
text: string,
deps: TokenizerServiceDeps,
@@ -805,9 +861,10 @@ export async function tokenizeSubtitle(
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
if (yomitanTokens && yomitanTokens.length > 0) {
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
return {
text: displayText,
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
tokens: renderedTokens.length > 0 ? renderedTokens : null,
};
}
@@ -788,6 +788,30 @@ test('stripSubtitleAnnotationMetadata keeps known hover data while clearing non-
});
});
test('stripSubtitleAnnotationMetadata clears character image metadata from excluded name matches', () => {
const token = makeToken({
surface: 'は',
headword: 'は',
reading: 'ハ',
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
isNameMatch: true,
});
token.characterImage = {
src: 'data:image/png;base64,AAAA',
alt: 'は',
};
assert.deepEqual(stripSubtitleAnnotationMetadata(token), {
...token,
isNPlusOneTarget: false,
isNameMatch: false,
characterImage: undefined,
jlptLevel: undefined,
frequencyRank: undefined,
});
});
test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => {
const token = makeToken({
surface: '猫',
@@ -508,11 +508,17 @@ export function stripSubtitleAnnotationMetadata(
return token;
}
return {
const strippedToken = {
...token,
isNPlusOneTarget: false,
isNameMatch: false,
jlptLevel: undefined,
frequencyRank: undefined,
};
if ('characterImage' in strippedToken) {
strippedToken.characterImage = undefined;
}
return strippedToken;
}
@@ -1577,18 +1577,24 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
assert.match(upsertScript ?? '', /"enabled":true/);
});
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const scripts: string[] = [];
const servedArchives: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
if (urlMatch) {
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
servedArchives.push(await response.text());
}
return true;
},
},
@@ -1611,15 +1617,103 @@ test('importYomitanDictionaryFromZip uses settings automation bridge instead of
true,
);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
scripts.some((script) => script.includes('importDictionaryArchiveUrl')),
true,
);
assert.deepEqual(servedArchives, ['zip-bytes']);
assert.equal(
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
false,
);
assert.equal(
scripts.some((script) => script.includes('subminerImportDictionary')),
false,
);
});
test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const scripts: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
if (
script.includes(
'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl',
)
) {
return false;
}
return true;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
error: () => undefined,
});
assert.equal(imported, true);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
true,
);
assert.equal(
scripts.some((script) => script.includes('importDictionaryArchiveUrl(')),
false,
);
assert.equal(
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
true,
);
});
test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
const zipPath = path.join(tempDir, 'dict.zip');
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
if (!urlMatch) return true;
fs.unlinkSync(zipPath);
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
return response.ok;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
error: () => undefined,
});
assert.equal(imported, false);
});
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
const scripts: string[] = [];
const settingsWindow = {
@@ -1,5 +1,6 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import { selectYomitanParseTokens } from './parser-selection-stage';
@@ -705,6 +706,70 @@ async function invokeYomitanSettingsAutomation<T>(
}
}
async function serveDictionaryZipOnce<T>(
zipPath: string,
callback: (url: string) => Promise<T>,
): Promise<T> {
const fileName = path.basename(zipPath);
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const requestPath = `/${token}/${encodeURIComponent(fileName)}`;
let served = false;
const server = http.createServer((request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, OPTIONS',
});
response.end();
return;
}
if (request.method !== 'GET' || request.url !== requestPath || served) {
response.writeHead(404, { 'access-control-allow-origin': '*' });
response.end();
return;
}
served = true;
let size = 0;
try {
size = fs.statSync(zipPath).size;
} catch {
response.writeHead(500, { 'access-control-allow-origin': '*' });
response.end();
return;
}
response.writeHead(200, {
'access-control-allow-origin': '*',
'content-length': String(size),
'content-type': 'application/zip',
});
const stream = fs.createReadStream(zipPath);
stream.on('error', () => {
if (!response.headersSent) {
response.writeHead(500, { 'access-control-allow-origin': '*' });
response.end();
return;
}
response.destroy();
});
stream.pipe(response);
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Dictionary import server did not bind to a TCP port.');
}
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
const YOMITAN_SCANNING_HELPERS = String.raw`
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
@@ -1863,17 +1928,43 @@ export async function importYomitanDictionaryFromZip(
return false;
}
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
const script = `
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
${JSON.stringify(archiveBase64)},
${JSON.stringify(path.basename(normalizedZipPath))}
);
return true;
})();
`;
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
`
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
`,
deps,
logger,
);
const result =
supportsUrlImport === true
? await serveDictionaryZipOnce(normalizedZipPath, async (archiveUrl) =>
invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl(
${JSON.stringify(archiveUrl)}
);
return true;
})();
`,
deps,
logger,
),
)
: await invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
${JSON.stringify(fs.readFileSync(normalizedZipPath).toString('base64'))},
${JSON.stringify(path.basename(normalizedZipPath))}
);
return true;
})();
`,
deps,
logger,
);
return result === true;
}