Compare commits

..

3 Commits

Author SHA1 Message Date
sudacode eed0a6a243 feat: use cached annotations on subtitle change and skip pre-warmed cues (#97) 2026-05-28 00:50:41 -07:00
sudacode d33009d4a3 style: update subtitle text shadow, JLPT underlines, and frequency topX default (#96)
* style: update subtitle text shadow, JLPT underlines, and frequency topX

- Replace directional text-shadow with 4-corner outline shadow for sharper readability
- Increase JLPT level border-bottom from 2px to 3px; add drop-shadow filter
- Add margin-left 0.18em to character image token
- Raise frequencyDictionary topX default from 1000 to 10000

* docs: add subtitle style changelog fragment

* docs: update generated config examples

* style: wrap long textShadow strings and add blank line in CSS
2026-05-28 00:18:39 -07:00
sudacode 8d0535f3ca feat: add Anki deck dropdown with Yomitan auto-fill in settings (#95) 2026-05-27 23:13:43 -07:00
42 changed files with 699 additions and 41 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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Improved subtitle annotation prefetching so cached colored annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: subtitles
- Updated subtitle defaults with a stronger outline-style text shadow, thicker JLPT underlines, and a `topX` frequency highlighting default of `10000`.
+3 -3
View File
@@ -380,7 +380,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
@@ -405,7 +405,7 @@
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 10000, // Only color tokens with frequency rank <= topX (default: 10000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
@@ -430,7 +430,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. } // Secondary setting.
+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`) |
+3 -3
View File
@@ -380,7 +380,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting. "backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting. "--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting. "--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
@@ -405,7 +405,7 @@
"frequencyDictionary": { "frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). "topX": 10000, // Only color tokens with frequency rank <= topX (default: 10000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
@@ -430,7 +430,7 @@
"word-spacing": "0", // Word spacing setting. "word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting. "font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting. "text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting. "text-shadow": "-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting. "backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults. } // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting. } // Secondary setting.
+48
View File
@@ -19,6 +19,7 @@ interface IntegrationTestContext {
function createIntegrationTestContext( function createIntegrationTestContext(
options: { options: {
highlightEnabled?: boolean; highlightEnabled?: boolean;
nPlusOneEnabled?: boolean;
onFindNotes?: () => Promise<number[]>; onFindNotes?: () => Promise<number[]>;
onNotesInfo?: () => Promise<unknown[]>; onNotesInfo?: () => Promise<unknown[]>;
stateDirPrefix?: string; stateDirPrefix?: string;
@@ -59,6 +60,12 @@ function createIntegrationTestContext(
knownWords: { knownWords: {
highlightEnabled: options.highlightEnabled ?? true, highlightEnabled: options.highlightEnabled ?? true,
}, },
nPlusOne:
options.nPlusOneEnabled === undefined
? undefined
: {
enabled: options.nPlusOneEnabled,
},
}, },
{} as never, {} as never,
{} as never, {} as never,
@@ -161,6 +168,47 @@ test('AnkiIntegration.refreshKnownWordCache bypasses stale checks', async () =>
} }
}); });
test('AnkiIntegration.refreshKnownWordCache notifies annotation cache listeners', async () => {
const ctx = createIntegrationTestContext({
stateDirPrefix: 'subminer-anki-integration-refresh-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache notifies when n+1 is enabled without highlights', async () => {
const ctx = createIntegrationTestContext({
highlightEnabled: false,
nPlusOneEnabled: true,
stateDirPrefix: 'subminer-anki-integration-nplusone-notify-',
});
let notifications = 0;
try {
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
notifications += 1;
});
await ctx.integration.refreshKnownWordCache();
assert.equal(ctx.calls.findNotes, 1);
assert.equal(notifications, 1);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => { test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled', async () => {
const ctx = createIntegrationTestContext({ const ctx = createIntegrationTestContext({
highlightEnabled: false, highlightEnabled: false,
+8 -2
View File
@@ -526,7 +526,9 @@ export class AnkiIntegration {
} }
private isKnownWordCacheEnabled(): boolean { private isKnownWordCacheEnabled(): boolean {
return this.config.knownWords?.highlightEnabled === true; return (
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
);
} }
private getConfiguredAnkiTags(): string[] { private getConfiguredAnkiTags(): string[] {
@@ -549,7 +551,11 @@ export class AnkiIntegration {
} }
async refreshKnownWordCache(): Promise<void> { async refreshKnownWordCache(): Promise<void> {
return this.knownWordCache.refresh(true); const shouldNotify = this.isKnownWordCacheEnabled();
await this.knownWordCache.refresh(true);
if (shouldNotify) {
this.notifyKnownWordCacheUpdated();
}
} }
private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void { private appendKnownWordsFromNoteInfo(noteInfo: NoteInfo): void {
@@ -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];
+2 -1
View File
@@ -22,7 +22,8 @@ import {
const DEFAULT_SUBTITLE_FONT_FAMILY = const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP'; 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY; const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)'; const DEFAULT_SUBTITLE_TEXT_SHADOW =
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar']; const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string { function makeTempDir(): string {
+5 -3
View File
@@ -23,7 +23,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
wordSpacing: 0, wordSpacing: 0,
fontKerning: 'normal', fontKerning: 'normal',
textRendering: 'geometricPrecision', textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)', textShadow:
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)',
paintOrder: '', paintOrder: '',
WebkitTextStroke: '', WebkitTextStroke: '',
fontStyle: 'normal', fontStyle: 'normal',
@@ -41,7 +42,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
frequencyDictionary: { frequencyDictionary: {
enabled: false, enabled: false,
sourcePath: '', sourcePath: '',
topX: 1000, topX: 10000,
mode: 'single', mode: 'single',
matchMode: 'headword', matchMode: 'headword',
singleColor: '#f5a97f', singleColor: '#f5a97f',
@@ -57,7 +58,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
wordSpacing: 0, wordSpacing: 0,
fontKerning: 'normal', fontKerning: 'normal',
textRendering: 'geometricPrecision', textRendering: 'geometricPrecision',
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)', textShadow:
'-1px -1px 2px rgba(0,0,0,0.95), 1px -1px 2px rgba(0,0,0,0.95), -1px 1px 2px rgba(0,0,0,0.95), 1px 1px 2px rgba(0,0,0,0.95), 0 0 8px rgba(0,0,0,0.5)',
paintOrder: '', paintOrder: '',
WebkitTextStroke: '', WebkitTextStroke: '',
backgroundColor: 'transparent', backgroundColor: 'transparent',
+1 -1
View File
@@ -127,7 +127,7 @@ export function buildSubtitleConfigOptionRegistry(
path: 'subtitleStyle.frequencyDictionary.topX', path: 'subtitleStyle.frequencyDictionary.topX',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX, defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX,
description: 'Only color tokens with frequency rank <= topX (default: 1000).', description: 'Only color tokens with frequency rank <= topX (default: 10000).',
}, },
{ {
path: 'subtitleStyle.frequencyDictionary.mode', path: 'subtitleStyle.frequencyDictionary.mode',
+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,
@@ -242,3 +242,59 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause'); assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause');
}); });
test('prefetch service skips cues already present in tokenization cache', async () => {
const cues = makeCues(5);
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.ok(!tokenizedTexts.includes('line-0'));
assert.ok(!tokenizedTexts.includes('line-1'));
assert.ok(tokenizedTexts.includes('line-2'));
});
test('prefetch service deduplicates repeated cue text within a run', async () => {
const cues: SubtitleCue[] = [
{ startTime: 0, endTime: 1, text: 'same' },
{ startTime: 1, endTime: 2, text: 'same' },
{ startTime: 2, endTime: 3, text: 'other' },
];
const tokenizedTexts: string[] = [];
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizedTexts.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 10; i += 1) {
await flushMicrotasks();
}
service.stop();
assert.deepEqual(tokenizedTexts.filter((text) => text === 'same'), ['same']);
assert.ok(tokenizedTexts.includes('other'));
});
+16 -3
View File
@@ -1,10 +1,12 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
import type { SubtitleCue } from '../../types'; import type { SubtitleCue } from '../../types';
import { normalizeSubtitleCacheKey } from './subtitle-processing-controller';
export interface SubtitlePrefetchServiceDeps { export interface SubtitlePrefetchServiceDeps {
cues: SubtitleCue[]; cues: SubtitleCue[];
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
priorityWindowSize?: number; priorityWindowSize?: number;
} }
@@ -58,6 +60,7 @@ export function createSubtitlePrefetchService(
async function tokenizeCueList( async function tokenizeCueList(
cuesToProcess: SubtitleCue[], cuesToProcess: SubtitleCue[],
runId: number, runId: number,
warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {}, options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> { ): Promise<void> {
for (const cue of cuesToProcess) { for (const cue of cuesToProcess) {
@@ -78,6 +81,15 @@ export function createSubtitlePrefetchService(
return; return;
} }
const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) {
warmedKeys.add(cacheKey);
}
continue;
}
warmedKeys.add(cacheKey);
try { try {
const result = await deps.tokenizeSubtitle(cue.text); const result = await deps.tokenizeSubtitle(cue.text);
if (result && !stopped && runId === currentRunId) { if (result && !stopped && runId === currentRunId) {
@@ -94,10 +106,11 @@ export function createSubtitlePrefetchService(
async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> { async function startPrefetching(currentTimeSeconds: number, runId: number): Promise<void> {
const cues = deps.cues; const cues = deps.cues;
const warmedKeys = new Set<string>();
// Phase 1: Priority window // Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true }); await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -108,7 +121,7 @@ export function createSubtitlePrefetchService(
const remainingCues = cues.filter( const remainingCues = cues.filter(
(cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(remainingCues, runId); await tokenizeCueList(remainingCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) { if (stopped || runId !== currentRunId) {
return; return;
@@ -118,7 +131,7 @@ export function createSubtitlePrefetchService(
const earlierCues = cues.filter( const earlierCues = cues.filter(
(cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text),
); );
await tokenizeCueList(earlierCues, runId); await tokenizeCueList(earlierCues, runId, warmedKeys);
} }
return { return {
@@ -236,6 +236,31 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing
assert.deepEqual(emitted, []); assert.deepEqual(emitted, []);
}); });
test('hasCachedSubtitle checks prefetched entries without consuming them', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.preCacheTokenization('猫\\Nです', { text: '猫\nです', tokens: [] });
assert.equal(controller.hasCachedSubtitle('猫\nです'), true);
controller.onSubtitleChange('猫\nです');
await flushMicrotasks();
assert.equal(tokenizeCalls, 0);
assert.deepEqual(emitted, [{ text: '猫\nです', tokens: [] }]);
controller.invalidateTokenizationCache();
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
});
test('isCacheFull returns false when cache is below limit', () => { test('isCacheFull returns false when cache is below limit', () => {
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }), tokenizeSubtitle: async (text) => ({ text, tokens: null }),
@@ -13,10 +13,11 @@ export interface SubtitleProcessingController {
invalidateTokenizationCache: () => void; invalidateTokenizationCache: () => void;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null; consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
} }
function normalizeSubtitleCacheKey(text: string): string { export function normalizeSubtitleCacheKey(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim(); return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
} }
@@ -152,6 +153,9 @@ export function createSubtitleProcessingController(
refreshRequested = false; refreshRequested = false;
return cached; return cached;
}, },
hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
},
isCacheFull: () => { isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
}, },
@@ -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 () => {
+28 -1
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,
@@ -1753,10 +1754,17 @@ function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
} }
appState.currentSubText = text; appState.currentSubText = text;
subtitlePrefetchService?.pause();
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
emitSubtitlePayload(cachedPayload);
return true;
}
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null }); const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
appState.currentSubtitleData = rawPayload; appState.currentSubtitleData = rawPayload;
broadcastToOverlayWindows('subtitle:set', rawPayload); broadcastToOverlayWindows('subtitle:set', rawPayload);
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
return true; return true;
} }
@@ -1833,6 +1841,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
preCacheTokenization: (text, data) => { preCacheTokenization: (text, data) => {
subtitleProcessingController.preCacheTokenization(text, data); subtitleProcessingController.preCacheTokenization(text, data);
}, },
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(), isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message), logWarn: (message) => logger.warn(message),
@@ -2067,6 +2076,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;
@@ -4207,6 +4227,12 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
}; };
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause();
}
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
}; };
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
@@ -4591,6 +4617,7 @@ const {
}, },
onSubtitleChange: (text) => { onSubtitleChange: (text) => {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
}, },
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
+59
View File
@@ -89,6 +89,65 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
); );
}); });
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
);
assert.ok(
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
);
});
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
const source = readMainSource();
const actionBlock = source.match(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/const cachedPayload = subtitleProcessingController\.consumeCachedSubtitle\(text\);/,
);
assert.match(actionBlock, /if \(cachedPayload\) \{/);
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
assert.match(actionBlock, /const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/);
assert.ok(
actionBlock.indexOf('consumeCachedSubtitle(text)') <
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /subtitleProcessingController\.invalidateTokenizationCache\(\);/);
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
assert.match(
actionBlock,
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
);
assert.ok(
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
actionBlock.indexOf('subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);'),
);
});
test('manual visible overlay changes notify mpv plugin visibility state', () => { test('manual visible overlay changes notify mpv plugin visibility state', () => {
const source = readMainSource(); const source = readMainSource();
const setBlock = source.match( const setBlock = source.match(
@@ -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 {
@@ -13,6 +13,7 @@ export interface SubtitlePrefetchInitControllerDeps {
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void; preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean; isCacheFull: () => boolean;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
@@ -67,6 +68,7 @@ export function createSubtitlePrefetchInitController(
cues, cues,
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(), isCacheFull: () => deps.isCacheFull(),
}); });
+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);
+22 -10
View File
@@ -667,9 +667,13 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
--subtitle-frequency-band-3-color: #f9e2af; --subtitle-frequency-band-3-color: #f9e2af;
--subtitle-frequency-band-4-color: #8bd5ca; --subtitle-frequency-band-4-color: #8bd5ca;
--subtitle-frequency-band-5-color: #8aadf4; --subtitle-frequency-band-5-color: #8aadf4;
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8), -1px -1px 2px rgba(0, 0, 0, 0.95),
-1px -1px 2px rgba(0, 0, 0, 0.5); 1px -1px 2px rgba(0, 0, 0, 0.95),
-1px 1px 2px rgba(0, 0, 0, 0.95),
1px 1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.5);
/* Enable text selection for Yomitan */ /* Enable text selection for Yomitan */
user-select: text; user-select: text;
cursor: text; cursor: text;
@@ -817,6 +821,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
display: inline-block; display: inline-block;
position: relative; position: relative;
padding-left: 1.08em; padding-left: 1.08em;
margin-left: 0.18em;
vertical-align: baseline; vertical-align: baseline;
} }
@@ -837,7 +842,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n1 { #subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796); border-bottom: 3px solid var(--subtitle-jlpt-n1-color, #ed8796);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
@@ -846,7 +852,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n2 { #subtitleRoot .word.word-jlpt-n2 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n2-color, #f5a97f); border-bottom: 3px solid var(--subtitle-jlpt-n2-color, #f5a97f);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
@@ -855,7 +862,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n3 { #subtitleRoot .word.word-jlpt-n3 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n3-color, #f9e2af); border-bottom: 3px solid var(--subtitle-jlpt-n3-color, #f9e2af);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
@@ -864,7 +872,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n4 { #subtitleRoot .word.word-jlpt-n4 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n4-color, #a6e3a1); border-bottom: 3px solid var(--subtitle-jlpt-n4-color, #a6e3a1);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
@@ -873,7 +882,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-jlpt-n5 { #subtitleRoot .word.word-jlpt-n5 {
text-decoration-line: none; text-decoration-line: none;
border-bottom: 2px solid var(--subtitle-jlpt-n5-color, #8aadf4); border-bottom: 3px solid var(--subtitle-jlpt-n5-color, #8aadf4);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
} }
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after { #subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
@@ -1186,9 +1196,11 @@ body.layer-modal #overlay {
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
paint-order: stroke fill; paint-order: stroke fill;
text-shadow: text-shadow:
0 2px 4px rgba(0, 0, 0, 0.95), -1px -1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.8), 1px -1px 2px rgba(0, 0, 0, 0.95),
0 0 16px rgba(0, 0, 0, 0.55); -1px 1px 2px rgba(0, 0, 0, 0.95),
1px 1px 2px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.5);
user-select: text; user-select: text;
cursor: text; cursor: text;
} }
+4 -3
View File
@@ -909,8 +909,8 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.doesNotMatch(plainJlptBlock, /text-decoration\s*:[^;]*\bunderline\b/i); assert.doesNotMatch(plainJlptBlock, /text-decoration\s*:[^;]*\bunderline\b/i);
assert.match( assert.match(
plainJlptBlock, plainJlptBlock,
new RegExp(`border-bottom:\\s*2px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`), new RegExp(`border-bottom:\\s*3px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`),
`JLPT level must paint a permanent 2px border-bottom in the level color`, `JLPT level must paint a permanent 3px border-bottom in the level color`,
); );
// JLPT tagging must communicate level *only* via the underline; it must // JLPT tagging must communicate level *only* via the underline; it must
@@ -973,6 +973,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(characterImageTokenBlock, /display:\s*inline-block;/); assert.match(characterImageTokenBlock, /display:\s*inline-block;/);
assert.match(characterImageTokenBlock, /position:\s*relative;/); assert.match(characterImageTokenBlock, /position:\s*relative;/);
assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/); assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/);
assert.match(characterImageTokenBlock, /margin-left:\s*0\.18em;/);
const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image'); const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image');
assert.match(characterImageBlock, /position:\s*absolute;/); assert.match(characterImageBlock, /position:\s*absolute;/);
@@ -1186,7 +1187,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(secondaryRootBlock, /-webkit-text-stroke:\s*0\.45px rgba\(0,\s*0,\s*0,\s*0\.7\);/); assert.match(secondaryRootBlock, /-webkit-text-stroke:\s*0\.45px rgba\(0,\s*0,\s*0,\s*0\.7\);/);
assert.match( assert.match(
secondaryRootBlock, secondaryRootBlock,
/text-shadow:\s*0 2px 4px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*0 0 8px rgba\(0,\s*0,\s*0,\s*0\.8\),\s*0 0 16px rgba\(0,\s*0,\s*0,\s*0\.55\);/, /text-shadow:\s*-1px -1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*1px -1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*-1px 1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*1px 1px 2px rgba\(0,\s*0,\s*0,\s*0\.95\),\s*0 0 8px rgba\(0,\s*0,\s*0,\s*0\.5\);/,
); );
const secondaryHoverBaseBlock = extractClassBlock( const secondaryHoverBaseBlock = extractClassBlock(
+1 -1
View File
@@ -89,7 +89,7 @@ function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string {
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = { const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
enabled: false, enabled: false,
topX: 1000, topX: 10000,
mode: 'single', mode: 'single',
singleColor: '#f5a97f', singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
@@ -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;
}