mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-29 12:55:16 -07:00
feat: add Anki deck dropdown with Yomitan auto-fill in settings (#95)
This commit is contained in:
@@ -520,6 +520,36 @@ test('KnownWordCacheManager uses the current deck fields for immediate append',
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager uses all configured deck fields for immediate append without a current deck', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
deck: '',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
decks: {
|
||||
'Kaishi 1.5k': ['Word'],
|
||||
Minecraft: ['Expression', 'Word'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { manager, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
manager.appendFromNoteInfo({
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Expression: { value: '別人' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(manager.isKnownWord('別人'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager reports immediate append cache clears as mutations', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
|
||||
@@ -326,7 +326,18 @@ export class KnownWordCacheManager {
|
||||
: null;
|
||||
|
||||
if (!selectedDeckEntry) {
|
||||
return null;
|
||||
const configuredFields = trimmedDeckEntries.flatMap(([, fields]) =>
|
||||
Array.isArray(fields) ? fields : [],
|
||||
);
|
||||
const normalizedFields = [
|
||||
...new Set(
|
||||
configuredFields
|
||||
.map(String)
|
||||
.map((field) => field.trim())
|
||||
.filter((field) => field.length > 0),
|
||||
),
|
||||
];
|
||||
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
|
||||
}
|
||||
|
||||
const deckFields = selectedDeckEntry[1];
|
||||
|
||||
@@ -151,6 +151,7 @@ test('settings registry exposes mpv aniskip button as an mpv key learn control',
|
||||
});
|
||||
|
||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||
assert.equal(field('ankiConnect.deck').control, 'anki-deck');
|
||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||
@@ -228,6 +229,7 @@ test('settings registry routes playback-related integrations into integrations',
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||
assert.equal(ankiConnect[1]?.configPath, 'ankiConnect.deck');
|
||||
assert.ok(
|
||||
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
|
||||
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
|
||||
@@ -236,6 +238,14 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
||||
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
|
||||
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
|
||||
);
|
||||
const miningSections = [
|
||||
...new Set(
|
||||
fields
|
||||
.filter((candidate) => candidate.category === 'mining-anki')
|
||||
.map((candidate) => candidate.section),
|
||||
),
|
||||
];
|
||||
assert.equal(miningSections[0], 'AnkiConnect');
|
||||
|
||||
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
|
||||
assert.deepEqual(
|
||||
@@ -288,6 +298,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.addMinedWordsImmediately',
|
||||
|
||||
@@ -17,7 +17,6 @@ type Leaf = {
|
||||
};
|
||||
|
||||
export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.wordField',
|
||||
'ankiConnect.audioField',
|
||||
'ankiConnect.imageField',
|
||||
@@ -129,11 +128,11 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Subtitle Sidebar Behavior',
|
||||
'YouTube Playback Settings',
|
||||
'mpv Playback',
|
||||
'AnkiConnect',
|
||||
'Note Fields',
|
||||
'Media Capture',
|
||||
'Kiku/Lapis Features',
|
||||
'Anki AI',
|
||||
'AnkiConnect',
|
||||
'AnkiConnect Proxy',
|
||||
'Jimaku',
|
||||
'Subtitle Sync',
|
||||
@@ -159,6 +158,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
const PATH_ORDER = new Map<string, number>(
|
||||
[
|
||||
'ankiConnect.enabled',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.proxy.enabled',
|
||||
'ankiConnect.isLapis.enabled',
|
||||
'ankiConnect.isKiku.enabled',
|
||||
@@ -494,6 +494,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
if (SECRET_PATHS.has(path)) return 'secret';
|
||||
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
|
||||
if (path === 'keybindings') return 'mpv-keybindings';
|
||||
if (path === 'ankiConnect.deck') return 'anki-deck';
|
||||
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
||||
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
||||
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
|
||||
@@ -611,6 +612,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
|
||||
|
||||
function fieldTypeRank(field: ConfigSettingsField): number {
|
||||
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
|
||||
if (field.configPath === 'ankiConnect.deck') return 1;
|
||||
if (field.control !== 'boolean') return 2;
|
||||
return isFeatureToggle(field) ? 0 : 1;
|
||||
}
|
||||
@@ -661,6 +663,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
pathStartsWith(path, 'subtitleStyle') ||
|
||||
pathStartsWith(path, 'subtitleSidebar') ||
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
path === 'ankiConnect.deck' ||
|
||||
path === 'ankiConnect.ai.enabled' ||
|
||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||
|
||||
@@ -30,6 +30,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
next.youtube.primarySubLanguages = ['ja', 'en'];
|
||||
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
||||
next.subsync.replace = !prev.subsync.replace;
|
||||
next.ankiConnect.deck = 'Mining';
|
||||
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
|
||||
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
|
||||
@@ -63,6 +64,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.replace',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -66,6 +66,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku',
|
||||
'subsync',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -36,6 +36,7 @@ export {
|
||||
} from './tokenizer/yomitan-parser-runtime';
|
||||
export {
|
||||
deleteYomitanDictionaryByTitle,
|
||||
getYomitanCurrentAnkiDeckName,
|
||||
getYomitanDictionaryInfo,
|
||||
getYomitanSettingsFull,
|
||||
importYomitanDictionaryFromZip,
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
extractYomitanCurrentAnkiDeckName,
|
||||
getYomitanDictionaryInfo,
|
||||
importYomitanDictionaryFromZip,
|
||||
deleteYomitanDictionaryByTitle,
|
||||
@@ -181,6 +182,72 @@ test('syncYomitanDefaultAnkiServer no-ops for empty target url', async () => {
|
||||
assert.equal(executeCount, 0);
|
||||
});
|
||||
|
||||
test('extractYomitanCurrentAnkiDeckName prefers the active profile first term card format deck', () => {
|
||||
assert.equal(
|
||||
extractYomitanCurrentAnkiDeckName({
|
||||
profileCurrent: 1,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
anki: {
|
||||
cardFormats: [{ type: 'term', deck: 'Inactive' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
options: {
|
||||
anki: {
|
||||
cardFormats: [
|
||||
{ type: 'kanji', deck: 'Kanji' },
|
||||
{ type: 'term', deck: 'Mining' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
'Mining',
|
||||
);
|
||||
});
|
||||
|
||||
test('extractYomitanCurrentAnkiDeckName ignores disabled card format decks', () => {
|
||||
assert.equal(
|
||||
extractYomitanCurrentAnkiDeckName({
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
anki: {
|
||||
cardFormats: [
|
||||
{ type: 'term', deck: 'Disabled Term', enabled: false },
|
||||
{ type: 'kanji', deck: 'Disabled Kanji', enabled: false },
|
||||
{ type: 'term', deck: 'Mining', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
'Mining',
|
||||
);
|
||||
});
|
||||
|
||||
test('extractYomitanCurrentAnkiDeckName falls back to legacy term deck', () => {
|
||||
assert.equal(
|
||||
extractYomitanCurrentAnkiDeckName({
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
anki: {
|
||||
terms: { deck: 'Legacy Mining' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
'Legacy Mining',
|
||||
);
|
||||
});
|
||||
|
||||
test('requestYomitanTermFrequencies returns normalized frequency entries', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
|
||||
@@ -1897,6 +1897,73 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
}
|
||||
}
|
||||
|
||||
function readDeckName(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getYomitanDeckFromProfileOptions(profileOptions: Record<string, unknown>): string {
|
||||
const anki = profileOptions.anki;
|
||||
if (!isObject(anki)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const cardFormats = Array.isArray(anki.cardFormats) ? anki.cardFormats : [];
|
||||
const enabledCardFormats = cardFormats
|
||||
.filter((cardFormat): cardFormat is Record<string, unknown> => isObject(cardFormat))
|
||||
.filter((cardFormat) => cardFormat.enabled !== false);
|
||||
|
||||
const termDeck = enabledCardFormats.find(
|
||||
(cardFormat) => cardFormat.type === 'term' && readDeckName(cardFormat.deck).length > 0,
|
||||
);
|
||||
if (termDeck) {
|
||||
return readDeckName(termDeck.deck);
|
||||
}
|
||||
|
||||
const firstDeck = enabledCardFormats
|
||||
.map((cardFormat) => readDeckName(cardFormat.deck))
|
||||
.find((deckName) => deckName.length > 0);
|
||||
if (firstDeck) {
|
||||
return firstDeck;
|
||||
}
|
||||
|
||||
const terms = anki.terms;
|
||||
if (isObject(terms)) {
|
||||
const legacyTermDeck = readDeckName(terms.deck);
|
||||
if (legacyTermDeck) {
|
||||
return legacyTermDeck;
|
||||
}
|
||||
}
|
||||
|
||||
const kanji = anki.kanji;
|
||||
return isObject(kanji) ? readDeckName(kanji.deck) : '';
|
||||
}
|
||||
|
||||
export function extractYomitanCurrentAnkiDeckName(optionsFull: Record<string, unknown>): string {
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const profileCurrent =
|
||||
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
|
||||
? Math.max(0, Math.floor(optionsFull.profileCurrent))
|
||||
: 0;
|
||||
const targetProfile = profiles[profileCurrent];
|
||||
if (!isObject(targetProfile) || !isObject(targetProfile.options)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return getYomitanDeckFromProfileOptions(targetProfile.options as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function getYomitanCurrentAnkiDeckName(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<string> {
|
||||
const optionsFull = await getYomitanSettingsFull(deps, logger);
|
||||
return optionsFull ? extractYomitanCurrentAnkiDeckName(optionsFull) : '';
|
||||
}
|
||||
|
||||
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
|
||||
return `
|
||||
(async () => {
|
||||
|
||||
+12
@@ -350,6 +350,7 @@ import {
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
addYomitanNoteViaSearch,
|
||||
clearYomitanParserCachesForWindow,
|
||||
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
sendMpvCommandRuntime,
|
||||
setMpvSubVisibilityRuntime,
|
||||
@@ -2067,6 +2068,17 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||
getYomitanAnkiDeckName: async () => {
|
||||
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => {
|
||||
logger.error(message, ...args);
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
logger.info(message, ...args);
|
||||
},
|
||||
});
|
||||
},
|
||||
getSettingsWindow: () => appState.configSettingsWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||
|
||||
@@ -60,6 +60,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.ankiConnect.behavior.autoUpdateNewCards = false;
|
||||
config.ankiConnect.deck = 'Mining';
|
||||
config.ankiConnect.knownWords.highlightEnabled = true;
|
||||
config.ankiConnect.knownWords.refreshMinutes = 90;
|
||||
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
|
||||
@@ -100,6 +101,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
{
|
||||
hotReloadFields: [
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
'ankiConnect.knownWords.decks',
|
||||
@@ -123,6 +125,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
|
||||
assert.deepEqual(ankiPatches, [
|
||||
{
|
||||
deck: 'Mining',
|
||||
behavior: { autoUpdateNewCards: false },
|
||||
knownWords: config.ankiConnect.knownWords,
|
||||
nPlusOne: config.ankiConnect.nPlusOne,
|
||||
|
||||
@@ -86,6 +86,9 @@ function buildAnkiRuntimeConfigPatch(
|
||||
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
|
||||
patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards };
|
||||
}
|
||||
if (diff.hotReloadFields.includes('ankiConnect.deck')) {
|
||||
patch.deck = config.ankiConnect.deck;
|
||||
}
|
||||
if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
|
||||
patch.knownWords = config.ankiConnect.knownWords;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createConfigSettingsRuntime } from './config-settings-runtime';
|
||||
|
||||
test('config settings runtime exposes inferred Yomitan Anki deck lookup', async () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
const runtime = createConfigSettingsRuntime({
|
||||
fields: [],
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getRawConfig: () => ({}),
|
||||
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
getWarnings: () => [],
|
||||
reloadConfigStrict: () =>
|
||||
({
|
||||
ok: true,
|
||||
config: deepCloneConfig(DEFAULT_CONFIG),
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}) as never,
|
||||
getSettingsWindow: () => null,
|
||||
setSettingsWindow: () => undefined,
|
||||
createSettingsWindow: () => ({}) as never,
|
||||
settingsHtmlPath: '/tmp/settings.html',
|
||||
openPath: async () => '',
|
||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||
createAnkiClient: () =>
|
||||
({
|
||||
deckNames: async () => [],
|
||||
fieldNamesForDeck: async () => [],
|
||||
modelNamesForDeck: async () => [],
|
||||
modelNames: async () => [],
|
||||
modelFieldNames: async () => [],
|
||||
}) as never,
|
||||
getYomitanAnkiDeckName: async () => 'Mining',
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => {
|
||||
handlers.set(channel, listener);
|
||||
},
|
||||
},
|
||||
ipcChannels: IPC_CHANNELS.request,
|
||||
});
|
||||
|
||||
runtime.registerHandlers();
|
||||
|
||||
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsAnkiDeckResult,
|
||||
ConfigSettingsAnkiListResult,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsSaveResult,
|
||||
@@ -34,6 +35,7 @@ export interface ConfigSettingsIpcChannels {
|
||||
getConfigSettingsAnkiDeckModelNames: string;
|
||||
getConfigSettingsAnkiModelNames: string;
|
||||
getConfigSettingsAnkiModelFieldNames: string;
|
||||
getConfigSettingsYomitanAnkiDeckName: string;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsAnkiClient {
|
||||
@@ -60,6 +62,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
||||
openPath(path: string): Promise<string>;
|
||||
defaultAnkiConnectUrl: string;
|
||||
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
|
||||
ipcMain: ConfigSettingsIpcMainLike;
|
||||
ipcChannels: ConfigSettingsIpcChannels;
|
||||
log?: (message: string) => void;
|
||||
@@ -190,6 +193,22 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
};
|
||||
}
|
||||
|
||||
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
|
||||
if (!deps.getYomitanAnkiDeckName) {
|
||||
return { ok: true, value: '' };
|
||||
}
|
||||
try {
|
||||
const value = await deps.getYomitanAnkiDeckName();
|
||||
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
value: '',
|
||||
error: error instanceof Error ? error.message : 'Failed to query Yomitan.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(): void {
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||
@@ -236,6 +255,9 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
: invalidAnkiListResult('Note type is required.');
|
||||
},
|
||||
);
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () =>
|
||||
getYomitanAnkiDeckName(),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,7 @@ test('settings preload exposes Anki lookup helpers', () => {
|
||||
'getAnkiDeckModelNames',
|
||||
'getAnkiModelNames',
|
||||
'getAnkiModelFieldNames',
|
||||
'getYomitanAnkiDeckName',
|
||||
]) {
|
||||
assert.match(source, new RegExp(`${method}:`));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type {
|
||||
ConfigSettingsAnkiDeckResult,
|
||||
ConfigSettingsAnkiListResult,
|
||||
ConfigSettingsAPI,
|
||||
ConfigSettingsPatch,
|
||||
@@ -17,6 +18,7 @@ const SETTINGS_IPC_CHANNELS = {
|
||||
getAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
|
||||
getAnkiModelNames: 'config-settings:anki-model-names',
|
||||
getAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||
getYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
|
||||
} as const;
|
||||
|
||||
const configSettingsAPI: ConfigSettingsAPI = {
|
||||
@@ -45,6 +47,8 @@ const configSettingsAPI: ConfigSettingsAPI = {
|
||||
draftUrl?: string,
|
||||
): Promise<ConfigSettingsAnkiListResult> =>
|
||||
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl),
|
||||
getYomitanAnkiDeckName: (): Promise<ConfigSettingsAnkiDeckResult> =>
|
||||
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getYomitanAnkiDeckName),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
|
||||
|
||||
@@ -49,3 +49,10 @@ test('known word deck rename selection keeps current deck on collision', () => {
|
||||
'Core',
|
||||
);
|
||||
});
|
||||
|
||||
test('Anki deck autofill uses inferred Yomitan deck only for untouched empty values', () => {
|
||||
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', 'Mining', false), 'Mining');
|
||||
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('Current', 'Mining', false), null);
|
||||
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', 'Mining', true), null);
|
||||
assert.equal(ankiControls.chooseAnkiDeckAutofillValue('', ' ', false), null);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,11 @@ const state: {
|
||||
modelFieldNames: Map<string, string[]>;
|
||||
modelFieldNamesLoading: Set<string>;
|
||||
modelFieldNamesErrors: Map<string, string>;
|
||||
yomitanAnkiDeckName: string | null;
|
||||
yomitanAnkiDeckNameLoading: boolean;
|
||||
yomitanAnkiDeckNameError: string | null;
|
||||
ankiDeckNameManuallySelected: boolean;
|
||||
ankiDeckNameAutofilled: boolean;
|
||||
noteFieldModelName: string;
|
||||
ankiConnectUrl: string;
|
||||
noteFieldModelNameManuallySelected: boolean;
|
||||
@@ -35,6 +40,11 @@ const state: {
|
||||
modelFieldNames: new Map(),
|
||||
modelFieldNamesLoading: new Set(),
|
||||
modelFieldNamesErrors: new Map(),
|
||||
yomitanAnkiDeckName: null,
|
||||
yomitanAnkiDeckNameLoading: false,
|
||||
yomitanAnkiDeckNameError: null,
|
||||
ankiDeckNameManuallySelected: false,
|
||||
ankiDeckNameAutofilled: false,
|
||||
noteFieldModelName: '',
|
||||
ankiConnectUrl: '',
|
||||
noteFieldModelNameManuallySelected: false,
|
||||
@@ -49,6 +59,11 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
|
||||
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||
state.noteFieldModelName = '';
|
||||
state.noteFieldModelNameManuallySelected = false;
|
||||
state.yomitanAnkiDeckName = null;
|
||||
state.yomitanAnkiDeckNameLoading = false;
|
||||
state.yomitanAnkiDeckNameError = null;
|
||||
state.ankiDeckNameManuallySelected = false;
|
||||
state.ankiDeckNameAutofilled = false;
|
||||
}
|
||||
|
||||
export function selectPreferredNoteFieldModelName(
|
||||
@@ -90,6 +105,16 @@ export function chooseKnownWordsDeckRenameValue(
|
||||
return nextDeckName;
|
||||
}
|
||||
|
||||
export function chooseAnkiDeckAutofillValue(
|
||||
currentDeckName: string,
|
||||
inferredDeckName: string,
|
||||
manuallySelected: boolean,
|
||||
): string | null {
|
||||
const current = currentDeckName.trim();
|
||||
const inferred = inferredDeckName.trim();
|
||||
return !manuallySelected && current.length === 0 && inferred.length > 0 ? inferred : null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
@@ -195,6 +220,28 @@ async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadYomitanAnkiDeckName(): Promise<void> {
|
||||
if (state.yomitanAnkiDeckName !== null || state.yomitanAnkiDeckNameLoading) return;
|
||||
state.yomitanAnkiDeckNameLoading = true;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getYomitanAnkiDeckName();
|
||||
if (result.ok) {
|
||||
state.yomitanAnkiDeckName = result.value.trim();
|
||||
state.yomitanAnkiDeckNameError = null;
|
||||
} else {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.';
|
||||
}
|
||||
} catch (error) {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError =
|
||||
error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.';
|
||||
} finally {
|
||||
state.yomitanAnkiDeckNameLoading = false;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (
|
||||
@@ -409,6 +456,54 @@ export function renderAnkiNoteTypeInput(
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderAnkiDeckInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const draftUrl = getDraftAnkiConnectUrl(context);
|
||||
void loadAnkiDeckNames(draftUrl);
|
||||
void loadYomitanAnkiDeckName();
|
||||
|
||||
const currentValue = context.valueForField(field);
|
||||
let current = typeof currentValue === 'string' ? currentValue.trim() : '';
|
||||
const inferred = state.yomitanAnkiDeckName ?? '';
|
||||
const autofillValue =
|
||||
state.ankiDeckNameAutofilled === false
|
||||
? chooseAnkiDeckAutofillValue(current, inferred, state.ankiDeckNameManuallySelected)
|
||||
: null;
|
||||
if (autofillValue !== null) {
|
||||
state.ankiDeckNameAutofilled = true;
|
||||
current = autofillValue;
|
||||
context.updateDraft(field.configPath, autofillValue);
|
||||
}
|
||||
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
addOption(select, '', state.deckNamesLoading ? 'Loading Decks...' : 'Select Deck');
|
||||
for (const deckName of uniqueSorted([...(state.deckNames ?? []), current])) {
|
||||
if (!deckName) continue;
|
||||
addOption(select, deckName);
|
||||
}
|
||||
select.value = current;
|
||||
select.addEventListener('change', () => {
|
||||
state.ankiDeckNameManuallySelected = true;
|
||||
state.ankiDeckNameAutofilled = true;
|
||||
context.updateDraft(field.configPath, select.value);
|
||||
});
|
||||
|
||||
const wrap = createElement('div', 'stacked-control');
|
||||
wrap.append(select);
|
||||
if (state.deckNamesError) {
|
||||
const hint = createElement('div', 'control-hint error');
|
||||
hint.textContent = state.deckNamesError;
|
||||
wrap.append(hint);
|
||||
} else if (state.yomitanAnkiDeckNameError && !state.yomitanAnkiDeckNameLoading) {
|
||||
const hint = createElement('div', 'control-hint');
|
||||
hint.textContent = state.yomitanAnkiDeckNameError;
|
||||
wrap.append(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderAnkiFieldInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
configureAnkiControls,
|
||||
renderAnkiDeckInput,
|
||||
initializeAnkiControls,
|
||||
renderAnkiFieldInput,
|
||||
renderAnkiNoteTypeInput,
|
||||
@@ -162,6 +163,10 @@ export function renderControl(
|
||||
return renderKnownWordsDecksInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'anki-deck') {
|
||||
return renderAnkiDeckInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'anki-note-type') {
|
||||
return renderAnkiNoteTypeInput(context, field);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export const IPC_CHANNELS = {
|
||||
getConfigSettingsAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
|
||||
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
|
||||
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ConfigSettingsControl =
|
||||
| 'key-code'
|
||||
| 'mpv-key'
|
||||
| 'known-words-decks'
|
||||
| 'anki-deck'
|
||||
| 'anki-note-type'
|
||||
| 'anki-field'
|
||||
| 'mpv-keybindings'
|
||||
@@ -96,6 +97,7 @@ export interface ConfigSettingsAPI {
|
||||
modelName: string,
|
||||
draftUrl?: string,
|
||||
): Promise<ConfigSettingsAnkiListResult>;
|
||||
getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult>;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsAnkiListResult {
|
||||
@@ -103,3 +105,9 @@ export interface ConfigSettingsAnkiListResult {
|
||||
values: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSettingsAnkiDeckResult {
|
||||
ok: boolean;
|
||||
value: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user