mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-29 12:55:16 -07:00
feat(character-dictionary): add manager modal and scope name matching to current media (#86)
This commit is contained in:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user