feat(character-dictionary): add manager modal and scope name matching to current media (#86)

This commit is contained in:
2026-05-25 18:29:20 -07:00
committed by GitHub
parent 097b619d71
commit 3932e53ced
71 changed files with 1896 additions and 127 deletions
@@ -119,3 +119,48 @@ test('buildSnapshotFromCharacters shows Japanese aliases without adding romanize
assert.equal(terms.includes('アクア'), true);
assert.equal(terms.includes('阿久亜'), true);
});
test('buildSnapshotFromCharacters stores media id in Yomitan structured-content data', () => {
const character: CharacterRecord = {
id: 1,
role: 'main',
firstNameHint: '',
fullName: 'Aqua',
lastNameHint: '',
nativeName: 'アクア',
alternativeNames: [],
bloodType: '',
birthday: null,
description: '',
imageUrl: null,
age: '',
sex: '',
voiceActors: [],
};
const snapshot = buildSnapshotFromCharacters(
21699,
"KONOSUBA -God's blessing on this wonderful world! 2",
[character],
new Map(),
new Map(),
1_700_000_000_000,
() => false,
);
const aquaEntry = snapshot.termEntries.find(([term]) => term === 'アクア');
assert.ok(aquaEntry);
const glossaryEntry = aquaEntry[5][0] as {
content: {
data?: Record<string, string>;
content: Array<Record<string, unknown>>;
};
};
assert.equal(glossaryEntry.content.data?.subminerMediaId, '21699');
assert.equal(
glossaryEntry.content.content.some((node) =>
Object.prototype.hasOwnProperty.call(node, 'subminerMediaId'),
),
false,
);
});
@@ -1,7 +1,7 @@
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export const ANILIST_REQUEST_DELAY_MS = 2000;
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 17;
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
export const HONORIFIC_SUFFIXES = [
@@ -146,6 +146,7 @@ function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | nu
export function createDefinitionGlossary(
character: CharacterRecord,
mediaId: number,
mediaTitle: string,
imagePath: string | null,
vaImagePaths: Map<number, string>,
@@ -258,7 +259,7 @@ export function createDefinitionGlossary(
return [
{
type: 'structured-content',
content: { tag: 'div', content },
content: { tag: 'div', data: { subminerMediaId: String(mediaId) }, content },
},
];
}
@@ -5,7 +5,10 @@ import * as path from 'path';
import test from 'node:test';
import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
import {
buildCharacterNameImageIndexFromSnapshots,
createCharacterDictionaryImageLookup,
} from './image-lookup';
import type { CharacterDictionarySnapshot } from './types';
const PNG_1X1_BASE64 =
@@ -119,3 +122,96 @@ test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes bef
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
});
test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', () => {
const outputDir = makeTempDir();
const towerSnapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 115230,
mediaTitle: 'Tower of God',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' },
},
],
0,
'',
],
],
images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }],
};
const konosubaSnapshot: CharacterDictionarySnapshot = {
...towerSnapshot,
mediaId: 21202,
mediaTitle: 'KonoSuba',
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m21202-c2.png', alt: 'Kazuma' },
},
],
0,
'',
],
],
images: [{ path: 'img/m21202-c2.png', dataBase64: 'KONOSUBA' }],
};
writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot);
writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
assert.equal(lookup.get('カズ', 21202)?.alt, 'Kazuma');
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 115230,
mediaTitle: 'Tower of God',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'カズ',
'かず',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' },
},
],
0,
'',
],
],
images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }],
};
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const lookup = createCharacterDictionaryImageLookup({ outputDir });
assert.equal(lookup.get('カズ', 21202), null);
assert.equal(lookup.get('カズ')?.alt, 'Kaz');
});
@@ -23,6 +23,14 @@ function normalizeLookupTerm(term: string): string {
return term.trim();
}
function normalizeLookupMediaId(mediaId: unknown): number | null {
if (typeof mediaId !== 'number' || !Number.isFinite(mediaId)) {
return null;
}
const normalized = Math.floor(mediaId);
return normalized > 0 ? normalized : null;
}
function getSnapshotsDir(outputDir: string): string {
return path.join(outputDir, 'snapshots');
}
@@ -209,8 +217,9 @@ export function buildCharacterNameImageIndexFromSnapshots(
export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string;
outputDir?: string;
getCurrentMediaId?: () => number | null | undefined;
}): {
get: (term: string) => CharacterNameImage | null;
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void;
} {
const outputDir =
@@ -218,10 +227,12 @@ export function createCharacterDictionaryImageLookup(deps: {
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
let signature: string | null = null;
let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
function refreshIfNeeded(): void {
if (!outputDir) {
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
signature = '';
return;
}
@@ -230,16 +241,29 @@ export function createCharacterDictionaryImageLookup(deps: {
return;
}
signature = nextSignature;
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
index = new Map<string, CharacterNameImage>();
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
for (const snapshot of readCachedSnapshots(outputDir)) {
appendSnapshotImages(index, snapshot);
const mediaIndex = new Map<string, CharacterNameImage>();
appendSnapshotImages(mediaIndex, snapshot);
if (mediaIndex.size > 0) {
indexByMediaId.set(snapshot.mediaId, mediaIndex);
}
}
}
return {
get(term: string): CharacterNameImage | null {
get(term: string, mediaId?: number | null): CharacterNameImage | null {
const normalizedTerm = normalizeLookupTerm(term);
if (!normalizedTerm) {
return null;
}
refreshIfNeeded();
const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null);
if (scopedMediaId !== null) {
return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null;
}
return index.get(normalizedTerm) ?? null;
},
invalidate(): void {
@@ -48,6 +48,7 @@ export function buildSnapshotFromCharacters(
const candidateTerms = buildNameTerms(character);
const glossary = createDefinitionGlossary(
character,
mediaId,
mediaTitle,
imagePath,
vaImagePaths,
+6
View File
@@ -98,6 +98,9 @@ export interface MainIpcRuntimeServiceDepsParams {
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot'];
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
@@ -272,6 +275,9 @@ export function createMainIpcRuntimeServiceDeps(
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot,
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
+4
View File
@@ -20,6 +20,7 @@ export interface OverlayShortcutRuntimeServiceInput {
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -53,6 +54,9 @@ export function createOverlayShortcutsRuntimeService(
openCharacterDictionary: () => {
input.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
input.openCharacterDictionaryManager();
},
openJimaku: () => {
input.openJimaku();
},
@@ -152,8 +152,5 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot
},
});
assert.deepEqual(calls, [
'sequencer:importing:importing',
'desktop:SubMiner:importing',
]);
assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']);
});
@@ -3,7 +3,12 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync';
import {
createCharacterDictionaryAutoSyncRuntimeService,
getCharacterDictionaryManagerSnapshot,
moveCharacterDictionaryManagedEntry,
removeCharacterDictionaryManagedEntry,
} from './character-dictionary-auto-sync';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
@@ -17,6 +22,94 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
return { promise, resolve };
}
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
const userDataPath = makeTempDir();
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(
statePath,
JSON.stringify(
{
activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God', '130298 - Eminence'],
mergedRevision: 'rev-1',
mergedDictionaryTitle: 'SubMiner Character Dictionary',
},
null,
2,
),
'utf8',
);
assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath).entries, [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{ mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false },
{ mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false },
]);
assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 130298, -1), {
ok: true,
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{ mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false },
{ mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false },
],
rebuildRequired: true,
});
const reorderedState = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
mergedRevision: string | null;
};
assert.equal(reorderedState.mergedRevision, null);
assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230), {
ok: true,
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{ mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false },
],
rebuildRequired: true,
});
});
test('character dictionary manager protects the actual current media after LRU reorder', () => {
const userDataPath = makeTempDir();
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(
statePath,
JSON.stringify(
{
activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God'],
mergedRevision: 'rev-1',
mergedDictionaryTitle: 'SubMiner Character Dictionary',
},
null,
2,
),
'utf8',
);
assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath, 115230).entries, [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false },
{ mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true },
]);
assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 115230, -1, 115230), {
ok: false,
message: 'The current anime stays anchored while you are watching it.',
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false },
{ mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true },
],
});
assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230, 115230), {
ok: false,
message: 'The current anime stays loaded while you are watching it.',
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false },
{ mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true },
],
});
});
test('auto sync imports merged dictionary and persists MRU state', async () => {
const userDataPath = makeTempDir();
const imported: string[] = [];
@@ -24,6 +24,21 @@ type AutoSyncDictionaryInfo = {
revision?: string | number;
};
export interface CharacterDictionaryManagerEntry {
mediaId: number;
label: string;
title: string;
current: boolean;
}
export interface CharacterDictionaryManagerSnapshot {
entries: CharacterDictionaryManagerEntry[];
}
export type CharacterDictionaryManagerMutationResult =
| (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean })
| { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] };
export interface CharacterDictionaryAutoSyncConfig {
enabled: boolean;
maxLoaded: number;
@@ -154,6 +169,167 @@ function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8');
}
function getAutoSyncStatePath(userDataPath: string): string {
return path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
}
function parseActiveMediaTitle(entry: AutoSyncMediaEntry): string {
const prefix = `${entry.mediaId} - `;
if (entry.label.startsWith(prefix)) {
return entry.label.slice(prefix.length).trim();
}
return entry.label === String(entry.mediaId) ? '' : entry.label.trim();
}
function resolveCurrentManagerMediaId(
state: AutoSyncState,
currentMediaId?: number | null,
): number | null {
const normalizedCurrentMediaId =
typeof currentMediaId === 'number' ? normalizeMediaId(currentMediaId) : null;
if (normalizedCurrentMediaId !== null) return normalizedCurrentMediaId;
return state.activeMediaIds[0]?.mediaId ?? null;
}
function toManagerEntries(
state: AutoSyncState,
currentMediaId?: number | null,
): CharacterDictionaryManagerEntry[] {
const resolvedCurrentMediaId = resolveCurrentManagerMediaId(state, currentMediaId);
return state.activeMediaIds.map((entry, index) => ({
mediaId: entry.mediaId,
label: entry.label,
title: parseActiveMediaTitle(entry),
current:
resolvedCurrentMediaId !== null ? entry.mediaId === resolvedCurrentMediaId : index === 0,
}));
}
export function getCharacterDictionaryManagerSnapshot(
userDataPath: string,
currentMediaId?: number | null,
): CharacterDictionaryManagerSnapshot {
return {
entries: toManagerEntries(
readAutoSyncState(getAutoSyncStatePath(userDataPath)),
currentMediaId,
),
};
}
export function moveCharacterDictionaryManagedEntry(
userDataPath: string,
mediaId: number,
direction: 1 | -1,
currentMediaId?: number | null,
): CharacterDictionaryManagerMutationResult {
const statePath = getAutoSyncStatePath(userDataPath);
const state = readAutoSyncState(statePath);
const managerEntries = toManagerEntries(state, currentMediaId);
const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId);
if (index < 0) {
return {
ok: false,
message: 'Character dictionary entry not found.',
entries: managerEntries,
};
}
if (managerEntries[index]?.current) {
return {
ok: false,
message: 'The current anime stays anchored while you are watching it.',
entries: managerEntries,
};
}
const targetIndex = Math.min(state.activeMediaIds.length - 1, Math.max(0, index + direction));
if (targetIndex === index) {
return { ok: true, entries: managerEntries };
}
const nextActiveMediaIds = [...state.activeMediaIds];
const [entry] = nextActiveMediaIds.splice(index, 1);
if (entry) {
nextActiveMediaIds.splice(targetIndex, 0, entry);
}
const nextState = { ...state, activeMediaIds: nextActiveMediaIds, mergedRevision: null };
writeAutoSyncState(statePath, nextState);
return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true };
}
export function removeCharacterDictionaryManagedEntry(
userDataPath: string,
mediaId: number,
currentMediaId?: number | null,
): CharacterDictionaryManagerMutationResult {
const statePath = getAutoSyncStatePath(userDataPath);
const state = readAutoSyncState(statePath);
const managerEntries = toManagerEntries(state, currentMediaId);
const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId);
if (index < 0) {
return {
ok: false,
message: 'Character dictionary entry not found.',
entries: managerEntries,
};
}
if (managerEntries[index]?.current) {
return {
ok: false,
message: 'The current anime stays loaded while you are watching it.',
entries: managerEntries,
};
}
const nextState = {
...state,
activeMediaIds: state.activeMediaIds.filter((entry) => entry.mediaId !== mediaId),
mergedRevision: null,
};
writeAutoSyncState(statePath, nextState);
return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true };
}
export function replaceCharacterDictionaryManagedEntry(
userDataPath: string,
mediaId: number,
replacement: { mediaId: number; mediaTitle: string },
): CharacterDictionaryManagerMutationResult {
const statePath = getAutoSyncStatePath(userDataPath);
const state = readAutoSyncState(statePath);
const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId);
if (index < 0) {
return {
ok: false,
message: 'Character dictionary entry not found.',
entries: toManagerEntries(state),
};
}
const normalizedReplacementMediaId = normalizeMediaId(replacement.mediaId);
const mediaTitle = replacement.mediaTitle.trim();
if (normalizedReplacementMediaId === null || !mediaTitle) {
return {
ok: false,
message: 'Invalid replacement AniList media.',
entries: toManagerEntries(state),
};
}
const replacementEntry = {
mediaId: normalizedReplacementMediaId,
label: buildActiveMediaLabel(normalizedReplacementMediaId, mediaTitle),
};
const nextActiveMediaIds = state.activeMediaIds
.map((entry, entryIndex) => (entryIndex === index ? replacementEntry : entry))
.filter(
(entry, entryIndex, entries) =>
entries.findIndex((candidate) => candidate.mediaId === entry.mediaId) === entryIndex,
);
const nextState = {
...state,
activeMediaIds: nextActiveMediaIds,
mergedRevision: null,
};
writeAutoSyncState(statePath, nextState);
return { ok: true, entries: toManagerEntries(nextState), rebuildRequired: true };
}
function arraysEqual(left: number[], right: number[]): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
@@ -205,9 +381,10 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
): {
scheduleSync: () => void;
runSyncNow: () => Promise<void>;
getCurrentMediaId: () => number | null;
} {
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
const statePath = getAutoSyncStatePath(deps.userDataPath);
const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
const debounceMs = 800;
@@ -216,6 +393,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let syncInFlight = false;
let runQueued = false;
let activeCurrentMediaId: number | null = null;
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -238,6 +416,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
const runSyncOnce = async (): Promise<void> => {
const config = deps.getConfig();
if (!config.enabled) {
activeCurrentMediaId = null;
return;
}
@@ -250,6 +429,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
onChecking: ({ mediaId, mediaTitle }) => {
currentMediaId = mediaId;
currentMediaTitle = mediaTitle;
activeCurrentMediaId = mediaId;
deps.onSyncStatus?.({
phase: 'checking',
mediaId,
@@ -260,6 +440,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
onGenerating: ({ mediaId, mediaTitle }) => {
currentMediaId = mediaId;
currentMediaTitle = mediaTitle;
activeCurrentMediaId = mediaId;
deps.onSyncStatus?.({
phase: 'generating',
mediaId,
@@ -270,6 +451,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
});
currentMediaId = snapshot.mediaId;
currentMediaTitle = snapshot.mediaTitle;
activeCurrentMediaId = snapshot.mediaId;
const state = readAutoSyncState(statePath);
const staleMediaIds = new Set(
(snapshot.staleMediaIds ?? [])
@@ -453,5 +635,6 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
runSyncNow: async () => {
await runSyncOnce();
},
getCurrentMediaId: () => activeCurrentMediaId,
};
}
+32 -4
View File
@@ -5,7 +5,7 @@ import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
export async function openCharacterDictionaryModal(deps: {
async function openCharacterDictionaryModalChannel(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
@@ -18,6 +18,8 @@ export async function openCharacterDictionaryModal(deps: {
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
channel: string;
retryWarning: string;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
@@ -27,8 +29,7 @@ export async function openCharacterDictionaryModal(deps: {
{
modal: CHARACTER_DICTIONARY_MODAL,
timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS,
retryWarning:
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
retryWarning: deps.retryWarning,
sendOpen: () =>
openOverlayHostedModal(
{
@@ -38,7 +39,7 @@ export async function openCharacterDictionaryModal(deps: {
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.characterDictionaryOpen,
channel: deps.channel,
modal: CHARACTER_DICTIONARY_MODAL,
preferModalWindow: true,
},
@@ -46,3 +47,30 @@ export async function openCharacterDictionaryModal(deps: {
},
);
}
type OpenCharacterDictionaryModalDeps = Omit<
Parameters<typeof openCharacterDictionaryModalChannel>[0],
'channel' | 'retryWarning'
>;
export async function openCharacterDictionaryModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
return await openCharacterDictionaryModalChannel({
...deps,
channel: IPC_CHANNELS.event.characterDictionaryOpen,
retryWarning:
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
});
}
export async function openCharacterDictionaryManagerModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
return await openCharacterDictionaryModalChannel({
...deps,
channel: IPC_CHANNELS.event.characterDictionaryManagerOpen,
retryWarning:
'Character dictionary manager did not acknowledge modal open on first attempt; retrying dedicated modal window.',
});
}
@@ -16,7 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
+1 -1
View File
@@ -20,7 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -17,6 +17,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openJimaku: () => calls.push('jimaku'),
markAudioCard: async () => {
calls.push('mark-audio');
@@ -49,6 +50,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
deps.showMpvOsd('x');
deps.openRuntimeOptionsPalette();
deps.openCharacterDictionary();
deps.openCharacterDictionaryManager();
deps.openJimaku();
await deps.markAudioCard();
deps.copySubtitleMultiple(5000);
@@ -66,6 +68,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
'osd:x',
'runtime-options',
'character-dictionary',
'character-dictionary-manager',
'jimaku',
'mark-audio',
'copy-multi:5000',
@@ -12,6 +12,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionary: () => deps.openCharacterDictionary(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(),
markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
@@ -6,6 +6,9 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
getCurrentCharacterDictionaryMediaId?: NonNullable<
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
@@ -70,6 +73,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
}
: {}),
...(deps.getCurrentCharacterDictionaryMediaId
? {
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
+2 -4
View File
@@ -66,8 +66,7 @@ test('build tray template handler wires actions and init guards', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
@@ -118,8 +117,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => false,
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
+1 -2
View File
@@ -28,8 +28,7 @@ test('tray main deps builders return mapped handlers', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),