feat: add Anki deck dropdown with Yomitan auto-fill in settings (#95)

This commit is contained in:
2026-05-27 23:13:43 -07:00
committed by GitHub
parent 75f9b8a803
commit 8d0535f3ca
24 changed files with 415 additions and 9 deletions
+2
View File
@@ -6,3 +6,5 @@ area: anki
- Fixed Kiku duplicate-card detection so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics. - Fixed Kiku duplicate-card detection so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
- Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL. - Fixed manual clipboard card updates from YouTube playback so generated audio and images use mpv's resolved stream URLs instead of the YouTube page URL.
- Sentence cards now refresh the current secondary subtitle before saving, so the translation field uses the loaded subtitle instead of repeating the primary text. - Sentence cards now refresh the current secondary subtitle before saving, so the translation field uses the loaded subtitle instead of repeating the primary text.
- Fixed immediate known-word cache append when no default Anki mining deck is configured but multiple known-word deck field mappings are present.
- Added an AnkiConnect deck dropdown at the top of Mining & Anki settings that auto-fills from Yomitan's current mining deck when available.
+1 -1
View File
@@ -36,7 +36,7 @@ In both modes, the enrichment workflow is the same:
4. Fills the translation field from the secondary subtitle or AI. 4. Fills the translation field from the secondary subtitle or AI.
5. Writes metadata to the miscInfo field. 5. Writes metadata to the miscInfo field.
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`. Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
### Proxy Mode Setup (Yomitan / Texthooker) ### Proxy Mode Setup (Yomitan / Texthooker)
+5 -5
View File
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
- Tracking & App - Tracking & App
- Advanced - Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes. Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies. The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
@@ -96,8 +96,8 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings, Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
logging level, selected source-language preferences, Jimaku/Subsync settings, and logging level, selected source-language preferences, Jimaku/Subsync settings, and
the Anki known-word, N+1, field, sentence-card, and Kiku options listed in the the Anki deck, known-word, N+1, field, sentence-card, and Kiku options listed
reference tables below. in the reference tables below.
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
@@ -105,7 +105,7 @@ Restart-required changes:
- Any other config sections still require restart. - Any other config sections still require restart.
- Shared top-level `ai` provider settings still require restart. - Shared top-level `ai` provider settings still require restart.
- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above. - AnkiConnect transport/proxy/media/tag fields still require restart unless listed above.
- SubMiner shows an on-screen/system notification listing restart-required sections when they change. - SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview ### Configuration Options Overview
@@ -951,7 +951,7 @@ This example is intentionally compact. The option table below documents availabl
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | | `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | | `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | | `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | | `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
@@ -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', () => { test('KnownWordCacheManager reports immediate append cache clears as mutations', () => {
const config: AnkiConnectConfig = { const config: AnkiConnectConfig = {
fields: { fields: {
+12 -1
View File
@@ -326,7 +326,18 @@ export class KnownWordCacheManager {
: null; : null;
if (!selectedDeckEntry) { 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]; const deckFields = selectedDeckEntry[1];
+11
View File
@@ -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', () => { 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.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type'); assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field'); 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', () => { test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect'); const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled'); assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
assert.equal(ankiConnect[1]?.configPath, 'ankiConnect.deck');
assert.ok( assert.ok(
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') < ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'), 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') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'), 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'); const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual( assert.deepEqual(
@@ -288,6 +298,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.replace', 'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.deck',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately', 'ankiConnect.knownWords.addMinedWordsImmediately',
+5 -2
View File
@@ -17,7 +17,6 @@ type Leaf = {
}; };
export const LEGACY_HIDDEN_CONFIG_PATHS = [ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.deck',
'ankiConnect.wordField', 'ankiConnect.wordField',
'ankiConnect.audioField', 'ankiConnect.audioField',
'ankiConnect.imageField', 'ankiConnect.imageField',
@@ -129,11 +128,11 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Behavior', 'Subtitle Sidebar Behavior',
'YouTube Playback Settings', 'YouTube Playback Settings',
'mpv Playback', 'mpv Playback',
'AnkiConnect',
'Note Fields', 'Note Fields',
'Media Capture', 'Media Capture',
'Kiku/Lapis Features', 'Kiku/Lapis Features',
'Anki AI', 'Anki AI',
'AnkiConnect',
'AnkiConnect Proxy', 'AnkiConnect Proxy',
'Jimaku', 'Jimaku',
'Subtitle Sync', 'Subtitle Sync',
@@ -159,6 +158,7 @@ const SECTION_ORDER = new Map<string, number>(
const PATH_ORDER = new Map<string, number>( const PATH_ORDER = new Map<string, number>(
[ [
'ankiConnect.enabled', 'ankiConnect.enabled',
'ankiConnect.deck',
'ankiConnect.proxy.enabled', 'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled', 'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled', 'ankiConnect.isKiku.enabled',
@@ -494,6 +494,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret'; if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations'; if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings'; 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.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type'; if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
if (path.startsWith('ankiConnect.fields.')) return 'anki-field'; if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
@@ -611,6 +612,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
function fieldTypeRank(field: ConfigSettingsField): number { function fieldTypeRank(field: ConfigSettingsField): number {
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2; if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
if (field.configPath === 'ankiConnect.deck') return 1;
if (field.control !== 'boolean') return 2; if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1; return isFeatureToggle(field) ? 0 : 1;
} }
@@ -661,6 +663,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') || pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') || pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' || path === 'secondarySub.defaultMode' ||
path === 'ankiConnect.deck' ||
path === 'ankiConnect.ai.enabled' || path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' || path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' || path === 'ankiConnect.knownWords.highlightEnabled' ||
@@ -30,6 +30,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.youtube.primarySubLanguages = ['ja', 'en']; next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace; next.subsync.replace = !prev.subsync.replace;
next.ankiConnect.deck = 'Mining';
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards; next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled; next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5; 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', 'youtube.primarySubLanguages',
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.replace', 'subsync.replace',
'ankiConnect.deck',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
+1
View File
@@ -66,6 +66,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku', 'jimaku',
'subsync', 'subsync',
'ankiConnect.deck',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
+1
View File
@@ -36,6 +36,7 @@ export {
} from './tokenizer/yomitan-parser-runtime'; } from './tokenizer/yomitan-parser-runtime';
export { export {
deleteYomitanDictionaryByTitle, deleteYomitanDictionaryByTitle,
getYomitanCurrentAnkiDeckName,
getYomitanDictionaryInfo, getYomitanDictionaryInfo,
getYomitanSettingsFull, getYomitanSettingsFull,
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
@@ -6,6 +6,7 @@ import test from 'node:test';
import * as vm from 'node:vm'; import * as vm from 'node:vm';
import { import {
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
extractYomitanCurrentAnkiDeckName,
getYomitanDictionaryInfo, getYomitanDictionaryInfo,
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
deleteYomitanDictionaryByTitle, deleteYomitanDictionaryByTitle,
@@ -181,6 +182,72 @@ test('syncYomitanDefaultAnkiServer no-ops for empty target url', async () => {
assert.equal(executeCount, 0); 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 () => { test('requestYomitanTermFrequencies returns normalized frequency entries', async () => {
let scriptValue = ''; let scriptValue = '';
const deps = createDeps(async (script) => { 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 { function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
return ` return `
(async () => { (async () => {
+12
View File
@@ -350,6 +350,7 @@ import {
saveSubtitlePosition as saveSubtitlePositionCore, saveSubtitlePosition as saveSubtitlePositionCore,
addYomitanNoteViaSearch, addYomitanNoteViaSearch,
clearYomitanParserCachesForWindow, clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
sendMpvCommandRuntime, sendMpvCommandRuntime,
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
@@ -2067,6 +2068,17 @@ const configSettingsRuntime = createConfigSettingsRuntime({
onHotReloadApplied: applyConfigHotReloadDiff, onHotReloadApplied: applyConfigHotReloadDiff,
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
createAnkiClient: (url) => new AnkiConnectClient(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, getSettingsWindow: () => appState.configSettingsWindow,
setSettingsWindow: (window) => { setSettingsWindow: (window) => {
appState.configSettingsWindow = window as BrowserWindow | null; 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', () => { test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => {
const config = deepCloneConfig(DEFAULT_CONFIG); const config = deepCloneConfig(DEFAULT_CONFIG);
config.ankiConnect.behavior.autoUpdateNewCards = false; config.ankiConnect.behavior.autoUpdateNewCards = false;
config.ankiConnect.deck = 'Mining';
config.ankiConnect.knownWords.highlightEnabled = true; config.ankiConnect.knownWords.highlightEnabled = true;
config.ankiConnect.knownWords.refreshMinutes = 90; config.ankiConnect.knownWords.refreshMinutes = 90;
config.ankiConnect.knownWords.decks = { Anime: ['Mining'] }; config.ankiConnect.knownWords.decks = { Anime: ['Mining'] };
@@ -100,6 +101,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
{ {
hotReloadFields: [ hotReloadFields: [
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.deck',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
@@ -123,6 +125,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
assert.deepEqual(ankiPatches, [ assert.deepEqual(ankiPatches, [
{ {
deck: 'Mining',
behavior: { autoUpdateNewCards: false }, behavior: { autoUpdateNewCards: false },
knownWords: config.ankiConnect.knownWords, knownWords: config.ankiConnect.knownWords,
nPlusOne: config.ankiConnect.nPlusOne, nPlusOne: config.ankiConnect.nPlusOne,
@@ -86,6 +86,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) { if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) {
patch.behavior = { autoUpdateNewCards: config.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'])) { if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) {
patch.knownWords = config.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 { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type { import type {
ConfigSettingsAnkiDeckResult,
ConfigSettingsAnkiListResult, ConfigSettingsAnkiListResult,
ConfigSettingsField, ConfigSettingsField,
ConfigSettingsSaveResult, ConfigSettingsSaveResult,
@@ -34,6 +35,7 @@ export interface ConfigSettingsIpcChannels {
getConfigSettingsAnkiDeckModelNames: string; getConfigSettingsAnkiDeckModelNames: string;
getConfigSettingsAnkiModelNames: string; getConfigSettingsAnkiModelNames: string;
getConfigSettingsAnkiModelFieldNames: string; getConfigSettingsAnkiModelFieldNames: string;
getConfigSettingsYomitanAnkiDeckName: string;
} }
export interface ConfigSettingsAnkiClient { export interface ConfigSettingsAnkiClient {
@@ -60,6 +62,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
openPath(path: string): Promise<string>; openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string; defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient; createAnkiClient(url: string): ConfigSettingsAnkiClient;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined>;
ipcMain: ConfigSettingsIpcMainLike; ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels; ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void; 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 { function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot()); deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => { deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
@@ -236,6 +255,9 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
: invalidAnkiListResult('Note type is required.'); : invalidAnkiListResult('Note type is required.');
}, },
); );
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsYomitanAnkiDeckName, () =>
getYomitanAnkiDeckName(),
);
} }
return { return {
+1
View File
@@ -18,6 +18,7 @@ test('settings preload exposes Anki lookup helpers', () => {
'getAnkiDeckModelNames', 'getAnkiDeckModelNames',
'getAnkiModelNames', 'getAnkiModelNames',
'getAnkiModelFieldNames', 'getAnkiModelFieldNames',
'getYomitanAnkiDeckName',
]) { ]) {
assert.match(source, new RegExp(`${method}:`)); assert.match(source, new RegExp(`${method}:`));
} }
+4
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { import type {
ConfigSettingsAnkiDeckResult,
ConfigSettingsAnkiListResult, ConfigSettingsAnkiListResult,
ConfigSettingsAPI, ConfigSettingsAPI,
ConfigSettingsPatch, ConfigSettingsPatch,
@@ -17,6 +18,7 @@ const SETTINGS_IPC_CHANNELS = {
getAnkiDeckModelNames: 'config-settings:anki-deck-model-names', getAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
getAnkiModelNames: 'config-settings:anki-model-names', getAnkiModelNames: 'config-settings:anki-model-names',
getAnkiModelFieldNames: 'config-settings:anki-model-field-names', getAnkiModelFieldNames: 'config-settings:anki-model-field-names',
getYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
} as const; } as const;
const configSettingsAPI: ConfigSettingsAPI = { const configSettingsAPI: ConfigSettingsAPI = {
@@ -45,6 +47,8 @@ const configSettingsAPI: ConfigSettingsAPI = {
draftUrl?: string, draftUrl?: string,
): Promise<ConfigSettingsAnkiListResult> => ): Promise<ConfigSettingsAnkiListResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl), ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl),
getYomitanAnkiDeckName: (): Promise<ConfigSettingsAnkiDeckResult> =>
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getYomitanAnkiDeckName),
}; };
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI); contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
@@ -49,3 +49,10 @@ test('known word deck rename selection keeps current deck on collision', () => {
'Core', '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);
});
+95
View File
@@ -17,6 +17,11 @@ const state: {
modelFieldNames: Map<string, string[]>; modelFieldNames: Map<string, string[]>;
modelFieldNamesLoading: Set<string>; modelFieldNamesLoading: Set<string>;
modelFieldNamesErrors: Map<string, string>; modelFieldNamesErrors: Map<string, string>;
yomitanAnkiDeckName: string | null;
yomitanAnkiDeckNameLoading: boolean;
yomitanAnkiDeckNameError: string | null;
ankiDeckNameManuallySelected: boolean;
ankiDeckNameAutofilled: boolean;
noteFieldModelName: string; noteFieldModelName: string;
ankiConnectUrl: string; ankiConnectUrl: string;
noteFieldModelNameManuallySelected: boolean; noteFieldModelNameManuallySelected: boolean;
@@ -35,6 +40,11 @@ const state: {
modelFieldNames: new Map(), modelFieldNames: new Map(),
modelFieldNamesLoading: new Set(), modelFieldNamesLoading: new Set(),
modelFieldNamesErrors: new Map(), modelFieldNamesErrors: new Map(),
yomitanAnkiDeckName: null,
yomitanAnkiDeckNameLoading: false,
yomitanAnkiDeckNameError: null,
ankiDeckNameManuallySelected: false,
ankiDeckNameAutofilled: false,
noteFieldModelName: '', noteFieldModelName: '',
ankiConnectUrl: '', ankiConnectUrl: '',
noteFieldModelNameManuallySelected: false, noteFieldModelNameManuallySelected: false,
@@ -49,6 +59,11 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void { export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
state.noteFieldModelName = ''; state.noteFieldModelName = '';
state.noteFieldModelNameManuallySelected = false; state.noteFieldModelNameManuallySelected = false;
state.yomitanAnkiDeckName = null;
state.yomitanAnkiDeckNameLoading = false;
state.yomitanAnkiDeckNameError = null;
state.ankiDeckNameManuallySelected = false;
state.ankiDeckNameAutofilled = false;
} }
export function selectPreferredNoteFieldModelName( export function selectPreferredNoteFieldModelName(
@@ -90,6 +105,16 @@ export function chooseKnownWordsDeckRenameValue(
return nextDeckName; 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[] { function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value) return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) ? 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> { async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl); syncAnkiConnectUrl(draftUrl);
if ( if (
@@ -409,6 +456,54 @@ export function renderAnkiNoteTypeInput(
return wrap; 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( export function renderAnkiFieldInput(
context: SettingsControlContext, context: SettingsControlContext,
field: ConfigSettingsField, field: ConfigSettingsField,
+5
View File
@@ -3,6 +3,7 @@ import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
import { parseOptionalNumberInputValue } from './input-values'; import { parseOptionalNumberInputValue } from './input-values';
import { import {
configureAnkiControls, configureAnkiControls,
renderAnkiDeckInput,
initializeAnkiControls, initializeAnkiControls,
renderAnkiFieldInput, renderAnkiFieldInput,
renderAnkiNoteTypeInput, renderAnkiNoteTypeInput,
@@ -162,6 +163,10 @@ export function renderControl(
return renderKnownWordsDecksInput(context, field); return renderKnownWordsDecksInput(context, field);
} }
if (field.control === 'anki-deck') {
return renderAnkiDeckInput(context, field);
}
if (field.control === 'anki-note-type') { if (field.control === 'anki-note-type') {
return renderAnkiNoteTypeInput(context, field); return renderAnkiNoteTypeInput(context, field);
} }
+1
View File
@@ -113,6 +113,7 @@ export const IPC_CHANNELS = {
getConfigSettingsAnkiDeckModelNames: 'config-settings:anki-deck-model-names', getConfigSettingsAnkiDeckModelNames: 'config-settings:anki-deck-model-names',
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names', getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names', getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
}, },
event: { event: {
subtitleSet: 'subtitle:set', subtitleSet: 'subtitle:set',
+8
View File
@@ -23,6 +23,7 @@ export type ConfigSettingsControl =
| 'key-code' | 'key-code'
| 'mpv-key' | 'mpv-key'
| 'known-words-decks' | 'known-words-decks'
| 'anki-deck'
| 'anki-note-type' | 'anki-note-type'
| 'anki-field' | 'anki-field'
| 'mpv-keybindings' | 'mpv-keybindings'
@@ -96,6 +97,7 @@ export interface ConfigSettingsAPI {
modelName: string, modelName: string,
draftUrl?: string, draftUrl?: string,
): Promise<ConfigSettingsAnkiListResult>; ): Promise<ConfigSettingsAnkiListResult>;
getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult>;
} }
export interface ConfigSettingsAnkiListResult { export interface ConfigSettingsAnkiListResult {
@@ -103,3 +105,9 @@ export interface ConfigSettingsAnkiListResult {
values: string[]; values: string[];
error?: string; error?: string;
} }
export interface ConfigSettingsAnkiDeckResult {
ok: boolean;
value: string;
error?: string;
}