mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 19:21:34 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eed0a6a243 | |||
| d33009d4a3 | |||
| 8d0535f3ca |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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: () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}:`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user