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
+1 -1
View File
@@ -43,7 +43,7 @@ ${B}Mining${R}
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
--open-character-dictionary Open character dictionary anime selection modal
--open-character-dictionary Open character dictionary management modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
+2 -1
View File
@@ -96,7 +96,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal('openCharacterDictionary' in config.shortcuts, false);
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
+1 -1
View File
@@ -88,7 +88,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
multiCopyTimeoutMs: 3000,
toggleSecondarySub: 'CommandOrControl+Shift+V',
markAudioCard: 'CommandOrControl+Shift+A',
openCharacterDictionary: 'CommandOrControl+Alt+A',
openCharacterDictionaryManager: 'CommandOrControl+D',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Slash',
+3 -3
View File
@@ -542,10 +542,10 @@ export function buildCoreConfigOptionRegistry(
description: 'Accelerator that marks the last mined card as an audio card.',
},
{
path: 'shortcuts.openCharacterDictionary',
path: 'shortcuts.openCharacterDictionaryManager',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
description: 'Accelerator that opens the character dictionary modal.',
defaultValue: defaultConfig.shortcuts.openCharacterDictionaryManager,
description: 'Accelerator that opens the character dictionary manager modal.',
},
{
path: 'shortcuts.openRuntimeOptions',
+1 -1
View File
@@ -207,7 +207,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
] as const;
+13
View File
@@ -54,9 +54,22 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
});
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
assert.equal(
fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'),
false,
);
assert.equal(
field('shortcuts.openCharacterDictionaryManager').label,
'Open Character Dictionary Manager',
);
assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
});
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'shortcuts.openCharacterDictionary',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
]) {
+2 -2
View File
@@ -208,7 +208,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionary': 'Open AniList Override',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
@@ -570,7 +570,7 @@ function subsectionForPath(path: string): string | undefined {
return 'Toggle & Visibility';
}
if (
leaf === 'openCharacterDictionary' ||
leaf === 'openCharacterDictionaryManager' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openSessionHelp' ||
+16
View File
@@ -801,6 +801,22 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
});
});
test('handleCliCommand opens character dictionary manager from CLI flag', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+2 -2
View File
@@ -492,8 +492,8 @@ export function handleCliCommand(
);
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionary' },
'openCharacterDictionary',
{ actionId: 'openCharacterDictionaryManager' },
'openCharacterDictionaryManager',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) {
@@ -106,6 +106,40 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
);
});
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
const buildDispatches = buildHyprlandPlacementDispatches as (
client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
bounds: Parameters<typeof buildHyprlandPlacementDispatches>[1],
options: { promote: false },
) => string[][];
assert.deepEqual(
buildDispatches(
{
address: '0xabc',
floating: true,
pinned: false,
},
{
x: 0,
y: 0,
width: 1920,
height: 1080,
},
{ promote: false },
),
[
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
['dispatch', 'setprop', 'address:0xabc rounding 0'],
['dispatch', 'setprop', 'address:0xabc border_size 0'],
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
['dispatch', 'setprop', 'address:0xabc no_blur 1'],
['dispatch', 'setprop', 'address:0xabc decorate 0'],
],
);
});
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
assert.deepEqual(
buildHyprlandPlacementDispatches({
+12 -2
View File
@@ -18,6 +18,10 @@ export interface HyprlandPlacementBounds {
height: number;
}
export interface HyprlandPlacementDispatchOptions {
promote?: boolean;
}
type ExecFileSync = typeof execFileSync;
export function shouldAttemptHyprlandWindowPlacement(
@@ -64,6 +68,7 @@ export function findHyprlandWindowForPlacement(
export function buildHyprlandPlacementDispatches(
client: HyprlandPlacementClient,
bounds?: HyprlandPlacementBounds | null,
options: HyprlandPlacementDispatchOptions = {},
): string[][] {
if (!client.address) {
return [];
@@ -95,7 +100,9 @@ export function buildHyprlandPlacementDispatches(
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
}
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
if (options.promote !== false) {
dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]);
}
return dispatches;
}
@@ -127,6 +134,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
pid?: number;
promote?: boolean;
execFileSync?: ExecFileSync;
}): boolean {
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
@@ -146,7 +154,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
return false;
}
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds);
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, {
promote: options.promote,
});
for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' });
}
+82 -4
View File
@@ -96,7 +96,14 @@ export interface IpcServiceDeps {
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
setCharacterDictionarySelection?: (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) => Promise<unknown>;
getCharacterDictionaryManagerSnapshot?: () => Promise<unknown>;
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -224,7 +231,14 @@ export interface IpcDepsRuntimeOptions {
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
setCharacterDictionarySelection?: (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) => Promise<unknown>;
getCharacterDictionaryManagerSnapshot?: () => Promise<unknown>;
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -317,6 +331,22 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
})),
getCharacterDictionaryManagerSnapshot:
options.getCharacterDictionaryManagerSnapshot ?? (async () => ({ entries: [] })),
removeCharacterDictionaryManagedEntry:
options.removeCharacterDictionaryManagedEntry ??
(async () => ({
ok: false,
message: 'Character dictionary manager unavailable.',
entries: [],
})),
moveCharacterDictionaryManagedEntry:
options.moveCharacterDictionaryManagedEntry ??
(async () => ({
ok: false,
message: 'Character dictionary manager unavailable.',
entries: [],
})),
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
@@ -629,11 +659,21 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
ipc.handle(
IPC_CHANNELS.request.setCharacterDictionarySelection,
async (_event, mediaId: unknown) => {
async (_event, mediaId: unknown, replaceManagedMediaId: unknown, mediaTitle: unknown) => {
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
return { ok: false, message: 'Invalid AniList media ID.' };
}
return await (deps.setCharacterDictionarySelection?.(mediaId as number) ??
const normalizedReplaceManagedMediaId =
Number.isSafeInteger(replaceManagedMediaId) && (replaceManagedMediaId as number) > 0
? (replaceManagedMediaId as number)
: undefined;
const normalizedMediaTitle =
typeof mediaTitle === 'string' && mediaTitle.trim() ? mediaTitle.trim() : undefined;
return await (deps.setCharacterDictionarySelection?.(
mediaId as number,
normalizedReplaceManagedMediaId,
normalizedMediaTitle,
) ??
Promise.resolve({
ok: false,
message: 'Character dictionary selection unavailable.',
@@ -641,6 +681,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.handle(IPC_CHANNELS.request.getCharacterDictionaryManagerSnapshot, async () => {
return await (deps.getCharacterDictionaryManagerSnapshot?.() ??
Promise.resolve({ entries: [] }));
});
ipc.handle(
IPC_CHANNELS.request.removeCharacterDictionaryManagedEntry,
async (_event, mediaId: unknown) => {
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
return { ok: false, message: 'Invalid AniList media ID.', entries: [] };
}
return await (deps.removeCharacterDictionaryManagedEntry?.(mediaId as number) ??
Promise.resolve({
ok: false,
message: 'Character dictionary manager unavailable.',
entries: [],
}));
},
);
ipc.handle(
IPC_CHANNELS.request.moveCharacterDictionaryManagedEntry,
async (_event, mediaId: unknown, direction: unknown) => {
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
return { ok: false, message: 'Invalid AniList media ID.', entries: [] };
}
if (direction !== 1 && direction !== -1) {
return { ok: false, message: 'Invalid move direction.', entries: [] };
}
return await (deps.moveCharacterDictionaryManagedEntry?.(mediaId as number, direction) ??
Promise.resolve({
ok: false,
message: 'Character dictionary manager unavailable.',
entries: [],
}));
},
);
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
return deps.appendClipboardVideoToQueue();
});
+26
View File
@@ -110,6 +110,32 @@ test('overlay manager applies bounds for main and modal windows', () => {
assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]);
});
test('overlay manager can suppress z-order promotion during bounds updates', () => {
const calls: string[] = [];
const createManager = createOverlayManager as unknown as (options: {
updateOverlayWindowBounds: (
geometry: Electron.Rectangle,
window: Electron.BrowserWindow | null,
options: { promote: boolean },
) => void;
shouldPromoteWindowOnBoundsUpdate: (window: Electron.BrowserWindow) => boolean;
}) => ReturnType<typeof createOverlayManager>;
const manager = createManager({
updateOverlayWindowBounds: (_geometry, _window, options) => {
calls.push(`promote:${options.promote}`);
},
shouldPromoteWindowOnBoundsUpdate: () => false,
});
manager.setMainWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
manager.setOverlayWindowBounds({ x: 1, y: 2, width: 3, height: 4 });
assert.deepEqual(calls, ['promote:false']);
});
test('runtime-option broadcast still uses expected channel', () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntime(
+16 -3
View File
@@ -16,10 +16,23 @@ export interface OverlayManager {
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
}
export function createOverlayManager(): OverlayManager {
type UpdateOverlayWindowBounds = typeof updateOverlayWindowBounds;
export interface OverlayManagerOptions {
updateOverlayWindowBounds?: UpdateOverlayWindowBounds;
shouldPromoteWindowOnBoundsUpdate?: (window: BrowserWindow) => boolean;
}
export function createOverlayManager(options: OverlayManagerOptions = {}): OverlayManager {
let mainWindow: BrowserWindow | null = null;
let modalWindow: BrowserWindow | null = null;
let visibleOverlayVisible = false;
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
applyOverlayBounds(geometry, window, { promote });
};
return {
getMainWindow: () => mainWindow,
@@ -32,10 +45,10 @@ export function createOverlayManager(): OverlayManager {
},
getOverlayWindow: () => mainWindow,
setOverlayWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, mainWindow);
updateWindowBounds(geometry, mainWindow);
},
setModalWindowBounds: (geometry) => {
updateOverlayWindowBounds(geometry, modalWindow);
updateWindowBounds(geometry, modalWindow);
},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
@@ -25,7 +25,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -49,6 +49,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openCharacterDictionary: () => {
calls.push('openCharacterDictionary');
},
openCharacterDictionaryManager: () => {
calls.push('openCharacterDictionaryManager');
},
openJimaku: () => {
calls.push('openJimaku');
},
@@ -93,6 +96,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers',
overlayHandlers.copySubtitleMultiple(1111);
overlayHandlers.toggleSecondarySub();
overlayHandlers.openRuntimeOptions();
overlayHandlers.openCharacterDictionaryManager();
overlayHandlers.openJimaku();
overlayHandlers.mineSentenceMultiple(2222);
overlayHandlers.updateLastCardFromClipboard();
@@ -104,6 +108,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers',
'copySubtitleMultiple:1111',
'toggleSecondarySub',
'openRuntimeOptions',
'openCharacterDictionaryManager',
'openJimaku',
'mineSentenceMultiple:2222',
'updateLastCardFromClipboard',
@@ -159,6 +164,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
@@ -192,6 +198,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
@@ -212,6 +219,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
@@ -249,6 +257,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
@@ -285,6 +294,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
@@ -315,6 +325,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openCharacterDictionary: () => {
called = true;
},
openCharacterDictionaryManager: () => {
called = true;
},
openJimaku: () => {
called = true;
},
@@ -398,6 +411,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -425,6 +439,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -7,6 +7,7 @@ const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -23,6 +24,7 @@ export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -100,6 +102,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openCharacterDictionary: () => {
deps.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
deps.openCharacterDictionaryManager();
},
openJimaku: () => {
deps.openJimaku();
},
@@ -108,6 +113,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
@@ -141,9 +147,9 @@ export function runOverlayShortcutLocalFallback(
},
},
{
accelerator: shortcuts.openCharacterDictionary,
accelerator: shortcuts.openCharacterDictionaryManager,
run: () => {
handlers.openCharacterDictionary();
handlers.openCharacterDictionaryManager();
},
},
{
+4 -1
View File
@@ -20,7 +20,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -44,6 +44,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -64,6 +65,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -86,6 +88,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
+2 -1
View File
@@ -11,6 +11,7 @@ export interface OverlayShortcutHandlers {
toggleSecondarySub: () => void;
markAudioCard: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
}
@@ -32,7 +33,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
];
+8 -1
View File
@@ -51,11 +51,18 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
export function updateOverlayWindowBounds(
geometry: WindowGeometry,
window: BrowserWindow | null,
options: {
promote?: boolean;
} = {},
): void {
if (!geometry || !window || window.isDestroyed()) return;
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
window.setBounds(bounds);
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), bounds });
ensureHyprlandWindowFloatingByTitle({
title: window.getTitle(),
bounds,
promote: options.promote,
});
}
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
@@ -35,6 +35,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
@@ -77,3 +78,11 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']);
});
test('dispatchSessionAction opens the character dictionary manager', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction({ actionId: 'openCharacterDictionaryManager' }, deps);
assert.deepEqual(calls, ['character-dictionary-manager']);
});
+5 -1
View File
@@ -19,6 +19,7 @@ export interface SessionActionExecutorDeps {
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
@@ -97,7 +98,10 @@ export async function dispatchSessionAction(
deps.openSessionHelp();
return;
case 'openCharacterDictionary':
deps.openCharacterDictionary();
deps.openCharacterDictionaryManager();
return;
case 'openCharacterDictionaryManager':
deps.openCharacterDictionaryManager();
return;
case 'openControllerSelect':
deps.openControllerSelect();
+37 -2
View File
@@ -19,7 +19,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -209,6 +209,41 @@ test('compileSessionBindings keeps default replay and next subtitle session acti
assert.equal(next?.actionId, 'playNextSubtitle');
});
test('compileSessionBindings keeps only the character dictionary manager bound by default', () => {
const result = compileSessionBindings({
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
keybindings: DEFAULT_KEYBINDINGS,
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
platform: 'linux',
rawConfig: DEFAULT_CONFIG,
});
const characterDictionaryBindings = result.bindings.flatMap((binding) => {
if (binding.actionType !== 'session-action') return [];
if (
binding.actionId !== 'openCharacterDictionary' &&
binding.actionId !== 'openCharacterDictionaryManager'
) {
return [];
}
return [
{
sourcePath: binding.sourcePath,
originalKey: binding.originalKey,
actionId: binding.actionId,
},
];
});
assert.deepEqual(characterDictionaryBindings, [
{
sourcePath: 'shortcuts.openCharacterDictionaryManager',
originalKey: 'CommandOrControl+D',
actionId: 'openCharacterDictionaryManager',
},
]);
});
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
@@ -411,7 +446,7 @@ test('compileSessionBindings wires every configured shortcut key into the shared
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'openSessionHelp',
+1 -1
View File
@@ -44,7 +44,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' },
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
+4
View File
@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getCurrentCharacterDictionaryMediaId?: () => number | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -85,6 +86,7 @@ export interface TokenizerDepsRuntimeOptions {
getNameMatchEnabled?: () => boolean;
getNameMatchImagesEnabled?: () => boolean;
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
getCurrentCharacterDictionaryMediaId?: () => number | null;
getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup;
@@ -237,6 +239,7 @@ export function createTokenizerDepsRuntime(
getNameMatchEnabled: options.getNameMatchEnabled,
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
getCharacterNameImage: options.getCharacterNameImage,
getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank,
@@ -708,6 +711,7 @@ async function parseWithYomitanInternalParser(
): Promise<MergedToken[] | null> {
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
includeNameMatchMetadata: options.nameMatchEnabled,
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
});
if (!selectedTokens || selectedTokens.length === 0) {
return null;
@@ -1281,6 +1281,158 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
});
test('requestYomitanScanTokens ignores SubMiner character entries from other media', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
return null;
});
await requestYomitanScanTokens(
'カズ',
deps,
{ error: () => undefined },
{ includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21202 },
);
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text;
if (text !== 'カズ') {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 2,
dictionaryEntries: [
{
headwords: [
{
term: 'カズ',
reading: 'かず',
sources: [{ originalText: 'カズ', isPrimary: true, matchType: 'exact' }],
},
],
definitions: [
{
dictionary: 'SubMiner Character Dictionary',
dictionaryAlias: 'SubMiner Character Dictionary',
entries: [
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m115230-c9.png',
alt: 'Kaz',
},
},
],
},
],
},
],
};
});
assert.deepEqual(result, []);
});
test('requestYomitanScanTokens accepts SubMiner character entries with structured-content media data', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
return null;
});
await requestYomitanScanTokens(
'アクア',
deps,
{ error: () => undefined },
{ includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21699 },
);
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text;
if (text !== 'アクア') {
return { originalTextLength: 0, dictionaryEntries: [] };
}
return {
originalTextLength: 3,
dictionaryEntries: [
{
headwords: [
{
term: 'アクア',
reading: 'あくあ',
sources: [{ originalText: 'アクア', isPrimary: true, matchType: 'exact' }],
},
],
definitions: [
{
dictionary: 'SubMiner Character Dictionary',
dictionaryAlias: 'SubMiner Character Dictionary',
entries: [
{
type: 'structured-content',
content: {
tag: 'div',
data: { subminerMediaId: '21699' },
content: [
{
tag: 'img',
path: 'img/m115230-c1.png',
alt: 'アクア',
},
],
},
},
],
},
],
},
],
};
});
assert.equal(Array.isArray(result), true);
assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'アクア');
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
});
test('requestYomitanScanTokens preserves matched headword word classes', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
@@ -1106,11 +1106,85 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
}
return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary"));
}
const exactPrimaryMatches = collectExactHeadwordMatches(dictionaryEntries, token, true);
function parseSubMinerMediaIdFromString(value) {
const imageMatch = value.match(/\bimg\/m(\d+)-/i);
if (imageMatch) {
const parsed = Number.parseInt(imageMatch[1], 10);
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
}
const titleMatch = value.match(/SubMiner Character Dictionary[^\d]*(?:AniList\s*)?(\d+)/i);
if (titleMatch) {
const parsed = Number.parseInt(titleMatch[1], 10);
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
}
return null;
}
function parseSubMinerMediaIdCandidate(value) {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
return value;
}
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
}
return null;
}
function collectSubMinerMediaIds(value, target) {
if (typeof value === 'string') {
const parsed = parseSubMinerMediaIdFromString(value);
if (parsed !== null) { target.add(parsed); }
return;
}
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
for (const item of value) { collectSubMinerMediaIds(item, target); }
return;
}
const mediaIdCandidates = [
value.subminerMediaId,
value.subMinerMediaId,
value.characterDictionaryMediaId,
value.data?.subminerMediaId,
value.data?.subMinerMediaId,
value.data?.characterDictionaryMediaId
];
for (const candidate of mediaIdCandidates) {
const parsed = parseSubMinerMediaIdCandidate(candidate);
if (parsed !== null) { target.add(parsed); }
}
for (const child of Object.values(value)) {
collectSubMinerMediaIds(child, target);
}
}
function getSubMinerMediaIds(entry) {
const mediaIds = new Set();
collectSubMinerMediaIds(entry, mediaIds);
return mediaIds;
}
function isCurrentMediaNameDictionaryEntry(entry) {
if (!isNameDictionaryEntry(entry)) {
return false;
}
if (currentCharacterDictionaryMediaId === null) {
return true;
}
const mediaIds = getSubMinerMediaIds(entry);
return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId);
}
const currentMediaDictionaryEntries =
currentCharacterDictionaryMediaId === null
? (dictionaryEntries || [])
: (dictionaryEntries || []).filter((entry) => {
if (!isNameDictionaryEntry(entry)) { return true; }
return isCurrentMediaNameDictionaryEntry(entry);
});
const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true);
let matchedNameDictionary = false;
if (includeNameMatchMetadata) {
for (const dictionaryEntry of dictionaryEntries || []) {
if (!isNameDictionaryEntry(dictionaryEntry)) { continue; }
for (const dictionaryEntry of currentMediaDictionaryEntries || []) {
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
for (const match of exactPrimaryMatches) {
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
matchedNameDictionary = true;
@@ -1121,13 +1195,14 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
}
const preferredMatch = exactPrimaryMatches[0];
if (preferredMatch) {
const exactFrequencyMatches = collectExactHeadwordMatches(dictionaryEntries, token, false)
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
.filter((match) => sameHeadword(match, preferredMatch));
return {
term: preferredMatch.headword.term,
reading: preferredMatch.headword.reading,
wordClasses: normalizeWordClasses(preferredMatch.headword),
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry),
isNameMatch:
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
frequencyRank: getBestFrequencyRankForMatches(
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
dictionaryPriorityByName,
@@ -1144,6 +1219,7 @@ function buildYomitanScanningScript(
profileIndex: number,
scanLength: number,
includeNameMatchMetadata: boolean,
currentCharacterDictionaryMediaId: number | null,
dictionaryPriorityByName: Record<string, number>,
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
): string {
@@ -1169,6 +1245,11 @@ function buildYomitanScanningScript(
});
${YOMITAN_SCANNING_HELPERS}
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
const currentCharacterDictionaryMediaId = ${
currentCharacterDictionaryMediaId !== null
? String(currentCharacterDictionaryMediaId)
: 'null'
};
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
const text = ${JSON.stringify(text)};
@@ -1320,6 +1401,7 @@ export async function requestYomitanScanTokens(
logger: LoggerLike,
options?: {
includeNameMatchMetadata?: boolean;
currentCharacterDictionaryMediaId?: number | null;
},
): Promise<YomitanScanToken[] | null> {
const yomitanExt = deps.getYomitanExt();
@@ -1355,6 +1437,11 @@ export async function requestYomitanScanTokens(
profileIndex,
scanLength,
options?.includeNameMatchMetadata === true,
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
options.currentCharacterDictionaryMediaId > 0
? Math.floor(options.currentCharacterDictionaryMediaId)
: null,
metadata?.dictionaryPriorityByName ?? {},
metadata?.dictionaryFrequencyModeByName ?? {},
),
+2 -2
View File
@@ -66,7 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
shortcuts: {
mineSentence: 'KeyQ',
openRuntimeOptions: 'Digit9',
openCharacterDictionary: 'Ctrl+Shift+KeyA',
openCharacterDictionaryManager: 'Ctrl+KeyD',
},
};
@@ -74,7 +74,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
assert.equal(resolved.mineSentence, 'Q');
assert.equal(resolved.openRuntimeOptions, '9');
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
assert.equal(resolved.openCharacterDictionaryManager, 'Ctrl+D');
});
test('preserves null shortcut overrides so defaults can be disabled', () => {
+4 -2
View File
@@ -12,7 +12,7 @@ export interface ConfiguredShortcuts {
multiCopyTimeoutMs: number;
toggleSecondarySub: string | null | undefined;
markAudioCard: string | null | undefined;
openCharacterDictionary: string | null | undefined;
openCharacterDictionaryManager: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openSessionHelp: string | null | undefined;
@@ -58,7 +58,9 @@ export function resolveConfiguredShortcuts(
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')),
openCharacterDictionaryManager: normalizeShortcut(
shortcutValue('openCharacterDictionaryManager'),
),
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
+103 -9
View File
@@ -500,7 +500,10 @@ import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './mai
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
import {
openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime,
openCharacterDictionaryModal as openCharacterDictionaryModalRuntime,
} from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
@@ -520,7 +523,13 @@ import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
import {
createCharacterDictionaryAutoSyncRuntimeService,
getCharacterDictionaryManagerSnapshot,
moveCharacterDictionaryManagedEntry,
removeCharacterDictionaryManagedEntry,
replaceCharacterDictionaryManagedEntry,
} from './main/runtime/character-dictionary-auto-sync';
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
@@ -838,7 +847,15 @@ const bootServices = createMainBootServices({
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
createOverlayManager: () =>
createOverlayManager({
shouldPromoteWindowOnBoundsUpdate: (window) =>
!shouldSuppressVisibleOverlayRaiseForSeparateWindow({
window,
mainWindow: overlayManager.getMainWindow(),
separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow],
}),
}),
createOverlayModalInputState,
createOverlayContentMeasurementStore: ({ logger }) => {
const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({
@@ -1899,6 +1916,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openCharacterDictionary: () => {
openCharacterDictionaryOverlay();
},
openCharacterDictionaryManager: () => {
openCharacterDictionaryManagerOverlay();
},
openJimaku: () => {
openJimakuOverlay();
},
@@ -2191,10 +2211,6 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
logWarn: (message) => logger.warn(message),
});
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH,
});
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: USER_DATA_PATH,
getConfig: () => {
@@ -2308,6 +2324,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
},
});
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH,
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
});
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
@@ -2918,6 +2939,14 @@ function openCharacterDictionaryOverlay(): void {
);
}
function openCharacterDictionaryManagerOverlay(): void {
openOverlayHostedModalWithOsd(
openCharacterDictionaryManagerModalRuntime,
'Character dictionary manager unavailable.',
'Failed to open character dictionary manager.',
);
}
function openControllerSelectOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerSelectModalRuntime,
@@ -4740,6 +4769,8 @@ const {
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
getCurrentCharacterDictionaryMediaId: () =>
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.frequency',
@@ -5727,6 +5758,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
@@ -5990,8 +6022,31 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
getCharacterDictionarySelection: (searchTitle?: string) =>
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
setCharacterDictionarySelection: async (mediaId: number) =>
applyCharacterDictionarySelection(
setCharacterDictionarySelection: async (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) => {
if (replaceManagedMediaId !== undefined && mediaTitle) {
const result = replaceCharacterDictionaryManagedEntry(
USER_DATA_PATH,
replaceManagedMediaId,
{
mediaId,
mediaTitle,
},
);
if (result.ok && result.rebuildRequired) {
try {
await characterDictionaryAutoSyncRuntime.runSyncNow();
characterDictionaryImageLookup.invalidate();
} catch (error) {
logger.warn('Failed to rebuild character dictionary after manager override:', error);
}
}
return result;
}
return await applyCharacterDictionarySelection(
{ mediaId },
{
setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request),
@@ -5999,7 +6054,46 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
warn: (message, error) => logger.warn(message, error),
},
);
},
getCharacterDictionaryManagerSnapshot: async () =>
getCharacterDictionaryManagerSnapshot(
USER_DATA_PATH,
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
),
removeCharacterDictionaryManagedEntry: async (mediaId: number) => {
const result = removeCharacterDictionaryManagedEntry(
USER_DATA_PATH,
mediaId,
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
);
if (result.ok && result.rebuildRequired) {
try {
await characterDictionaryAutoSyncRuntime.runSyncNow();
characterDictionaryImageLookup.invalidate();
} catch (error) {
logger.warn('Failed to rebuild character dictionary after manager removal:', error);
}
}
return result;
},
moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) => {
const result = moveCharacterDictionaryManagedEntry(
USER_DATA_PATH,
mediaId,
direction,
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
);
if (result.ok && result.rebuildRequired) {
try {
await characterDictionaryAutoSyncRuntime.runSyncNow();
characterDictionaryImageLookup.invalidate();
} catch (error) {
logger.warn('Failed to rebuild character dictionary after manager reorder:', error);
}
}
return result;
},
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
@@ -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'),
+25 -2
View File
@@ -159,6 +159,9 @@ const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessio
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryOpen,
);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
const onOpenControllerSelectEvent = createQueuedIpcListener(
IPC_CHANNELS.event.controllerSelectOpen,
);
@@ -388,6 +391,7 @@ const electronAPI: ElectronAPI = {
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
@@ -415,8 +419,27 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
getCharacterDictionarySelection: (searchTitle?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
setCharacterDictionarySelection: (mediaId: number) =>
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
setCharacterDictionarySelection: (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) =>
ipcRenderer.invoke(
IPC_CHANNELS.request.setCharacterDictionarySelection,
mediaId,
replaceManagedMediaId,
mediaTitle,
),
getCharacterDictionaryManagerSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionaryManagerSnapshot),
removeCharacterDictionaryManagedEntry: (mediaId: number) =>
ipcRenderer.invoke(IPC_CHANNELS.request.removeCharacterDictionaryManagedEntry, mediaId),
moveCharacterDictionaryManagedEntry: (mediaId: number, direction: 1 | -1) =>
ipcRenderer.invoke(
IPC_CHANNELS.request.moveCharacterDictionaryManagedEntry,
mediaId,
direction,
),
notifyOverlayModalClosed: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
},
+1 -1
View File
@@ -87,7 +87,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
multiCopyTimeoutMs: 3000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
+47 -15
View File
@@ -200,29 +200,61 @@
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
<div class="modal-content character-dictionary-content">
<div class="modal-header">
<div class="modal-title">Character Dictionary Anime</div>
<div class="modal-title">Character Dictionary Management</div>
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
<div class="character-dictionary-search">
<input
id="characterDictionarySearchInput"
class="character-dictionary-search-input"
type="text"
aria-label="Search character dictionary"
autocomplete="off"
/>
<div
class="character-dictionary-tabs"
role="tablist"
aria-label="Character dictionary views"
>
<button
id="characterDictionarySearchButton"
class="character-dictionary-use"
id="characterDictionaryOverrideTab"
class="character-dictionary-tab active"
type="button"
role="tab"
aria-selected="true"
>
Search
Override
</button>
<button
id="characterDictionaryManageTab"
class="character-dictionary-tab"
type="button"
role="tab"
aria-selected="false"
>
Manage
</button>
</div>
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
<div id="characterDictionarySearchPanel">
<div class="character-dictionary-search">
<input
id="characterDictionarySearchInput"
class="character-dictionary-search-input"
type="text"
aria-label="Search character dictionary"
autocomplete="off"
/>
<button
id="characterDictionarySearchButton"
class="character-dictionary-use"
type="button"
>
Search
</button>
</div>
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
</div>
<div id="characterDictionaryManagerPanel" class="hidden">
<ul
id="characterDictionaryManagedEntries"
class="character-dictionary-candidates character-dictionary-managed-entries"
></ul>
</div>
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
</div>
</div>
@@ -24,6 +24,7 @@ function createClassList(initialTokens: string[] = []) {
}
function createElementStub() {
const listeners = new Map<string, Array<(event?: { stopPropagation?: () => void }) => void>>();
return {
className: '',
textContent: '',
@@ -35,7 +36,15 @@ function createElementStub() {
append(...children: unknown[]) {
this.children.push(...children);
},
addEventListener: () => {},
addEventListener: (
event: string,
listener: (event?: { stopPropagation?: () => void }) => void,
) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string, payload?: { stopPropagation?: () => void }) => {
for (const listener of listeners.get(event) ?? []) listener(payload);
},
};
}
@@ -157,6 +166,299 @@ test('character dictionary modal announces open before AniList refresh resolves'
}
});
test('character dictionary modal opens manager view with active entries', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const calls: string[] = [];
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const summary = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => {
calls.push('snapshot');
return {
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
};
},
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: summary,
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: createNodeStub(),
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
) as ReturnType<typeof createCharacterDictionaryModal> & {
openCharacterDictionaryManagerModal: () => Promise<void>;
};
await modal.openCharacterDictionaryManagerModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.deepEqual(calls, ['snapshot']);
assert.equal(managedEntries.children.length, 2);
assert.equal(
summary.textContent,
'2 loaded character dictionaries. Order controls eviction priority; current dictionary stays loaded.',
);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary manager reports failed reorder IPC calls', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => ({
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
}),
moveCharacterDictionaryManagedEntry: async () => {
throw new Error('move failed');
},
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryManagerModal();
const secondEntry = managedEntries.children[1] as { children: unknown[] };
const controls = secondEntry.children[1] as {
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
};
controls.children[0]?.dispatchEvent('click', { stopPropagation: () => {} });
await flushAsyncWork();
assert.equal(status.textContent, 'move failed');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary manager reports pending refresh after removal', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const managedEntries = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionaryManagerSnapshot: async () => ({
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
{
mediaId: 115230,
label: '115230 - Tower of God',
title: 'Tower of God',
current: false,
},
],
}),
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
removeCharacterDictionaryManagedEntry: async () => ({
ok: true,
entries: [
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
],
rebuildRequired: true,
}),
getCharacterDictionarySelection: async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
notifyOverlayModalOpened: () => {},
} as never,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionarySearchInput: createNodeStub(),
characterDictionarySearchButton: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
characterDictionarySearchPanel: createNodeStub(),
characterDictionaryManagerPanel: createNodeStub(true),
characterDictionaryOverrideTab: createNodeStub(),
characterDictionaryManageTab: createNodeStub(),
characterDictionaryManagedEntries: managedEntries,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryManagerModal();
const secondEntry = managedEntries.children[1] as { children: unknown[] };
const controls = secondEntry.children[1] as {
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
};
controls.children[3]?.dispatchEvent('click', { stopPropagation: () => {} });
await flushAsyncWork();
assert.equal(status.textContent, 'Entry removed. Merged dictionary will refresh shortly.');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal loads candidates and applies selected override', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
+208 -7
View File
@@ -1,9 +1,13 @@
import type {
CharacterDictionaryCandidate,
CharacterDictionaryManagerEntry,
CharacterDictionaryManagerSnapshot,
CharacterDictionarySelectionSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
type CharacterDictionaryView = 'override' | 'manage';
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.min(Math.max(index, 0), length - 1);
@@ -28,6 +32,9 @@ export function createCharacterDictionaryModal(
},
) {
let hasSearched = false;
let activeView: CharacterDictionaryView = 'override';
let managerSnapshot: CharacterDictionaryManagerSnapshot | null = null;
let pendingManagedOverride: { mediaId: number; title: string } | null = null;
function setStatus(message: string, isError = false): void {
ctx.state.characterDictionaryStatus = message;
@@ -54,6 +61,22 @@ export function createCharacterDictionaryModal(
render();
}
function setActiveView(view: CharacterDictionaryView): void {
activeView = view;
ctx.dom.characterDictionarySearchPanel?.classList.toggle('hidden', view !== 'override');
ctx.dom.characterDictionaryManagerPanel?.classList.toggle('hidden', view !== 'manage');
ctx.dom.characterDictionaryOverrideTab?.classList.toggle('active', view === 'override');
ctx.dom.characterDictionaryManageTab?.classList.toggle('active', view === 'manage');
ctx.dom.characterDictionaryOverrideTab?.setAttribute(
'aria-selected',
view === 'override' ? 'true' : 'false',
);
ctx.dom.characterDictionaryManageTab?.setAttribute(
'aria-selected',
view === 'manage' ? 'true' : 'false',
);
}
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
const item = document.createElement('li');
@@ -127,6 +150,84 @@ export function createCharacterDictionaryModal(
);
}
function renderManagerEntry(
entry: CharacterDictionaryManagerEntry,
index: number,
entryCount: number,
): HTMLLIElement {
const item = document.createElement('li');
item.className = 'character-dictionary-candidate character-dictionary-managed-entry';
const main = document.createElement('div');
main.className = 'runtime-options-label';
main.textContent = entry.title || entry.label;
const meta = document.createElement('div');
meta.className = 'runtime-options-allowed';
meta.textContent = `AniList ${entry.mediaId}${entry.current ? ' · Current' : ''}`;
const body = document.createElement('div');
body.className = 'character-dictionary-candidate-body';
body.append(main, meta);
const controls = document.createElement('div');
controls.className = 'character-dictionary-manager-actions';
const makeButton = (
label: string,
disabled: boolean,
onClick: () => void | Promise<void>,
): HTMLButtonElement => {
const button = document.createElement('button');
button.className = 'character-dictionary-use';
button.type = 'button';
button.textContent = label;
button.disabled = disabled;
button.addEventListener('click', (event) => {
event.stopPropagation();
if (button.disabled) return;
void onClick();
});
return button;
};
controls.append(
makeButton('Up', entry.current || index === 0, () => moveManagedEntry(entry.mediaId, -1)),
makeButton('Down', entry.current || index >= entryCount - 1, () =>
moveManagedEntry(entry.mediaId, 1),
),
makeButton('Override', false, () => openManagedOverride(entry)),
makeButton('Remove', entry.current, () => removeManagedEntry(entry.mediaId)),
);
item.append(body, controls);
return item;
}
function renderManager(): void {
const entries = managerSnapshot?.entries ?? [];
ctx.dom.characterDictionaryManagedEntries?.replaceChildren();
if (!ctx.dom.characterDictionaryManagedEntries) return;
ctx.dom.characterDictionarySummary.textContent =
entries.length > 0
? `${entries.length} loaded character dictionaries. Order controls eviction priority; current dictionary stays loaded.`
: 'No loaded character dictionaries.';
ctx.dom.characterDictionaryCurrent.textContent = '';
if (entries.length === 0) {
const empty = document.createElement('li');
empty.className = 'character-dictionary-empty';
empty.textContent = 'No loaded character dictionaries.';
ctx.dom.characterDictionaryManagedEntries.append(empty);
return;
}
ctx.dom.characterDictionaryManagedEntries.replaceChildren(
...entries.map((entry, index) => renderManagerEntry(entry, index, entries.length)),
);
}
async function refreshSelection(searchTitle?: string): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
hasSearched = searchTitle !== '';
@@ -140,6 +241,12 @@ export function createCharacterDictionaryModal(
);
}
async function refreshManager(): Promise<void> {
managerSnapshot = await window.electronAPI.getCharacterDictionaryManagerSnapshot();
renderManager();
setStatus('Loaded character dictionary entries.');
}
async function searchCandidates(): Promise<void> {
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
if (!searchTitle) {
@@ -165,17 +272,80 @@ export function createCharacterDictionaryModal(
setStatus(`Saving override for ${candidate.title}...`);
try {
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
const result = await window.electronAPI.setCharacterDictionarySelection(
candidate.id,
pendingManagedOverride?.mediaId,
pendingManagedOverride ? candidate.title : undefined,
);
if (!result.ok) {
setStatus('Failed to save override', true);
setStatus('message' in result ? result.message : 'Failed to save override', true);
return;
}
if (pendingManagedOverride) {
const replacedTitle = candidate.title;
pendingManagedOverride = null;
await refreshManager();
setActiveView('manage');
setStatus(`Managed entry replaced with ${replacedTitle}.`);
return;
}
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
: '';
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
if ('selected' in result) {
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
: '';
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
}
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function moveManagedEntry(mediaId: number, direction: 1 | -1): Promise<void> {
setStatus('Updating entry order...');
try {
const result = await window.electronAPI.moveCharacterDictionaryManagedEntry(
mediaId,
direction,
);
managerSnapshot = { entries: result.entries };
renderManager();
setStatus(result.ok ? 'Entry order updated.' : result.message, !result.ok);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function removeManagedEntry(mediaId: number): Promise<void> {
setStatus('Removing entry...');
try {
const result = await window.electronAPI.removeCharacterDictionaryManagedEntry(mediaId);
managerSnapshot = { entries: result.entries };
renderManager();
setStatus(
result.ok
? result.rebuildRequired
? 'Entry removed. Merged dictionary will refresh shortly.'
: 'Entry removed.'
: result.message,
!result.ok,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function openManagedOverride(entry: CharacterDictionaryManagerEntry): Promise<void> {
pendingManagedOverride = entry.current
? null
: { mediaId: entry.mediaId, title: entry.title || entry.label };
setActiveView('override');
const searchTitle = entry.title || entry.label;
ctx.dom.characterDictionarySearchInput.value = searchTitle;
setStatus(`Searching AniList for ${searchTitle}...`);
try {
await refreshSelection(searchTitle);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
@@ -192,6 +362,8 @@ export function createCharacterDictionaryModal(
}
async function openCharacterDictionaryModal(): Promise<void> {
setActiveView('override');
pendingManagedOverride = null;
if (!ctx.state.characterDictionaryModalOpen) {
showShell();
} else {
@@ -205,14 +377,33 @@ export function createCharacterDictionaryModal(
}
}
async function openCharacterDictionaryManagerModal(): Promise<void> {
setActiveView('manage');
pendingManagedOverride = null;
if (!ctx.state.characterDictionaryModalOpen) {
showShell();
} else {
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
setStatus('Refreshing character dictionary entries...');
}
try {
await refreshManager();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function closeCharacterDictionaryModal(): void {
if (!ctx.state.characterDictionaryModalOpen) return;
ctx.state.characterDictionaryModalOpen = false;
ctx.state.characterDictionarySelection = null;
managerSnapshot = null;
pendingManagedOverride = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.characterDictionaryModal.classList.add('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
ctx.dom.characterDictionaryCandidates.replaceChildren();
ctx.dom.characterDictionaryManagedEntries?.replaceChildren();
hasSearched = false;
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
setStatus('');
@@ -245,6 +436,9 @@ export function createCharacterDictionaryModal(
}
return false;
}
if (activeView === 'manage') {
return false;
}
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
moveSelection(1);
@@ -265,6 +459,12 @@ export function createCharacterDictionaryModal(
function wireDomEvents(): void {
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
ctx.dom.characterDictionaryOverrideTab?.addEventListener('click', () => {
void openCharacterDictionaryModal();
});
ctx.dom.characterDictionaryManageTab?.addEventListener('click', () => {
void openCharacterDictionaryManagerModal();
});
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
void searchCandidates();
});
@@ -278,6 +478,7 @@ export function createCharacterDictionaryModal(
return {
openCharacterDictionaryModal,
openCharacterDictionaryManagerModal,
closeCharacterDictionaryModal,
handleCharacterDictionaryKeydown,
wireDomEvents,
+4 -1
View File
@@ -205,7 +205,9 @@ function describeSessionAction(
case 'openSessionHelp':
return 'Open session help';
case 'openCharacterDictionary':
return 'Open character dictionary anime selector';
return 'Open AniList override selector';
case 'openCharacterDictionaryManager':
return 'Open character dictionary manager';
case 'openControllerSelect':
return 'Open controller select';
case 'openControllerDebug':
@@ -255,6 +257,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'openRuntimeOptions':
case 'openJimaku':
case 'openCharacterDictionary':
case 'openCharacterDictionaryManager':
case 'openControllerSelect':
case 'openControllerDebug':
case 'openYoutubePicker':
+5
View File
@@ -465,6 +465,11 @@ function registerModalOpenHandlers(): void {
await characterDictionaryModal.openCharacterDictionaryModal();
});
});
window.electronAPI.onOpenCharacterDictionaryManager(() => {
runGuardedAsync('character-dictionary-manager:open', async () => {
await characterDictionaryModal.openCharacterDictionaryManagerModal();
});
});
window.electronAPI.onOpenSessionHelp(() => {
runGuarded('session-help:open', () => {
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
+33
View File
@@ -1568,6 +1568,27 @@ iframe[id^='yomitan-popup'],
width: min(680px, 92%);
}
.character-dictionary-tabs {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.character-dictionary-tab {
border: 1px solid rgba(110, 115, 141, 0.28);
border-radius: 6px;
background: rgba(24, 25, 38, 0.78);
color: var(--ctp-subtext0);
padding: 6px 10px;
cursor: pointer;
}
.character-dictionary-tab.active {
border-color: rgba(138, 173, 244, 0.62);
background: rgba(138, 173, 244, 0.18);
color: var(--ctp-text);
}
.character-dictionary-current {
font-size: 12px;
color: var(--ctp-subtext1);
@@ -1631,6 +1652,18 @@ iframe[id^='yomitan-popup'],
min-width: 0;
}
.character-dictionary-manager-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.character-dictionary-managed-entry {
align-items: flex-start;
}
.character-dictionary-use {
flex: 0 0 auto;
border: 1px solid rgba(138, 173, 244, 0.38);
+20
View File
@@ -59,11 +59,16 @@ export type RendererDom = {
characterDictionaryModal: HTMLDivElement;
characterDictionaryClose: HTMLButtonElement;
characterDictionaryOverrideTab: HTMLButtonElement;
characterDictionaryManageTab: HTMLButtonElement;
characterDictionarySummary: HTMLDivElement;
characterDictionarySearchPanel: HTMLDivElement;
characterDictionarySearchInput: HTMLInputElement;
characterDictionarySearchButton: HTMLButtonElement;
characterDictionaryCurrent: HTMLDivElement;
characterDictionaryCandidates: HTMLUListElement;
characterDictionaryManagerPanel: HTMLDivElement;
characterDictionaryManagedEntries: HTMLUListElement;
characterDictionaryStatus: HTMLDivElement;
subsyncModal: HTMLDivElement;
@@ -188,7 +193,16 @@ export function resolveRendererDom(): RendererDom {
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
characterDictionaryOverrideTab: getRequiredElement<HTMLButtonElement>(
'characterDictionaryOverrideTab',
),
characterDictionaryManageTab: getRequiredElement<HTMLButtonElement>(
'characterDictionaryManageTab',
),
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
characterDictionarySearchPanel: getRequiredElement<HTMLDivElement>(
'characterDictionarySearchPanel',
),
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
'characterDictionarySearchInput',
),
@@ -199,6 +213,12 @@ export function resolveRendererDom(): RendererDom {
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
'characterDictionaryCandidates',
),
characterDictionaryManagerPanel: getRequiredElement<HTMLDivElement>(
'characterDictionaryManagerPanel',
),
characterDictionaryManagedEntries: getRequiredElement<HTMLUListElement>(
'characterDictionaryManagedEntries',
),
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
+4
View File
@@ -77,6 +77,9 @@ export const IPC_CHANNELS = {
jellyfinSetupSubmit: 'jellyfin:setup-submit',
getCharacterDictionarySelection: 'character-dictionary:get-selection',
setCharacterDictionarySelection: 'character-dictionary:set-selection',
getCharacterDictionaryManagerSnapshot: 'character-dictionary:get-manager-snapshot',
removeCharacterDictionaryManagedEntry: 'character-dictionary:remove-managed-entry',
moveCharacterDictionaryManagedEntry: 'character-dictionary:move-managed-entry',
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
appendPlaylistBrowserFile: 'playlist-browser:append-file',
@@ -131,6 +134,7 @@ export const IPC_CHANNELS = {
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
characterDictionaryOpen: 'character-dictionary:open',
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
+1
View File
@@ -34,6 +34,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openRuntimeOptions',
'openSessionHelp',
'openCharacterDictionary',
'openCharacterDictionaryManager',
'openControllerSelect',
'openControllerDebug',
'openJimaku',
+1 -1
View File
@@ -105,7 +105,7 @@ export interface ShortcutsConfig {
multiCopyTimeoutMs?: number;
toggleSecondarySub?: string | null;
markAudioCard?: string | null;
openCharacterDictionary?: string | null;
openCharacterDictionaryManager?: string | null;
openRuntimeOptions?: string | null;
openJimaku?: string | null;
openSessionHelp?: string | null;
+29 -1
View File
@@ -379,6 +379,21 @@ export interface CharacterDictionarySelectionResult {
staleMediaIds: 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 SessionNumericSelectionStartPayload {
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
timeoutMs: number;
@@ -454,6 +469,7 @@ export interface ElectronAPI {
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onOpenCharacterDictionary: (callback: () => void) => void;
onOpenCharacterDictionaryManager: (callback: () => void) => void;
onSubtitleSidebarToggle: (callback: () => void) => void;
onPrimarySubtitleBarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void;
@@ -477,7 +493,19 @@ export interface ElectronAPI {
getCharacterDictionarySelection: (
searchTitle?: string,
) => Promise<CharacterDictionarySelectionSnapshot>;
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
setCharacterDictionarySelection: (
mediaId: number,
replaceManagedMediaId?: number,
mediaTitle?: string,
) => Promise<CharacterDictionarySelectionResult | CharacterDictionaryManagerMutationResult>;
getCharacterDictionaryManagerSnapshot: () => Promise<CharacterDictionaryManagerSnapshot>;
removeCharacterDictionaryManagedEntry: (
mediaId: number,
) => Promise<CharacterDictionaryManagerMutationResult>;
moveCharacterDictionaryManagedEntry: (
mediaId: number,
direction: 1 | -1,
) => Promise<CharacterDictionaryManagerMutationResult>;
notifyOverlayModalClosed: (
modal:
| 'runtime-options'
+1
View File
@@ -17,6 +17,7 @@ export type SessionActionId =
| 'openRuntimeOptions'
| 'openSessionHelp'
| 'openCharacterDictionary'
| 'openCharacterDictionaryManager'
| 'openControllerSelect'
| 'openControllerDebug'
| 'openJimaku'