feat: add AniList character dictionary sync

This commit is contained in:
2026-03-05 22:43:19 -08:00
parent 2f07c3407a
commit 33ded3c1bf
117 changed files with 3579 additions and 6443 deletions

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
@@ -163,6 +164,13 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push('retryAnilistQueue');
return { ok: true, message: 'AniList retry processed.' };
},
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-1.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 10,
}),
runJellyfinCommand: async () => {
calls.push('runJellyfinCommand');
},
@@ -396,6 +404,52 @@ test('handleCliCommand runs AniList retry command', async () => {
assert.ok(calls.includes('log:AniList retry processed.'));
});
test('handleCliCommand runs dictionary generation command', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
generateCharacterDictionary: async () => ({
zipPath: '/tmp/anilist-9253.zip',
fromCache: true,
mediaId: 9253,
mediaTitle: 'STEINS;GATE',
entryCount: 314,
}),
});
handleCliCommand(makeArgs({ dictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('log:Generating character dictionary for current anime...'));
assert.ok(
calls.includes('log:Character dictionary cache hit: AniList 9253 (STEINS;GATE), entries=314'),
);
assert.ok(calls.includes('log:Dictionary ZIP: /tmp/anilist-9253.zip'));
assert.ok(calls.includes('stopApp'));
});
test('handleCliCommand forwards --dictionary-target to dictionary runtime', async () => {
let receivedTarget: string | undefined;
const { deps } = createDeps({
generateCharacterDictionary: async (targetPath?: string) => {
receivedTarget = targetPath;
return {
zipPath: '/tmp/anilist-100.zip',
fromCache: false,
mediaId: 100,
mediaTitle: 'Test',
entryCount: 1,
};
},
});
handleCliCommand(
makeArgs({ dictionary: true, dictionaryTarget: '/tmp/example-video.mkv' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(receivedTarget, '/tmp/example-video.mkv');
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },

View File

@@ -53,6 +53,13 @@ export interface CliCommandServiceDeps {
lastError: string | null;
};
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
generateCharacterDictionary: (targetPath?: string) => Promise<{
zipPath: string;
fromCache: boolean;
mediaId: number;
mediaTitle: string;
entryCount: number;
}>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -134,6 +141,15 @@ export interface CliCommandDepsRuntimeOptions {
overlay: OverlayCliRuntime;
mining: MiningCliRuntime;
anilist: AnilistCliRuntime;
dictionary: {
generate: (targetPath?: string) => Promise<{
zipPath: string;
fromCache: boolean;
mediaId: number;
mediaTitle: string;
entryCount: number;
}>;
};
jellyfin: {
openSetup: () => void;
runCommand: (args: CliArgs) => Promise<void>;
@@ -202,6 +218,7 @@ export function createCliCommandDepsRuntime(
openJellyfinSetup: options.jellyfin.openSetup,
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
generateCharacterDictionary: options.dictionary.generate,
runJellyfinCommand: options.jellyfin.runCommand,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
@@ -239,50 +256,6 @@ export function handleCliCommand(
deps.setLogLevel?.(args.logLevel);
}
const hasNonStartAction =
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.texthooker ||
args.help;
const ignoreStartOnly =
source === 'second-instance' &&
args.start &&
!hasNonStartAction &&
deps.isOverlayRuntimeInitialized();
if (ignoreStartOnly) {
deps.log('Ignoring --start because SubMiner is already running.');
return;
}
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
@@ -402,6 +375,29 @@ export function handleCliCommand(
} else if (args.jellyfin) {
deps.openJellyfinSetup();
deps.log('Opened Jellyfin setup flow.');
} else if (args.dictionary) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps.log('Generating character dictionary for current anime...');
deps
.generateCharacterDictionary(args.dictionaryTarget)
.then((result) => {
const cacheLabel = result.fromCache ? 'cache hit' : 'generated';
deps.log(
`Character dictionary ${cacheLabel}: AniList ${result.mediaId} (${result.mediaTitle}), entries=${result.entryCount}`,
);
deps.log(`Dictionary ZIP: ${result.zipPath}`);
})
.catch((error) => {
deps.error('generateCharacterDictionary failed:', error);
deps.warn(
`Dictionary generation failed: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.anilistRetryQueue) {
const queueStatus = deps.getAnilistQueueStatus();
deps.log(

View File

@@ -30,6 +30,15 @@ export {
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
export {
deleteYomitanDictionaryByTitle,
getYomitanDictionaryInfo,
getYomitanSettingsFull,
importYomitanDictionaryFromZip,
removeYomitanDictionarySettings,
setYomitanSettingsFull,
upsertYomitanDictionarySettings,
} from './tokenizer/yomitan-parser-runtime';
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';

View File

@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -1,12 +1,25 @@
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 {
getYomitanDictionaryInfo,
importYomitanDictionaryFromZip,
deleteYomitanDictionaryByTitle,
removeYomitanDictionarySettings,
requestYomitanParseResults,
requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer,
upsertYomitanDictionarySettings,
} from './yomitan-parser-runtime';
function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
function createDeps(
executeJavaScript: (script: string) => Promise<unknown>,
options?: {
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
},
) {
const parserWindow = {
isDestroyed: () => false,
webContents: {
@@ -22,6 +35,7 @@ function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
setYomitanParserReadyPromise: () => undefined,
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => undefined,
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
};
}
@@ -417,3 +431,126 @@ test('requestYomitanParseResults disables Yomitan MeCab parser path', async () =
assert.ok(parseScript, 'expected parseText request script');
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
});
test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
scriptValue = script;
return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }];
});
const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined });
assert.equal(dictionaries.length, 1);
assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)');
assert.match(scriptValue, /getDictionaryInfo/);
});
test('dictionary settings helpers upsert and remove dictionary entries', async () => {
const scripts: string[] = [];
const optionsFull = {
profileCurrent: 0,
profiles: [
{
options: {
dictionaries: [
{
name: 'SubMiner Character Dictionary (AniList 1)',
alias: 'SubMiner Character Dictionary (AniList 1)',
enabled: false,
},
],
},
},
],
};
const deps = createDeps(async (script) => {
scripts.push(script);
if (script.includes('optionsGetFull')) {
return JSON.parse(JSON.stringify(optionsFull));
}
if (script.includes('setAllSettings')) {
return true;
}
return null;
});
const title = 'SubMiner Character Dictionary (AniList 1)';
const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, {
error: () => undefined,
});
const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, {
error: () => undefined,
});
assert.equal(upserted, true);
assert.equal(removed, true);
const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length;
assert.equal(setCalls, 2);
});
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', 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);
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('__subminerYomitanSettingsAutomation')), true);
assert.equal(scripts.some((script) => script.includes('importDictionaryArchiveBase64')), true);
assert.equal(scripts.some((script) => script.includes('subminerImportDictionary')), false);
});
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
const scripts: string[] = [];
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: {
executeJavaScript: async (script: string) => {
scripts.push(script);
return true;
},
},
};
const deps = createDeps(async () => true, {
createYomitanExtensionWindow: async (pageName: string) => {
assert.equal(pageName, 'settings.html');
return settingsWindow;
},
});
const deleted = await deleteYomitanDictionaryByTitle(
'SubMiner Character Dictionary (AniList 130298)',
deps,
{ error: () => undefined },
);
assert.equal(deleted, true);
assert.equal(scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true);
assert.equal(scripts.some((script) => script.includes('deleteDictionary')), true);
assert.equal(scripts.some((script) => script.includes('subminerDeleteDictionary')), false);
});

View File

@@ -1,4 +1,6 @@
import type { BrowserWindow, Extension } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
interface LoggerLike {
error: (message: string, ...args: unknown[]) => void;
@@ -13,6 +15,12 @@ interface YomitanParserRuntimeDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
}
export interface YomitanDictionaryInfo {
title: string;
revision?: string | number;
}
export interface YomitanTermFrequency {
@@ -489,6 +497,93 @@ async function ensureYomitanParserWindow(
return initPromise;
}
async function createYomitanExtensionWindow(
pageName: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<BrowserWindow | null> {
if (typeof deps.createYomitanExtensionWindow === 'function') {
return await deps.createYomitanExtensionWindow(pageName);
}
const electron = await import('electron');
const yomitanExt = deps.getYomitanExt();
if (!yomitanExt) {
return null;
}
const { BrowserWindow, session } = electron;
const window = new BrowserWindow({
show: false,
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: session.defaultSession,
},
});
try {
await new Promise<void>((resolve, reject) => {
window.webContents.once('did-finish-load', () => resolve());
window.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => {
reject(new Error(errorDescription));
});
void window
.loadURL(`chrome-extension://${yomitanExt.id}/${pageName}`)
.catch((error: Error) => reject(error));
});
return window;
} catch (err) {
logger.error(
`Failed to create hidden Yomitan ${pageName} window: ${(err as Error).message}`,
);
if (!window.isDestroyed()) {
window.destroy();
}
return null;
}
}
async function invokeYomitanSettingsAutomation<T>(
script: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<T | null> {
const settingsWindow = await createYomitanExtensionWindow('settings.html', deps, logger);
if (!settingsWindow || settingsWindow.isDestroyed()) {
return null;
}
try {
await settingsWindow.webContents.executeJavaScript(
`
(async () => {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
if (globalThis.__subminerYomitanSettingsAutomation?.ready === true) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Yomitan settings automation bridge did not become ready");
})();
`,
true,
);
return (await settingsWindow.webContents.executeJavaScript(script, true)) as T;
} catch (err) {
logger.error('Failed to drive Yomitan settings automation:', (err as Error).message);
return null;
} finally {
if (!settingsWindow.isDestroyed()) {
settingsWindow.destroy();
}
}
}
export async function requestYomitanParseResults(
text: string,
deps: YomitanParserRuntimeDeps,
@@ -963,3 +1058,320 @@ export async function syncYomitanDefaultAnkiServer(
return false;
}
}
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
return `
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (!response || typeof response !== "object") {
reject(new Error("Invalid response from Yomitan backend"));
return;
}
if (response.error) {
reject(new Error(response.error.message || "Yomitan backend error"));
return;
}
resolve(response.result);
});
});
return await invoke(${actionLiteral}, ${paramsLiteral});
})();
`;
}
async function invokeYomitanBackendAction<T>(
action: string,
params: unknown,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<T | null> {
const isReady = await ensureYomitanParserWindow(deps, logger);
const parserWindow = deps.getYomitanParserWindow();
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
return null;
}
const script = buildYomitanInvokeScript(
JSON.stringify(action),
params === undefined ? 'undefined' : JSON.stringify(params),
);
try {
return (await parserWindow.webContents.executeJavaScript(script, true)) as T;
} catch (err) {
logger.error(`Yomitan backend action failed (${action}):`, (err as Error).message);
return null;
}
}
function createDefaultDictionarySettings(name: string, enabled: boolean): Record<string, unknown> {
return {
name,
alias: name,
enabled,
allowSecondarySearches: false,
definitionsCollapsible: 'not-collapsible',
partsOfSpeechFilter: true,
useDeinflections: true,
styles: '',
};
}
function getTargetProfileIndices(
optionsFull: Record<string, unknown>,
profileScope: 'all' | 'active',
): number[] {
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profileScope === 'active') {
const profileCurrent =
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
? Math.max(0, Math.floor(optionsFull.profileCurrent))
: 0;
return profileCurrent < profiles.length ? [profileCurrent] : [];
}
return profiles.map((_profile, index) => index);
}
export async function getYomitanDictionaryInfo(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<YomitanDictionaryInfo[]> {
const result = await invokeYomitanBackendAction<unknown>('getDictionaryInfo', undefined, deps, logger);
if (!Array.isArray(result)) {
return [];
}
return result
.filter((entry): entry is Record<string, unknown> => isObject(entry))
.map((entry) => {
const title = typeof entry.title === 'string' ? entry.title.trim() : '';
const revision = entry.revision;
return {
title,
revision:
typeof revision === 'string' || typeof revision === 'number' ? revision : undefined,
};
})
.filter((entry) => entry.title.length > 0);
}
export async function getYomitanSettingsFull(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<Record<string, unknown> | null> {
const result = await invokeYomitanBackendAction<unknown>('optionsGetFull', undefined, deps, logger);
return isObject(result) ? result : null;
}
export async function setYomitanSettingsFull(
value: Record<string, unknown>,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
source = 'subminer',
): Promise<boolean> {
const result = await invokeYomitanBackendAction<unknown>(
'setAllSettings',
{ value, source },
deps,
logger,
);
return result !== null;
}
export async function importYomitanDictionaryFromZip(
zipPath: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedZipPath = zipPath.trim();
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
logger.error(`Dictionary ZIP not found: ${zipPath}`);
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);
return result === true;
}
export async function deleteYomitanDictionaryByTitle(
dictionaryTitle: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const result = await invokeYomitanSettingsAutomation<boolean>(
`
(async () => {
await globalThis.__subminerYomitanSettingsAutomation.deleteDictionary(
${JSON.stringify(normalizedTitle)}
);
return true;
})();
`,
deps,
logger,
);
return result === true;
}
export async function upsertYomitanDictionarySettings(
dictionaryTitle: string,
profileScope: 'all' | 'active',
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const optionsFull = await getYomitanSettingsFull(deps, logger);
if (!optionsFull) {
return false;
}
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
const indices = getTargetProfileIndices(optionsFull, profileScope);
let changed = false;
for (const index of indices) {
const profile = profiles[index];
if (!isObject(profile)) {
continue;
}
if (!isObject(profile.options)) {
profile.options = {};
}
const profileOptions = profile.options as Record<string, unknown>;
if (!Array.isArray(profileOptions.dictionaries)) {
profileOptions.dictionaries = [];
}
const dictionaries = profileOptions.dictionaries as unknown[];
const existingIndex = dictionaries.findIndex(
(entry) =>
isObject(entry) &&
typeof (entry as { name?: unknown }).name === 'string' &&
((entry as { name: string }).name.trim() === normalizedTitle),
);
if (existingIndex >= 0) {
const existing = dictionaries[existingIndex] as Record<string, unknown>;
if (existing.enabled !== true) {
existing.enabled = true;
changed = true;
}
if (typeof existing.alias !== 'string' || existing.alias.trim().length === 0) {
existing.alias = normalizedTitle;
changed = true;
}
if (existingIndex > 0) {
dictionaries.splice(existingIndex, 1);
dictionaries.unshift(existing);
changed = true;
}
continue;
}
dictionaries.unshift(createDefaultDictionarySettings(normalizedTitle, true));
changed = true;
}
if (!changed) {
return false;
}
return await setYomitanSettingsFull(optionsFull, deps, logger);
}
export async function removeYomitanDictionarySettings(
dictionaryTitle: string,
profileScope: 'all' | 'active',
mode: 'delete' | 'disable',
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
): Promise<boolean> {
const normalizedTitle = dictionaryTitle.trim();
if (!normalizedTitle) {
return false;
}
const optionsFull = await getYomitanSettingsFull(deps, logger);
if (!optionsFull) {
return false;
}
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
const indices = getTargetProfileIndices(optionsFull, profileScope);
let changed = false;
for (const index of indices) {
const profile = profiles[index];
if (!isObject(profile) || !isObject(profile.options)) {
continue;
}
const profileOptions = profile.options as Record<string, unknown>;
if (!Array.isArray(profileOptions.dictionaries)) {
continue;
}
const dictionaries = profileOptions.dictionaries as unknown[];
if (mode === 'delete') {
const before = dictionaries.length;
profileOptions.dictionaries = dictionaries.filter(
(entry) =>
!(
isObject(entry) &&
typeof (entry as { name?: unknown }).name === 'string' &&
(entry as { name: string }).name.trim() === normalizedTitle
),
);
if ((profileOptions.dictionaries as unknown[]).length !== before) {
changed = true;
}
continue;
}
for (const entry of dictionaries) {
if (
!isObject(entry) ||
typeof (entry as { name?: unknown }).name !== 'string' ||
(entry as { name: string }).name.trim() !== normalizedTitle
) {
continue;
}
const dictionaryEntry = entry as Record<string, unknown>;
if (dictionaryEntry.enabled !== false) {
dictionaryEntry.enabled = false;
changed = true;
}
}
}
if (!changed) {
return false;
}
return await setYomitanSettingsFull(optionsFull, deps, logger);
}

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -17,6 +18,41 @@ function readManifestVersion(manifestPath: string): string | null {
}
}
export function hashDirectoryContents(dirPath: string): string | null {
try {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return null;
}
const hash = createHash('sha256');
const queue = [''];
while (queue.length > 0) {
const relativeDir = queue.shift()!;
const absoluteDir = path.join(dirPath, relativeDir);
const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const normalizedRelativePath = relativePath.split(path.sep).join('/');
hash.update(normalizedRelativePath);
if (entry.isDirectory()) {
queue.push(relativePath);
continue;
}
if (!entry.isFile()) {
continue;
}
hash.update(fs.readFileSync(path.join(dirPath, relativePath)));
}
}
return hash.digest('hex');
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
@@ -49,5 +85,32 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
}
}
return false;
const sourceHash = hashDirectoryContents(sourceDir);
const targetHash = hashDirectoryContents(targetDir);
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
}
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): {
targetDir: string;
copied: boolean;
} {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy) {
shouldCopy = hashDirectoryContents(sourceDir) !== hashDirectoryContents(targetDir);
}
if (shouldCopy) {
fs.mkdirSync(extensionsRoot, { recursive: true });
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
}
return { targetDir, copied: shouldCopy };
}

