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