mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix: disable macOS mpv menu shortcuts, buffer latest subtitle IPC state
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv - Replace queued IPC listener with latest-value variant for subtitle channels - Skip JSONC line/block comments in duplicate-key offset helpers - Preserve configured Anki note model name in selectPreferredNoteFieldModelName - Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue - Apply asCssColor on hover token CSS compat reads
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: shortcuts
|
||||||
|
|
||||||
|
- Disabled native mpv menu shortcuts during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus.
|
||||||
+13
-11
@@ -61,6 +61,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||||
|
|
||||||
|
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
@@ -68,7 +70,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||||
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
|
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
@@ -96,17 +98,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
|
|||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | ------------------------ |
|
| ----- | -------------------------------------- |
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
| `y-y` | Open SubMiner menu (OSD) |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `v` | Toggle primary subtitle bar visibility |
|
| `v` | Toggle primary subtitle bar visibility |
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
| `y-h` | Open session help |
|
| `y-h` | Open session help |
|
||||||
|
|
||||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,15 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildConfiguredMpvDefaultArgs disables macOS menu shortcuts so SubMiner bindings reach mpv', () => {
|
||||||
|
withPlatform('darwin', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildConfiguredMpvDefaultArgs(makeArgs()).includes('--macos-menu-shortcuts=no'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
|
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
|
||||||
const pluginDir = '/opt/SubMiner/plugin/subminer';
|
const pluginDir = '/opt/SubMiner/plugin/subminer';
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -1248,6 +1248,10 @@ export function buildConfiguredMpvDefaultArgs(
|
|||||||
const mpvArgs: string[] = [];
|
const mpvArgs: string[] = [];
|
||||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// macOS menu accelerators do not reach mpv script bindings unless disabled.
|
||||||
|
mpvArgs.push('--macos-menu-shortcuts=no');
|
||||||
|
}
|
||||||
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
|
mpvArgs.push(...buildMpvBackendArgs(args, baseEnv));
|
||||||
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
|
mpvArgs.push(...buildMpvLaunchModeArgs(args.launchMode));
|
||||||
return mpvArgs;
|
return mpvArgs;
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-bac
|
|||||||
function applySubtitleHoverTokenCssCompatibility(
|
function applySubtitleHoverTokenCssCompatibility(
|
||||||
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||||
): void {
|
): void {
|
||||||
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
|
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
|
||||||
if (hoverTokenColor !== undefined) {
|
if (hoverTokenColor !== undefined) {
|
||||||
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
|
const hoverTokenBackgroundColor = asCssColor(
|
||||||
|
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
|
||||||
|
);
|
||||||
if (hoverTokenBackgroundColor !== undefined) {
|
if (hoverTokenBackgroundColor !== undefined) {
|
||||||
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
|
|||||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
|
||||||
|
const { context } = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
css: {
|
||||||
|
'--subtitle-hover-token-color': 'purple',
|
||||||
|
'--subtitle-hover-token-background-color': '#363a4fd6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applySubtitleDomainConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
|
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ test('applyConfigSettingsPatchToContent updates effective duplicate object path'
|
|||||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
|
||||||
|
const input = `{
|
||||||
|
"ankiConnect": {
|
||||||
|
"nPlusOne": {
|
||||||
|
"enabled": false
|
||||||
|
} /* old value */ ,
|
||||||
|
// effective value follows
|
||||||
|
"nPlusOne": {
|
||||||
|
"minSentenceWords": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = applyConfigSettingsPatchToContent({
|
||||||
|
content: input,
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
previousWarnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const parsed = parse(result.content);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||||
|
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||||
const input = `{
|
const input = `{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
|
|||||||
@@ -125,16 +125,55 @@ function isWhitespace(value: string | undefined): boolean {
|
|||||||
|
|
||||||
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
function nextNonWhitespaceOffset(content: string, offset: number): number {
|
||||||
let index = offset;
|
let index = offset;
|
||||||
while (index < content.length && isWhitespace(content[index])) {
|
while (index < content.length) {
|
||||||
index += 1;
|
if (isWhitespace(content[index])) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (content[index] === '/' && content[index + 1] === '/') {
|
||||||
|
index += 2;
|
||||||
|
while (index < content.length && content[index] !== '\n') index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (content[index] === '/' && content[index + 1] === '*') {
|
||||||
|
index += 2;
|
||||||
|
while (
|
||||||
|
index + 1 < content.length &&
|
||||||
|
!(content[index] === '*' && content[index + 1] === '/')
|
||||||
|
) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
index = Math.min(content.length, index + 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
function previousNonWhitespaceOffset(content: string, offset: number): number {
|
||||||
let index = offset;
|
let index = offset;
|
||||||
while (index >= 0 && isWhitespace(content[index])) {
|
while (index >= 0) {
|
||||||
index -= 1;
|
if (isWhitespace(content[index])) {
|
||||||
|
index -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lineStart = content.lastIndexOf('\n', index) + 1;
|
||||||
|
const linePrefix = content.slice(lineStart, index + 1);
|
||||||
|
const lineCommentStart = linePrefix.lastIndexOf('//');
|
||||||
|
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
|
||||||
|
index = lineStart - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (content[index] === '/' && content[index - 1] === '*') {
|
||||||
|
index -= 2;
|
||||||
|
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
|
||||||
|
index -= 1;
|
||||||
|
}
|
||||||
|
index -= 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ test('settings preload exposes Anki lookup helpers', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlay preload queues subtitle updates until renderer listener registration', () => {
|
test('overlay preload buffers only latest subtitle state until renderer listener registration', () => {
|
||||||
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/const onSubtitleSetEvent =\s*createQueuedIpcListenerWithPayload<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
|
/const onSubtitleSetEvent =\s*createLatestValueIpcListenerWithPayload<SubtitleData>\(\s*IPC_CHANNELS\.event\.subtitleSet,/,
|
||||||
);
|
);
|
||||||
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-5
@@ -122,6 +122,37 @@ function createQueuedIpcListenerWithPayload<T>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLatestValueIpcListenerWithPayload<T>(
|
||||||
|
channel: string,
|
||||||
|
normalize: (payload: unknown) => T,
|
||||||
|
): (listener: PayloadedListener<T>) => void {
|
||||||
|
let pending: T | undefined;
|
||||||
|
const listeners: PayloadedListener<T>[] = [];
|
||||||
|
|
||||||
|
const dispatch = (payload: T): void => {
|
||||||
|
if (listeners.length === 0) {
|
||||||
|
pending = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on(channel, (_event: IpcRendererEvent, payloadArg: unknown) => {
|
||||||
|
dispatch(normalize(payloadArg));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (listener: PayloadedListener<T>): void => {
|
||||||
|
listeners.push(listener);
|
||||||
|
if (pending !== undefined) {
|
||||||
|
const payload = pending;
|
||||||
|
pending = undefined;
|
||||||
|
listener(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||||
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
||||||
@@ -161,23 +192,23 @@ const onKikuFieldGroupingRequestEvent =
|
|||||||
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||||
(payload) => payload as KikuFieldGroupingRequestData,
|
(payload) => payload as KikuFieldGroupingRequestData,
|
||||||
);
|
);
|
||||||
const onSubtitleSetEvent = createQueuedIpcListenerWithPayload<SubtitleData>(
|
const onSubtitleSetEvent = createLatestValueIpcListenerWithPayload<SubtitleData>(
|
||||||
IPC_CHANNELS.event.subtitleSet,
|
IPC_CHANNELS.event.subtitleSet,
|
||||||
(payload) => payload as SubtitleData,
|
(payload) => payload as SubtitleData,
|
||||||
);
|
);
|
||||||
const onSubtitleVisibilityEvent = createQueuedIpcListenerWithPayload<boolean>(
|
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
|
||||||
IPC_CHANNELS.event.subtitleVisibility,
|
IPC_CHANNELS.event.subtitleVisibility,
|
||||||
(payload) => payload === true,
|
(payload) => payload === true,
|
||||||
);
|
);
|
||||||
const onSubtitlePositionSetEvent = createQueuedIpcListenerWithPayload<SubtitlePosition | null>(
|
const onSubtitlePositionSetEvent = createLatestValueIpcListenerWithPayload<SubtitlePosition | null>(
|
||||||
IPC_CHANNELS.event.subtitlePositionSet,
|
IPC_CHANNELS.event.subtitlePositionSet,
|
||||||
(payload) => payload as SubtitlePosition | null,
|
(payload) => payload as SubtitlePosition | null,
|
||||||
);
|
);
|
||||||
const onSecondarySubtitleSetEvent = createQueuedIpcListenerWithPayload<string>(
|
const onSecondarySubtitleSetEvent = createLatestValueIpcListenerWithPayload<string>(
|
||||||
IPC_CHANNELS.event.secondarySubtitleSet,
|
IPC_CHANNELS.event.secondarySubtitleSet,
|
||||||
(payload) => (typeof payload === 'string' ? payload : ''),
|
(payload) => (typeof payload === 'string' ? payload : ''),
|
||||||
);
|
);
|
||||||
const onSecondarySubtitleModeEvent = createQueuedIpcListenerWithPayload<SecondarySubMode>(
|
const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<SecondarySubMode>(
|
||||||
IPC_CHANNELS.event.secondarySubtitleMode,
|
IPC_CHANNELS.event.secondarySubtitleMode,
|
||||||
(payload) => payload as SecondarySubMode,
|
(payload) => payload as SecondarySubMode,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as ankiControls from './settings-anki-controls';
|
import * as ankiControls from './settings-anki-controls';
|
||||||
|
|
||||||
test('note field model preference ignores configured sentence-card model before Kiku fallback', () => {
|
test('note field model preference keeps configured sentence-card model before Kiku fallback', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||||
'Kiku',
|
'Lapis Morph',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('note field model preference ignores configured sentence-card model case-insensitively', () => {
|
test('note field model preference keeps configured sentence-card model case-insensitively', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
||||||
'Kiku',
|
'Lapis Morph',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,15 +29,23 @@ test('note field model preference does not treat partial Kiku matches as Kiku',
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
||||||
assert.equal(
|
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''), '');
|
||||||
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('note field model preference stays blank when no Kiku or Lapis note type exists', () => {
|
test('note field model preference stays blank when no current Kiku or Lapis note type exists', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('known word deck rename selection keeps current deck on collision', () => {
|
||||||
|
assert.equal(
|
||||||
|
ankiControls.chooseKnownWordsDeckRenameValue(
|
||||||
|
{ Mining: ['Word'], Core: ['Expression'] },
|
||||||
|
'Core',
|
||||||
|
'Mining',
|
||||||
|
),
|
||||||
|
'Core',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export function selectPreferredNoteFieldModelName(
|
|||||||
modelNames: readonly string[],
|
modelNames: readonly string[],
|
||||||
currentModelName = '',
|
currentModelName = '',
|
||||||
): string {
|
): string {
|
||||||
void currentModelName;
|
const normalizedCurrent = currentModelName.trim().toLowerCase();
|
||||||
|
if (normalizedCurrent) {
|
||||||
|
const current = modelNames.find((name) => name.trim().toLowerCase() === normalizedCurrent);
|
||||||
|
if (current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
|
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
|
||||||
if (exactKiku) {
|
if (exactKiku) {
|
||||||
@@ -70,6 +76,20 @@ export function selectPreferredNoteFieldModelName(
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chooseKnownWordsDeckRenameValue(
|
||||||
|
decks: Record<string, string[]>,
|
||||||
|
currentDeckName: string,
|
||||||
|
nextDeckName: string,
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
nextDeckName !== currentDeckName &&
|
||||||
|
Object.prototype.hasOwnProperty.call(decks, nextDeckName)
|
||||||
|
) {
|
||||||
|
return currentDeckName;
|
||||||
|
}
|
||||||
|
return nextDeckName;
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -491,16 +511,23 @@ export function renderKnownWordsDecksInput(
|
|||||||
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
||||||
}
|
}
|
||||||
const row = createElement('div', 'deck-field-row');
|
const row = createElement('div', 'deck-field-row');
|
||||||
|
const usedDeckNames = new Set(Object.keys(currentDecks));
|
||||||
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
|
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
|
||||||
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
|
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
|
||||||
|
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
|
||||||
addOption(deckSelect, candidateDeck);
|
addOption(deckSelect, candidateDeck);
|
||||||
}
|
}
|
||||||
deckSelect.value = deckName;
|
deckSelect.value = deckName;
|
||||||
deckSelect.addEventListener('change', () => {
|
deckSelect.addEventListener('change', () => {
|
||||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||||
|
const nextDeckName = chooseKnownWordsDeckRenameValue(nextDecks, deckName, deckSelect.value);
|
||||||
|
if (nextDeckName !== deckSelect.value) {
|
||||||
|
deckSelect.value = nextDeckName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fields = nextDecks[deckName] ?? [];
|
const fields = nextDecks[deckName] ?? [];
|
||||||
delete nextDecks[deckName];
|
delete nextDecks[deckName];
|
||||||
nextDecks[deckSelect.value] = fields;
|
nextDecks[nextDeckName] = fields;
|
||||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||||
requestRender();
|
requestRender();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user