View File

@@ -4,7 +4,11 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function writeFile(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -12,41 +16,66 @@ function writeFile(filePath: string, content: string): void {
}
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
});
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
test('shouldCopyYomitanExtension skips copy when extension contents match', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
const targetDir = path.join(tempRoot, 'target');
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
});
test('ensureExtensionCopy refreshes copied extension when display files change', () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
'new display code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'old display code',
);
const result = ensureExtensionCopy(sourceDir, userDataRoot);
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
assert.equal(
fs.readFileSync(path.join(targetDir, 'js', 'display', 'structured-content-generator.js'), 'utf8'),
'new display code',
);
});

View File

@@ -2,7 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
import { ensureExtensionCopy } from './yomitan-extension-copy';
const logger = createLogger('main:yomitan-extension-loader');
@@ -15,26 +15,6 @@ export interface YomitanExtensionLoaderDeps {
setYomitanExtension: (extension: Extension | null) => void;
}
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
if (process.platform === 'win32') {
return sourceDir;
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
if (shouldCopy) {
fs.mkdirSync(extensionsRoot, { recursive: true });
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
logger.info(`Copied yomitan extension to ${targetDir}`);
}
return targetDir;
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -60,7 +40,11 @@ export async function loadYomitanExtension(
return null;
}
extPath = ensureExtensionCopy(extPath, deps.userDataPath);
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
const parserWindow = deps.getYomitanParserWindow();
if (parserWindow && !parserWindow.isDestroyed()) {

View File

@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
class FakeStyle {
private values = new Map<string, string>();
set width(value: string) {
this.values.set('width', value);
}
get width(): string {
return this.values.get('width') ?? '';
}
set height(value: string) {
this.values.set('height', value);
}
get height(): string {
return this.values.get('height') ?? '';
}
set border(value: string) {
this.values.set('border', value);
}
set borderRadius(value: string) {
this.values.set('borderRadius', value);
}
set paddingTop(value: string) {
this.values.set('paddingTop', value);
}
setProperty(name: string, value: string): void {
this.values.set(name, value);
}
removeProperty(name: string): void {
this.values.delete(name);
}
}
class FakeNode {
public childNodes: Array<FakeNode | FakeTextNode> = [];
public className = '';
public dataset: Record<string, string> = {};
public style = new FakeStyle();
public textContent: string | null = null;
public title = '';
public href = '';
public rel = '';
public target = '';
public width = 0;
public height = 0;
public parentNode: FakeNode | null = null;
constructor(public readonly tagName: string) {}
appendChild(node: FakeNode | FakeTextNode): FakeNode | FakeTextNode {
if (node instanceof FakeNode) {
node.parentNode = this;
}
this.childNodes.push(node);
return node;
}
addEventListener(): void {}
closest(selector: string): FakeNode | null {
if (!selector.startsWith('.')) {
return null;
}
const className = selector.slice(1);
let current: FakeNode | null = this;
while (current) {
if (current.className === className) {
return current;
}
current = current.parentNode;
}
return null;
}
removeAttribute(name: string): void {
if (name === 'src') {
return;
}
if (name === 'href') {
this.href = '';
}
}
}
class FakeImageElement extends FakeNode {
public onload: (() => void) | null = null;
public onerror: ((error: unknown) => void) | null = null;
private _src = '';
constructor() {
super('img');
}
set src(value: string) {
this._src = value;
this.onload?.();
}
get src(): string {
return this._src;
}
}
class FakeCanvasElement extends FakeNode {
constructor() {
super('canvas');
}
}
class FakeTextNode {
constructor(public readonly data: string) {}
}
class FakeDocument {
createElement(tagName: string): FakeNode {
if (tagName === 'img') {
return new FakeImageElement();
}
if (tagName === 'canvas') {
return new FakeCanvasElement();
}
return new FakeNode(tagName);
}
createTextNode(data: string): FakeTextNode {
return new FakeTextNode(data);
}
}
function findFirstByClass(node: FakeNode, className: string): FakeNode | null {
if (node.className === className) {
return node;
}
for (const child of node.childNodes) {
if (child instanceof FakeNode) {
const result = findFirstByClass(child, className);
if (result) {
return result;
}
}
}
return null;
}
test('StructuredContentGenerator uses direct img loading for popup glossary images', async () => {
const { DisplayContentManager } = await import(
pathToFileURL(
path.join(process.cwd(), 'vendor/yomitan/js/display/display-content-manager.js'),
).href
);
const { StructuredContentGenerator } = await import(
pathToFileURL(
path.join(process.cwd(), 'vendor/yomitan/js/display/structured-content-generator.js'),
).href
);
const createObjectURLCalls: string[] = [];
const revokeObjectURLCalls: string[] = [];
const originalHtmlImageElement = globalThis.HTMLImageElement;
const originalHtmlCanvasElement = globalThis.HTMLCanvasElement;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
globalThis.HTMLImageElement = FakeImageElement as unknown as typeof HTMLImageElement;
globalThis.HTMLCanvasElement = FakeCanvasElement as unknown as typeof HTMLCanvasElement;
URL.createObjectURL = (_blob: Blob) => {
const value = 'blob:test-image';
createObjectURLCalls.push(value);
return value;
};
URL.revokeObjectURL = (value: string) => {
revokeObjectURLCalls.push(value);
};
try {
const manager = new DisplayContentManager({
application: {
api: {
getMedia: async () => [
{
content: Buffer.from('png-bytes').toString('base64'),
mediaType: 'image/png',
},
],
},
},
});
const generator = new StructuredContentGenerator(
manager,
new FakeDocument(),
{
devicePixelRatio: 1,
navigator: { userAgent: 'Mozilla/5.0' },
},
);
const node = generator.createDefinitionImage(
{
tag: 'img',
path: 'img/test.png',
width: 8,
height: 11,
title: 'Alpha',
background: true,
},
'SubMiner Character Dictionary',
) as FakeNode;
await manager.executeMediaRequests();
const imageNode = findFirstByClass(node, 'gloss-image');
assert.ok(imageNode);
assert.equal(imageNode.tagName, 'img');
assert.equal((imageNode as FakeImageElement).src, 'blob:test-image');
assert.equal(node.dataset.imageLoadState, 'loaded');
assert.equal(node.dataset.hasImage, 'true');
assert.deepEqual(createObjectURLCalls, ['blob:test-image']);
manager.unloadAll();
assert.deepEqual(revokeObjectURLCalls, ['blob:test-image']);
} finally {
globalThis.HTMLImageElement = originalHtmlImageElement;
globalThis.HTMLCanvasElement = originalHtmlCanvasElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});