mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 16:19:26 -07:00
Restore multi-copy digit capture and add AniList selection (#56)
This commit is contained in:
@@ -25,12 +25,21 @@ import {
|
||||
} from './character-dictionary-runtime/constants';
|
||||
import {
|
||||
downloadCharacterImage,
|
||||
fetchAniListMediaCandidateById,
|
||||
fetchCharactersForMedia,
|
||||
resolveAniListMediaIdFromGuess,
|
||||
searchAniListMediaCandidates,
|
||||
} from './character-dictionary-runtime/fetch';
|
||||
import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryBuildResult,
|
||||
CharacterDictionaryGenerateOptions,
|
||||
CharacterDictionaryManualSelectionResult,
|
||||
CharacterDictionaryManualSelectionSnapshot,
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionarySnapshotProgress,
|
||||
@@ -136,6 +145,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||
) => Promise<CharacterDictionarySnapshotResult>;
|
||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||
getManualSelectionSnapshot: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||
setManualSelection: (request: {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
}) => Promise<CharacterDictionaryManualSelectionResult>;
|
||||
generateForCurrentMedia: (
|
||||
targetPath?: string,
|
||||
options?: CharacterDictionaryGenerateOptions,
|
||||
@@ -144,26 +160,56 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||
const sleepMs = deps.sleep ?? sleep;
|
||||
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
|
||||
const manualSelectionStore = createCharacterDictionaryManualSelectionStore({
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
|
||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||
let hasAniListRequest = false;
|
||||
return async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGuessInput = (
|
||||
targetPath?: string,
|
||||
): { mediaPath: string | null; mediaTitle: string | null } => {
|
||||
const dictionaryTarget = targetPath?.trim() || '';
|
||||
return dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
};
|
||||
};
|
||||
|
||||
const guessCurrentMedia = async (targetPath?: string) => {
|
||||
const guessInput = resolveGuessInput(targetPath);
|
||||
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
|
||||
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle);
|
||||
if (!guessed || !guessed.title.trim()) {
|
||||
throw new Error('Unable to resolve current anime from media path/title.');
|
||||
}
|
||||
return {
|
||||
guessed,
|
||||
seriesKey: buildCharacterDictionarySeriesKey({
|
||||
mediaPath: mediaPathForGuess,
|
||||
mediaTitle: guessInput.mediaTitle,
|
||||
guess: guessed,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCurrentMedia = async (
|
||||
targetPath?: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<ResolvedAniListMedia> => {
|
||||
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
||||
const dictionaryTarget = targetPath?.trim() || '';
|
||||
const guessInput =
|
||||
dictionaryTarget.length > 0
|
||||
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||
: {
|
||||
mediaPath: deps.getCurrentMediaPath(),
|
||||
mediaTitle: deps.getCurrentMediaTitle(),
|
||||
};
|
||||
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
|
||||
const mediaTitle = guessInput.mediaTitle;
|
||||
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle);
|
||||
if (!guessed || !guessed.title.trim()) {
|
||||
throw new Error('Unable to resolve current anime from media path/title.');
|
||||
}
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
||||
typeof guessed.episode === 'number' && guessed.episode > 0
|
||||
@@ -171,6 +217,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
: ''
|
||||
}`,
|
||||
);
|
||||
const override = await manualSelectionStore.getOverride(seriesKey);
|
||||
if (override) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
|
||||
);
|
||||
return {
|
||||
id: override.mediaId,
|
||||
title: override.mediaTitle,
|
||||
staleMediaIds: override.staleMediaIds,
|
||||
};
|
||||
}
|
||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||
return resolved;
|
||||
@@ -283,25 +340,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
targetPath?: string,
|
||||
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||
) => {
|
||||
let hasAniListRequest = false;
|
||||
const waitForAniListRequestSlot = async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
||||
progress?.onChecking?.({
|
||||
mediaId: resolvedMedia.id,
|
||||
mediaTitle: resolvedMedia.title,
|
||||
});
|
||||
return getOrCreateSnapshot(
|
||||
const snapshot = await getOrCreateSnapshot(
|
||||
resolvedMedia.id,
|
||||
resolvedMedia.title,
|
||||
waitForAniListRequestSlot,
|
||||
progress,
|
||||
);
|
||||
return {
|
||||
...snapshot,
|
||||
staleMediaIds: resolvedMedia.staleMediaIds,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async (mediaIds: number[]) => {
|
||||
const normalizedMediaIds = normalizeMergedMediaIds(mediaIds);
|
||||
@@ -341,18 +395,58 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
entryCount,
|
||||
};
|
||||
},
|
||||
getManualSelectionSnapshot: async (targetPath?: string) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [candidates, override] = await Promise.all([
|
||||
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
|
||||
manualSelectionStore.getOverride(seriesKey),
|
||||
]);
|
||||
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||
.then(
|
||||
(entry): AniListMediaCandidate => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||
}),
|
||||
)
|
||||
.catch(() => null);
|
||||
return {
|
||||
seriesKey,
|
||||
guessTitle: guessed.title,
|
||||
current,
|
||||
override: override
|
||||
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
|
||||
: null,
|
||||
candidates,
|
||||
};
|
||||
},
|
||||
setManualSelection: async ({ targetPath, mediaId }) => {
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||
const [selected, current] = await Promise.all([
|
||||
fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot),
|
||||
resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null),
|
||||
]);
|
||||
const staleMediaIds = current && current.id !== selected.id ? [current.id] : [];
|
||||
await manualSelectionStore.setOverride({
|
||||
seriesKey,
|
||||
mediaId: selected.id,
|
||||
mediaTitle: selected.title,
|
||||
staleMediaIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
seriesKey,
|
||||
selected,
|
||||
staleMediaIds,
|
||||
};
|
||||
},
|
||||
generateForCurrentMedia: async (
|
||||
targetPath?: string,
|
||||
_options?: CharacterDictionaryGenerateOptions,
|
||||
) => {
|
||||
let hasAniListRequest = false;
|
||||
const waitForAniListRequestSlot = async (): Promise<void> => {
|
||||
if (!hasAniListRequest) {
|
||||
hasAniListRequest = true;
|
||||
return;
|
||||
}
|
||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||
};
|
||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
||||
const snapshot = await getOrCreateSnapshot(
|
||||
resolvedMedia.id,
|
||||
|
||||
34
src/main/character-dictionary-runtime/fetch.test.ts
Normal file
34
src/main/character-dictionary-runtime/fetch.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { searchAniListMediaCandidates } from './fetch';
|
||||
|
||||
test('searchAniListMediaCandidates trims fallback candidate titles', async () => {
|
||||
const previousFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 21355, episodes: 25, title: {} }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const candidates = await searchAniListMediaCandidates(' Re:ZERO ');
|
||||
|
||||
assert.equal(candidates[0]?.title, 'Re:ZERO');
|
||||
} finally {
|
||||
if (previousFetchDescriptor) {
|
||||
Object.defineProperty(globalThis, 'fetch', previousFetchDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'fetch');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryRole,
|
||||
CharacterRecord,
|
||||
ResolvedAniListMedia,
|
||||
@@ -123,6 +124,30 @@ function pickAniListSearchResult(
|
||||
};
|
||||
}
|
||||
|
||||
function toAniListMediaCandidate(
|
||||
entry: {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
},
|
||||
fallbackTitle: string,
|
||||
): AniListMediaCandidate {
|
||||
const normalizedFallback = fallbackTitle.trim() || `AniList ${entry.id}`;
|
||||
return {
|
||||
id: entry.id,
|
||||
title:
|
||||
entry.title?.english?.trim() ||
|
||||
entry.title?.romaji?.trim() ||
|
||||
entry.title?.native?.trim() ||
|
||||
normalizedFallback,
|
||||
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAniList<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
@@ -208,6 +233,69 @@ export async function resolveAniListMediaIdFromGuess(
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function searchAniListMediaCandidates(
|
||||
title: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<AniListMediaCandidate[]> {
|
||||
const data = await fetchAniList<AniListSearchResponse>(
|
||||
`
|
||||
query($search: String!) {
|
||||
Page(perPage: 10) {
|
||||
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
beforeRequest,
|
||||
);
|
||||
return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title));
|
||||
}
|
||||
|
||||
export async function fetchAniListMediaCandidateById(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
): Promise<AniListMediaCandidate> {
|
||||
const data = await fetchAniList<{
|
||||
Media?: {
|
||||
id: number;
|
||||
episodes?: number | null;
|
||||
title?: {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
native?: string | null;
|
||||
};
|
||||
} | null;
|
||||
}>(
|
||||
`
|
||||
query($id: Int!) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: mediaId },
|
||||
beforeRequest,
|
||||
);
|
||||
if (!data.Media) {
|
||||
throw new Error(`AniList media ${mediaId} not found.`);
|
||||
}
|
||||
return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`);
|
||||
}
|
||||
|
||||
export async function fetchCharactersForMedia(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
buildCharacterDictionarySeriesKey,
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './manual-selection';
|
||||
|
||||
const REZERO_EP1 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
||||
const REZERO_EP2 =
|
||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
|
||||
}
|
||||
|
||||
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
|
||||
const key = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
|
||||
});
|
||||
|
||||
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const firstKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP1,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
await store.setOverride({
|
||||
seriesKey: firstKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
|
||||
const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||
const secondKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: REZERO_EP2,
|
||||
mediaTitle: null,
|
||||
guess: {
|
||||
title: 'Re ZERO, Starting Life in Another World',
|
||||
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||
year: 2016,
|
||||
season: 1,
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(secondKey, firstKey);
|
||||
assert.deepEqual(await reloaded.getOverride(secondKey), {
|
||||
seriesKey: firstKey,
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
staleMediaIds: [10607],
|
||||
});
|
||||
});
|
||||
118
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
118
src/main/character-dictionary-runtime/manual-selection.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||
import { ensureDir } from '../../shared/fs-utils';
|
||||
|
||||
export type CharacterDictionaryManualSelection = {
|
||||
seriesKey: string;
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
type ManualSelectionStoreFile = {
|
||||
overrides?: CharacterDictionaryManualSelection[];
|
||||
};
|
||||
|
||||
function normalizeManualMediaId(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
const mediaId = Math.floor(value);
|
||||
return mediaId > 0 ? mediaId : null;
|
||||
}
|
||||
|
||||
function normalizeSeriesKeyPart(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[':]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeNumbers(values: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const result: number[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = normalizeManualMediaId(value);
|
||||
if (normalized === null || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Partial<CharacterDictionaryManualSelection>;
|
||||
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
|
||||
const mediaId = normalizeManualMediaId(raw.mediaId);
|
||||
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||
if (!seriesKey || mediaId === null || !mediaTitle) return null;
|
||||
return {
|
||||
seriesKey,
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
|
||||
};
|
||||
}
|
||||
|
||||
function readOverrides(filePath: string): CharacterDictionaryManualSelection[] {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile;
|
||||
if (!Array.isArray(parsed.overrides)) return [];
|
||||
const byKey = new Map<string, CharacterDictionaryManualSelection>();
|
||||
for (const value of parsed.overrides) {
|
||||
const normalized = normalizeOverride(value);
|
||||
if (normalized) byKey.set(normalized.seriesKey, normalized);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void {
|
||||
ensureDir(path.dirname(filePath));
|
||||
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function buildCharacterDictionarySeriesKey(input: {
|
||||
mediaPath: string | null;
|
||||
mediaTitle: string | null;
|
||||
guess: AnilistMediaGuess | null;
|
||||
}): string {
|
||||
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
|
||||
const sourceTitle =
|
||||
guessedTitle ||
|
||||
(input.mediaTitle && input.mediaTitle.trim()) ||
|
||||
(input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) ||
|
||||
'unknown';
|
||||
const withoutEpisode = sourceTitle
|
||||
.replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ')
|
||||
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
||||
.trim();
|
||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||
return input.guess?.year ? `${base}-${input.guess.year}` : base;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||
|
||||
return {
|
||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
||||
},
|
||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||
const normalized = normalizeOverride(selection);
|
||||
if (!normalized) {
|
||||
throw new Error('Invalid character dictionary manual selection.');
|
||||
}
|
||||
const remaining = readOverrides(filePath).filter(
|
||||
(entry) => entry.seriesKey !== normalized.seriesKey,
|
||||
);
|
||||
writeOverrides(filePath, [...remaining, normalized]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = {
|
||||
entryCount: number;
|
||||
fromCache: boolean;
|
||||
updatedAt: number;
|
||||
staleMediaIds?: number[];
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotProgress = {
|
||||
@@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = {
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
export type AniListMediaCandidate = {
|
||||
id: number;
|
||||
title: string;
|
||||
episodes: number | null;
|
||||
};
|
||||
|
||||
export type CharacterDictionaryManualSelectionSnapshot = {
|
||||
seriesKey: string;
|
||||
guessTitle: string | null;
|
||||
current: AniListMediaCandidate | null;
|
||||
override: AniListMediaCandidate | null;
|
||||
candidates: AniListMediaCandidate[];
|
||||
};
|
||||
|
||||
export type CharacterDictionaryManualSelectionResult = {
|
||||
ok: boolean;
|
||||
seriesKey: string;
|
||||
selected: AniListMediaCandidate;
|
||||
staleMediaIds: number[];
|
||||
};
|
||||
|
||||
export interface CharacterDictionaryRuntimeDeps {
|
||||
userDataPath: string;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
@@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps {
|
||||
export type ResolvedAniListMedia = {
|
||||
id: number;
|
||||
title: string;
|
||||
staleMediaIds?: number[];
|
||||
};
|
||||
|
||||
27
src/main/character-dictionary-selection.test.ts
Normal file
27
src/main/character-dictionary-selection.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyCharacterDictionarySelection } from './character-dictionary-selection';
|
||||
|
||||
test('applyCharacterDictionarySelection returns saved override when post-save sync fails', async () => {
|
||||
const warnings: unknown[] = [];
|
||||
const result = await applyCharacterDictionarySelection(
|
||||
{ mediaId: 21355 },
|
||||
{
|
||||
setManualSelection: async (request) => ({
|
||||
ok: true,
|
||||
seriesKey: `series-${request.mediaId}`,
|
||||
selected: { id: request.mediaId, title: 'Re:ZERO', episodes: 25 },
|
||||
staleMediaIds: [10607],
|
||||
}),
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
runSyncNow: async () => {
|
||||
throw new Error('sync failed');
|
||||
},
|
||||
warn: (...args) => warnings.push(args),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.selected.id, 21355);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
29
src/main/character-dictionary-selection.ts
Normal file
29
src/main/character-dictionary-selection.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { CharacterDictionaryManualSelectionResult } from './character-dictionary-runtime/types';
|
||||
|
||||
export type CharacterDictionarySelectionRequest = {
|
||||
targetPath?: string;
|
||||
mediaId: number;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySelectionDeps = {
|
||||
setManualSelection: (
|
||||
request: CharacterDictionarySelectionRequest,
|
||||
) => Promise<CharacterDictionaryManualSelectionResult>;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
runSyncNow: () => Promise<void>;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
};
|
||||
|
||||
export async function applyCharacterDictionarySelection(
|
||||
request: CharacterDictionarySelectionRequest,
|
||||
deps: CharacterDictionarySelectionDeps,
|
||||
): Promise<CharacterDictionaryManualSelectionResult> {
|
||||
const result = await deps.setManualSelection(request);
|
||||
deps.resetAnilistMediaGuessState();
|
||||
try {
|
||||
await deps.runSyncNow();
|
||||
} catch (error) {
|
||||
deps.warn('Character dictionary auto-sync failed after manual selection', error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -37,6 +38,8 @@ export interface CliCommandRuntimeServiceContext {
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
|
||||
getCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['getSelection'];
|
||||
setCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['setSelection'];
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
@@ -81,6 +84,7 @@ function createCliCommandDepsFromContext(
|
||||
isInitialized: context.isOverlayInitialized,
|
||||
initialize: context.initializeOverlay,
|
||||
toggleVisible: context.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: context.togglePrimarySubtitleBar,
|
||||
setVisible: context.setVisibleOverlay,
|
||||
},
|
||||
mining: {
|
||||
@@ -103,6 +107,8 @@ function createCliCommandDepsFromContext(
|
||||
},
|
||||
dictionary: {
|
||||
generate: context.generateCharacterDictionary,
|
||||
getSelection: context.getCharacterDictionarySelection,
|
||||
setSelection: context.setCharacterDictionarySelection,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: context.openJellyfinSetup,
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
@@ -147,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
|
||||
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
|
||||
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
|
||||
togglePrimarySubtitleBar: CliCommandDepsRuntimeOptions['overlay']['togglePrimarySubtitleBar'];
|
||||
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
|
||||
};
|
||||
mining: {
|
||||
@@ -169,6 +172,8 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
};
|
||||
dictionary: {
|
||||
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
|
||||
getSelection: CliCommandDepsRuntimeOptions['dictionary']['getSelection'];
|
||||
setSelection: CliCommandDepsRuntimeOptions['dictionary']['setSelection'];
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||
@@ -258,6 +263,8 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
@@ -319,6 +326,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
isInitialized: params.overlay.isInitialized,
|
||||
initialize: params.overlay.initialize,
|
||||
toggleVisible: params.overlay.toggleVisible,
|
||||
togglePrimarySubtitleBar: params.overlay.togglePrimarySubtitleBar,
|
||||
setVisible: params.overlay.setVisible,
|
||||
},
|
||||
mining: {
|
||||
@@ -341,6 +349,8 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
},
|
||||
dictionary: {
|
||||
generate: params.dictionary.generate,
|
||||
getSelection: params.dictionary.getSelection,
|
||||
setSelection: params.dictionary.setSelection,
|
||||
},
|
||||
jellyfin: {
|
||||
openSetup: params.jellyfin.openSetup,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
isOverlayShortcutContextActive?: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openCharacterDictionary: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
@@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService(
|
||||
openRuntimeOptions: () => {
|
||||
input.openRuntimeOptionsPalette();
|
||||
},
|
||||
openCharacterDictionary: () => {
|
||||
input.openCharacterDictionary();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.openJimaku();
|
||||
},
|
||||
|
||||
@@ -459,6 +459,66 @@ test('auto sync keeps revisited media retained when a new title is added afterwa
|
||||
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']);
|
||||
});
|
||||
|
||||
test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dictionariesDir, 'auto-sync-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'],
|
||||
mergedRevision: 'old',
|
||||
mergedDictionaryTitle: 'SubMiner Character Dictionary',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const builtMediaIds: number[][] = [];
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
maxLoaded: 5,
|
||||
profileScope: 'all',
|
||||
}),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21355,
|
||||
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||
entryCount: 120,
|
||||
fromCache: false,
|
||||
updatedAt: 99,
|
||||
staleMediaIds: [10607],
|
||||
}),
|
||||
buildMergedDictionary: async (mediaIds) => {
|
||||
builtMediaIds.push([...mediaIds]);
|
||||
return {
|
||||
zipPath: path.join(dictionariesDir, 'merged.zip'),
|
||||
revision: `rev-${mediaIds.join('-')}`,
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 200,
|
||||
};
|
||||
},
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => true,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => false,
|
||||
now: () => 1,
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(builtMediaIds, [[21355, 130298]]);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
|
||||
) as { activeMediaIds: string[] };
|
||||
assert.deepEqual(state.activeMediaIds, [
|
||||
'21355 - Re:ZERO -Starting Life in Another World-',
|
||||
'130298 - The Eminence in Shadow',
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
|
||||
@@ -271,12 +271,19 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
const state = readAutoSyncState(statePath);
|
||||
const staleMediaIds = new Set(
|
||||
(snapshot.staleMediaIds ?? [])
|
||||
.map((mediaId) => normalizeMediaId(mediaId))
|
||||
.filter((mediaId): mediaId is number => mediaId !== null),
|
||||
);
|
||||
const nextActiveMediaIds = [
|
||||
{
|
||||
mediaId: snapshot.mediaId,
|
||||
label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle),
|
||||
},
|
||||
...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId),
|
||||
...state.activeMediaIds.filter(
|
||||
(entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId),
|
||||
),
|
||||
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
|
||||
const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId);
|
||||
deps.logInfo?.(
|
||||
|
||||
48
src/main/runtime/character-dictionary-open.ts
Normal file
48
src/main/runtime/character-dictionary-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
|
||||
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openCharacterDictionaryModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
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.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.characterDictionaryOpen,
|
||||
modal: CHARACTER_DICTIONARY_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => calls.push('init'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy'),
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -36,6 +37,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
@@ -67,6 +70,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
@@ -86,6 +90,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -26,6 +26,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
|
||||
@@ -29,6 +29,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('open-setup'),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
@@ -48,6 +49,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
@@ -92,6 +95,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
@@ -113,6 +117,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
deps.generateCharacterDictionary(targetPath),
|
||||
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
openFirstRunSetup: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
|
||||
@@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
isOverlayInitialized: () => boolean;
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -41,6 +42,8 @@ export type CliCommandContextFactoryDeps = {
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
getCharacterDictionarySelection?: CliCommandRuntimeServiceContext['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
@@ -79,6 +82,7 @@ export function createCliCommandContext(
|
||||
isOverlayInitialized: deps.isOverlayInitialized,
|
||||
initializeOverlay: deps.initializeOverlay,
|
||||
toggleVisibleOverlay: deps.toggleVisibleOverlay,
|
||||
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
|
||||
openFirstRunSetup: deps.openFirstRunSetup,
|
||||
setVisibleOverlay: deps.setVisibleOverlay,
|
||||
copyCurrentSubtitle: deps.copyCurrentSubtitle,
|
||||
@@ -98,6 +102,23 @@ export function createCliCommandContext(
|
||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||
getCharacterDictionarySelection:
|
||||
deps.getCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
seriesKey: '',
|
||||
guessTitle: null,
|
||||
current: null,
|
||||
override: null,
|
||||
candidates: [],
|
||||
})),
|
||||
setCharacterDictionarySelection:
|
||||
deps.setCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
ok: false,
|
||||
seriesKey: '',
|
||||
selected: { id: 0, title: '', episodes: null },
|
||||
staleMediaIds: [],
|
||||
})),
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
showMpvOsd: () => {},
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
togglePrimarySubtitleBar: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
|
||||
@@ -20,12 +20,14 @@ function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> |
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
managedPlayback: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
@@ -51,6 +53,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
@@ -62,6 +65,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: undefined,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
|
||||
@@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
return Boolean(
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
|
||||
@@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
multiCopyTimeoutMs: 5000,
|
||||
toggleSecondarySub: null,
|
||||
markAudioCard: null,
|
||||
openCharacterDictionary: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
|
||||
@@ -11,6 +11,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -24,6 +26,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
getReconnectTimer: () => deps.getReconnectTimer(),
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
|
||||
deps.setReconnectTimer(timer),
|
||||
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => deps.requestAppQuit?.(),
|
||||
},
|
||||
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ export type MpvClientRuntimeServiceOptions = {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
|
||||
@@ -16,6 +16,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
isOverlayShortcutContextActive: () => false,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openCharacterDictionary: () => calls.push('character-dictionary'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
markAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
@@ -47,6 +48,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
assert.equal(shortcutsRegistered, true);
|
||||
deps.showMpvOsd('x');
|
||||
deps.openRuntimeOptionsPalette();
|
||||
deps.openCharacterDictionary();
|
||||
deps.openJimaku();
|
||||
await deps.markAudioCard();
|
||||
deps.copySubtitleMultiple(5000);
|
||||
@@ -63,6 +65,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
'registered:true',
|
||||
'osd:x',
|
||||
'runtime-options',
|
||||
'character-dictionary',
|
||||
'jimaku',
|
||||
'mark-audio',
|
||||
'copy-multi:5000',
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openCharacterDictionary: () => deps.openCharacterDictionary(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
markAudioCard: () => deps.markAudioCard(),
|
||||
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
|
||||
|
||||
@@ -41,7 +41,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
let initialized = false;
|
||||
const buildTemplate = createBuildTrayMenuTemplateHandler({
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openOverlay();
|
||||
handlers.openSessionHelp();
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
@@ -56,7 +56,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
calls.push('init');
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -71,7 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
assert.deepEqual(template, [{ label: 'ok' }]);
|
||||
assert.deepEqual(calls, [
|
||||
'init',
|
||||
'visible:true',
|
||||
'help',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createResolveTrayIconPathHandler(deps: {
|
||||
|
||||
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -41,7 +41,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
openSessionHelpModal: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -53,11 +53,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}) {
|
||||
return (): TMenuItem[] => {
|
||||
return deps.buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => {
|
||||
openSessionHelp: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
deps.openSessionHelpModal();
|
||||
},
|
||||
openFirstRunSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
|
||||
@@ -24,7 +24,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never,
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -36,7 +36,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
})();
|
||||
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('open-overlay'),
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
|
||||
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -40,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
openSessionHelpModal: () => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -54,7 +54,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
|
||||
@@ -19,14 +19,12 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
fileExists: () => true,
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Overlay' }],
|
||||
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Help' }],
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayInitialized = true;
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlay = visible;
|
||||
},
|
||||
openSessionHelpModal: () => {},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -88,7 +86,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
});
|
||||
|
||||
assert.equal(runtime.resolveTrayIconPath(), '/tmp/SubMiner.png');
|
||||
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Overlay' }] });
|
||||
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Help' }] });
|
||||
runtime.ensureTray();
|
||||
assert.equal(overlayInitialized, true);
|
||||
assert.equal(visibleOverlay, true);
|
||||
|
||||
@@ -29,7 +29,7 @@ test('resolve tray icon returns null when no asset exists', () => {
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
@@ -42,15 +42,17 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
});
|
||||
|
||||
assert.equal(template.length, 9);
|
||||
assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false);
|
||||
assert.equal(template[0]!.label, 'Open Help');
|
||||
template[0]!.click?.();
|
||||
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[8]!.click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
assert.deepEqual(calls, ['help', 'separator', 'quit']);
|
||||
});
|
||||
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openOverlay: () => undefined,
|
||||
openSessionHelp: () => undefined,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
}
|
||||
|
||||
export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openSessionHelp: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -49,8 +49,8 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
label: 'Open Overlay',
|
||||
click: handlers.openOverlay,
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
...(handlers.showFirstRunSetup
|
||||
? [
|
||||
|
||||
Reference in New Issue
Block a user