mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(character-dictionary): add manager modal and scope name matching to current media (#86)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -207,7 +207,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'mineSentenceMultiple',
|
||||
'toggleSecondarySub',
|
||||
'markAudioCard',
|
||||
'openCharacterDictionary',
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
] as const;
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,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();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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: () => {},
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ?? {},
|
||||
),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -34,6 +34,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openCharacterDictionary',
|
||||
'openCharacterDictionaryManager',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
|
||||
+1
-1
@@ -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
@@ -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'
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SessionActionId =
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
| 'openCharacterDictionary'
|
||||
| 'openCharacterDictionaryManager'
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
|
||||
Reference in New Issue
Block a user