mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-07 03:22:17 -08:00
feat: add AniList character dictionary sync
This commit is contained in:
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
239
src/core/services/yomitan-structured-content-generator.test.ts
Normal file
239
src/core/services/yomitan-structured-content-generator.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user