feat(anki): support Senren scene-switching field grouping

- Add Senren configuration, duplicate merging, and scene-aligned media fields
- Document Senren setup and enforce Kiku/Senren exclusivity
This commit is contained in:
2026-08-31 23:29:55 -07:00
parent 14c0086520
commit 9e1c8f91b2
38 changed files with 595 additions and 94 deletions
+1
View File
@@ -153,6 +153,7 @@ function createFieldGroupingMergeCollaborator(options?: {
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
fieldGroupingProvider: 'kiku' as const,
}),
getCurrentSubtitleText: () => options?.currentSubtitleText,
resolveFieldName,
+40 -3
View File
@@ -835,6 +835,19 @@ export class AnkiIntegration {
};
}
private getSenrenConfig(): {
enabled: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
} {
const senren = this.config.isSenren;
return {
enabled: senren?.enabled === true,
fieldGrouping: senren?.fieldGrouping,
deleteDuplicateInAuto: senren?.deleteDuplicateInAuto,
};
}
private getEffectiveSentenceCardConfig(): {
model?: string;
sentenceField: string;
@@ -843,10 +856,27 @@ export class AnkiIntegration {
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
senrenEnabled: boolean;
fieldGroupingProvider: 'kiku' | 'senren' | null;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
fieldGroupingDeleteDuplicateInAuto: boolean;
wordCardKind: WordCardKind;
} {
const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig();
const senren = this.getSenrenConfig();
const kikuFieldGrouping = (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled';
const senrenFieldGrouping = (senren.fieldGrouping || 'auto') as 'auto' | 'manual' | 'disabled';
// Kiku and Senren are mutually exclusive; config resolution enforces it, and
// Kiku wins here too in case a runtime patch re-enables both.
const fieldGroupingProvider = kiku.enabled ? 'kiku' : senren.enabled ? 'senren' : null;
const fieldGroupingMode =
fieldGroupingProvider === 'kiku'
? kikuFieldGrouping
: fieldGroupingProvider === 'senren'
? senrenFieldGrouping
: 'disabled';
return {
model: lapis.sentenceCardModel,
@@ -854,8 +884,15 @@ export class AnkiIntegration {
audioField: 'SentenceAudio',
lapisEnabled: lapis.enabled,
kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
kikuFieldGrouping,
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
senrenEnabled: senren.enabled,
fieldGroupingProvider,
fieldGroupingMode,
fieldGroupingDeleteDuplicateInAuto:
fieldGroupingProvider === 'senren'
? senren.deleteDuplicateInAuto !== false
: kiku.deleteDuplicateInAuto !== false,
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
};
}
@@ -874,7 +911,7 @@ export class AnkiIntegration {
private async processNewCard(
noteId: number,
options?: { skipKikuFieldGrouping?: boolean },
options?: { skipFieldGrouping?: boolean },
): Promise<void> {
await this.noteUpdateWorkflow.execute(noteId, options);
}
@@ -1496,7 +1533,7 @@ export class AnkiIntegration {
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
): boolean {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
return false;
}
@@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -208,8 +207,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
setCardTypeFields,
});
@@ -117,8 +117,7 @@ test('sentence card writes generated audio only to sentence audio field', async
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
+8 -16
View File
@@ -69,8 +69,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -168,8 +167,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -267,8 +265,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -387,8 +384,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -490,8 +486,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -629,8 +624,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -728,8 +722,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'manual',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -817,8 +810,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'manual',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
+2 -4
View File
@@ -132,8 +132,7 @@ interface CardCreationDeps {
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
};
getFallbackDurationSeconds: () => number;
@@ -632,8 +631,7 @@ export class CardCreationService {
).trim();
let duplicateNoteIds: number[] = [];
if (
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
sentenceCardConfig.fieldGroupingMode !== 'disabled' &&
pendingExpressionText &&
this.deps.findDuplicateNoteIds
) {
@@ -26,6 +26,7 @@ function createCollaborator(
miscInfoValue?: string;
};
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
fieldGroupingProvider?: 'kiku' | 'senren' | null;
} = {},
) {
const warnings = options.warnings ?? [];
@@ -46,6 +47,7 @@ function createCollaborator(
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
fieldGroupingProvider: options.fieldGroupingProvider ?? 'kiku',
}),
getCurrentSubtitleText: () => options.currentSubtitleText,
resolveFieldName,
@@ -251,6 +253,147 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});
test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
word: '語',
sentence: '<span class="group">前<span class="highlight">語</span>後</span>',
sentenceAudio: '[sound:original.opus]',
picture: '<img src="original.webp">',
miscInfo: '<span class="group">Show EP1 (0:01:00)</span>',
}),
makeNote(200, {
word: '語',
sentence: '<span class="group">次<span class="highlight">語</span>文</span>',
sentenceAudio: '[sound:new.opus]',
picture: '<img src="new.webp">',
miscInfo: 'Show EP2 (0:02:00)',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">前<span class="highlight">語</span>後</span>' +
'<span class="group">次<span class="highlight">語</span>文</span>',
);
assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]');
assert.equal(merged.picture, '<img src="original.webp"><img src="new.webp">');
assert.equal(
merged.miscInfo,
'<span class="group">Show EP1 (0:01:00)</span><span class="group">Show EP2 (0:02:00)</span>',
);
});
test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
sentence: 'plain legacy sentence',
sentenceAudio: '[sound:a.opus][sound:b.opus]',
miscInfo: '<span class="group2">pinned</span> stray text',
}),
makeNote(200, {
sentence: '<span class="group">new sentence</span>',
sentenceAudio: '[sound:c.opus]',
miscInfo: '<span class="group">new misc</span>',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">plain legacy sentence</span><span class="group">new sentence</span>',
);
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]');
assert.equal(
merged.miscInfo,
'<span class="group2">pinned</span><span class="group">stray text</span>' +
'<span class="group">new misc</span>',
);
});
test('Senren merge rebases numbered groups from an appended source note', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
miscInfo: '<span class="group">keep one</span><span class="group">keep two</span>',
}),
makeNote(200, {
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
miscInfo: '<span class="group2">source two</span>',
}),
false,
);
assert.equal(
merged.sentenceAudio,
'[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]',
);
assert.equal(
merged.miscInfo,
'<span class="group">keep one</span><span class="group">keep two</span>' +
'<span class="group4">source two</span>',
);
});
test('Senren merge keeps ungrouped text in place around an existing group span', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
miscInfo: 'leading<span class="group">middle</span>trailing',
sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]',
}),
makeNote(200, {
miscInfo: '<span class="group">appended</span>',
sentenceAudio: '[sound:d.opus]',
}),
false,
);
// Order must follow the source field, and the two ungrouped runs must stay separate.
assert.equal(
merged.miscInfo,
'<span class="group">leading</span><span class="group">middle</span>' +
'<span class="group">trailing</span><span class="group">appended</span>',
);
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]');
});
test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, { miscInfo: '<span class="group">a<span class="highlight">b' }),
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
false,
);
assert.equal(
merged.miscInfo,
'<span class="group">a<span class="highlight">b</span></span><span class="group">next</span>',
);
const openTags = merged.miscInfo!.match(/<span\b/g)?.length ?? 0;
const closeTags = merged.miscInfo!.match(/<\/span>/g)?.length ?? 0;
assert.equal(openTags, closeTags);
});
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
@@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps {
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
fieldGroupingProvider: 'kiku' | 'senren' | null;
};
getCurrentSubtitleText: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
@@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator {
const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const senrenSourceSceneOffset =
sentenceCardConfig.fieldGroupingProvider === 'senren'
? this.countSenrenAudioScenes(
this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField),
)
: 0;
const sourceFields: Record<string, string> = {};
const resolvedKeepFieldByPreferred = new Map<string, string>();
for (const preferredFieldName of groupableFields) {
@@ -162,6 +170,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
)
: '';
continue;
@@ -174,6 +183,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else if (existingValue.trim() && newValue.trim()) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
@@ -182,6 +192,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else {
if (!newValue.trim()) continue;
@@ -342,13 +353,149 @@ export class FieldGroupingMergeCollaborator {
return [...entries].sort((a, b) => b.groupId - a.groupId);
}
private isSentenceAudioField(fieldName: string): boolean {
const normalized = fieldName.toLowerCase();
const audioField = (
this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio'
).toLowerCase();
return normalized === 'sentenceaudio' || normalized === audioField;
}
private isSenrenGroupOpenTag(openTag: string): boolean {
const classMatch =
openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i);
if (!classMatch) return false;
// Senren's templates match class tokens case-sensitively (/^group\d*$/).
return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token));
}
private countSenrenAudioScenes(value: string): number {
const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0;
if (soundEntries > 0) return soundEntries;
return this.parseSenrenSceneEntries(value).length;
}
private rebaseSenrenNumberedGroup(entry: string, sceneOffset: number): string {
if (sceneOffset <= 0) return entry;
return entry.replace(
/^(\s*<span\b[^>]*?\bclass\s*=\s*)(["'])([^"']*)\2/i,
(_match: string, prefix: string, quote: string, rawClasses: string) => {
const classes = rawClasses
.split(/(\s+)/)
.map((classToken) => {
const groupMatch = classToken.match(/^group(\d+)$/);
if (!groupMatch) return classToken;
const targetScene = Number(groupMatch[1]);
if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken;
return `group${targetScene + sceneOffset}`;
})
.join('');
return `${prefix}${quote}${classes}${quote}`;
},
);
}
/**
* Splits a Senren field into ordered scene entries. Top-level
* `<span class="group">`/`"groupN"` spans are kept verbatim (nested markup like
* `<span class="highlight">` included); ungrouped runs are wrapped in a group
* span at their original position, because Senren discards anything outside a
* group span once scene switching activates.
*/
private parseSenrenSceneEntries(value: string): string[] {
const tokenRegex = /<span\b[^>]*>|<\/span>/gi;
const entries: string[] = [];
const pushUngrouped = (raw: string): void => {
const text = raw.replace(/<br\s*\/?>/gi, ' ').trim();
if (text) entries.push(`<span class="group">${text}</span>`);
};
let cursor = 0;
let depth = 0;
let entryStart = -1;
let match;
while ((match = tokenRegex.exec(value)) !== null) {
const token = match[0]!;
if (token[1] !== '/') {
if (depth === 0 && this.isSenrenGroupOpenTag(token)) {
pushUngrouped(value.slice(cursor, match.index));
entryStart = match.index;
cursor = match.index;
}
depth += 1;
} else {
depth = Math.max(0, depth - 1);
if (depth === 0 && entryStart !== -1) {
const end = match.index + token.length;
entries.push(value.slice(entryStart, end));
entryStart = -1;
cursor = end;
}
}
}
if (entryStart !== -1) {
// Unclosed group span: close every span still open (the group and any nested
// markup) so the following scenes are siblings rather than nested inside it.
entries.push(`${value.slice(entryStart)}${'</span>'.repeat(depth)}`);
} else {
pushUngrouped(value.slice(cursor));
}
return entries;
}
/**
* Merges two notes' field values in Senren's scene-switching format. Scenes are
* appended in order (existing first, never resorted) so indices stay aligned
* across sentence/picture/miscInfo with the sentenceAudio entries, which alone
* drive Senren's scene count.
*/
private applySenrenFieldGrouping(
existingValue: string,
newValue: string,
fieldName: string,
sourceSceneOffset: number,
): string {
if (this.isPictureField(fieldName)) {
const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)];
if (tags.length === 0) return existingValue || newValue;
return tags.join('');
}
if (this.isSentenceAudioField(fieldName)) {
const existing = existingValue.trim();
const added = newValue.trim();
if (!existing || !added) return existing || added;
if (!/\[sound:[^\]]+\]/.test(added)) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return existing + added;
}
const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry) =>
this.rebaseSenrenNumberedGroup(entry, sourceSceneOffset),
);
const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries];
if (merged.length === 0) return existingValue || newValue;
return merged.join('');
}
private applyFieldGrouping(
existingValue: string,
newValue: string,
keepGroupId: number,
sourceGroupId: number,
fieldName: string,
senrenSourceSceneOffset: number,
): string {
if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') {
return this.applySenrenFieldGrouping(
existingValue,
newValue,
fieldName,
senrenSourceSceneOffset,
);
}
if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
@@ -71,7 +71,7 @@ function createWorkflowHarness() {
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
kikuDeleteDuplicateInAuto: true,
fieldGroupingDeleteDuplicateInAuto: true,
}),
getCurrentSubtitleText: () => 'subtitle-text',
getFieldGroupingCallback: (): FieldGroupingCallback | null => {
@@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps {
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingDeleteDuplicateInAuto: boolean;
};
getCurrentSubtitleText: () => string | undefined;
getFieldGroupingCallback:
@@ -75,7 +75,7 @@ export class FieldGroupingWorkflow {
originalNoteId,
newNoteId,
this.getExpression(newNoteInfo),
sentenceCardConfig.kikuDeleteDuplicateInAuto,
sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto,
);
} catch (error) {
this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
+14 -18
View File
@@ -21,14 +21,14 @@ function createHarness(
manualHandled?: boolean;
expression?: string | null;
currentSentenceImageField?: string | undefined;
onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void;
onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void;
} = {},
) {
const calls: string[] = [];
const findNotesQueries: Array<{ query: string; maxRetries?: number }> = [];
const noteInfoRequests: number[][] = [];
const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = [];
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
@@ -46,9 +46,8 @@ function createHarness(
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: options.kikuEnabled ?? true,
kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null,
fieldGroupingMode: options.kikuFieldGrouping ?? 'auto',
}),
isUpdateInProgress: () => false,
getDeck: options.deck ? () => options.deck : undefined,
@@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']);
assert.deepEqual(harness.calls, ['osd:Field grouping requires Kiku or Senren mode']);
assert.equal(harness.findNotesQueries.length, 0);
});
@@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']);
assert.deepEqual(harness.calls, ['osd:Field grouping is disabled']);
assert.equal(harness.findNotesQueries.length, 0);
});
@@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => true,
withUpdateProgress: async () => {
@@ -266,7 +264,7 @@ test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids be
});
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
const harness = createHarness({
noteIds: [11],
notesInfo: [
@@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]);
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]);
assert.deepEqual(harness.manualCalls, []);
});
@@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(),
@@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => {
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(),
+9 -10
View File
@@ -20,9 +20,8 @@ interface FieldGroupingDeps {
sentenceField: string;
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingProvider: 'kiku' | 'senren' | null;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
};
isUpdateInProgress: () => boolean;
getDeck?: () => string | undefined;
@@ -46,7 +45,7 @@ interface FieldGroupingDeps {
noteInfo: FieldGroupingNoteInfo,
configuredFieldNames: (string | undefined)[],
) => boolean;
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>;
processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise<void>;
getSentenceCardImageFieldName: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
computeFieldGroupingMergedFields: (
@@ -76,12 +75,12 @@ export class FieldGroupingService {
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled) {
this.deps.showOsdNotification('Kiku mode is not enabled');
if (sentenceCardConfig.fieldGroupingProvider === null) {
this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode');
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'disabled') {
this.deps.showOsdNotification('Kiku field grouping is disabled');
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
this.deps.showOsdNotification('Field grouping is disabled');
return;
}
@@ -134,7 +133,7 @@ export class FieldGroupingService {
])
) {
await this.deps.processNewCard(noteId, {
skipKikuFieldGrouping: true,
skipFieldGrouping: true,
});
}
@@ -147,7 +146,7 @@ export class FieldGroupingService {
const noteInfo = refreshedInfo[0]!;
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
@@ -58,7 +58,7 @@ function createWorkflowHarness() {
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
fieldGroupingMode: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
@@ -151,7 +151,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
});
harness.deps.client.notesInfo = async () =>
[
@@ -184,7 +184,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
wordCardKind: 'click',
});
harness.deps.client.notesInfo = async () =>
@@ -220,7 +220,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
wordCardKind: 'none',
});
harness.deps.client.notesInfo = async () =>
@@ -275,7 +275,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
fieldGroupingMode: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
@@ -318,7 +318,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
fieldGroupingMode: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
+5 -7
View File
@@ -39,7 +39,7 @@ export interface NoteUpdateWorkflowDeps {
sentenceField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
@@ -160,7 +160,7 @@ export class NoteUpdateWorkflow {
return null;
}
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card');
try {
const notesInfoResult = await this.deps.client.notesInfo([noteId]);
@@ -187,9 +187,7 @@ export class NoteUpdateWorkflow {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
!options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled';
let duplicateNoteId: number | null = null;
if (shouldRunFieldGrouping && hasExpressionText) {
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
@@ -345,7 +343,7 @@ export class NoteUpdateWorkflow {
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
@@ -354,7 +352,7 @@ export class NoteUpdateWorkflow {
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
if (sentenceCardConfig.fieldGroupingMode === 'manual') {
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}),
},
isSenren: {
...DEFAULT_ANKI_CONNECT_CONFIG.isSenren,
...(config.isSenren ?? {}),
},
lapisKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
...(config.lapisKiku ?? {}),
@@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime {
patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
isSenren:
patch.isSenren !== undefined
? { ...this.config.isSenren, ...patch.isSenren }
: this.config.isSenren,
lapisKiku:
patch.lapisKiku !== undefined
? { ...this.config.lapisKiku, ...patch.lapisKiku }
+42
View File
@@ -2188,6 +2188,7 @@ test('runtime options registry is centralized', () => {
'subtitle.annotation.frequency',
'anki.nPlusOneMatchMode',
'anki.kikuFieldGrouping',
'anki.senrenFieldGrouping',
]);
});
@@ -2775,6 +2776,47 @@ test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
);
});
test('forces Senren off when Kiku is also enabled and validates Senren fieldGrouping', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isKiku": { "enabled": true },
"isSenren": { "enabled": true }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().ankiConnect.isKiku.enabled, true);
assert.equal(service.getConfig().ankiConnect.isSenren.enabled, false);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
);
const senrenOnlyDir = makeTempDir();
fs.writeFileSync(
path.join(senrenOnlyDir, 'config.jsonc'),
`{
"ankiConnect": {
"isSenren": { "enabled": true, "fieldGrouping": "sometimes" }
}
}`,
'utf-8',
);
const senrenOnlyService = new ConfigService(senrenOnlyDir);
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.enabled, true);
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.fieldGrouping, 'auto');
assert.ok(
senrenOnlyService
.getWarnings()
.some((warning) => warning.path === 'ankiConnect.isSenren.fieldGrouping'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -91,6 +91,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
isSenren: {
enabled: false,
fieldGrouping: 'auto',
deleteDuplicateInAuto: true,
},
lapisKiku: {
wordCardKind: 'word-and-sentence',
},
@@ -363,6 +363,28 @@ export function buildIntegrationConfigOptionRegistry(
description:
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
},
{
path: 'ankiConnect.isSenren.fieldGrouping',
kind: 'enum',
enumValues: ['auto', 'manual', 'disabled'],
defaultValue: defaultConfig.ankiConnect.isSenren.fieldGrouping,
description: 'Senren duplicate-card field grouping mode (scene switching).',
runtime: runtimeOptionById.get('anki.senrenFieldGrouping'),
},
{
path: 'ankiConnect.isSenren.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isSenren.enabled,
description:
'Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled.',
},
{
path: 'ankiConnect.isSenren.deleteDuplicateInAuto',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isSenren.deleteDuplicateInAuto,
description:
'When Senren field grouping is "auto", delete the duplicate source card after grouping completes.',
},
{
path: 'ankiConnect.isLapis.enabled',
kind: 'boolean',
+17
View File
@@ -124,5 +124,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'anki.senrenFieldGrouping',
path: 'ankiConnect.isSenren.fieldGrouping',
label: 'Senren Field Grouping',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['auto', 'manual', 'disabled'],
defaultValue: 'auto',
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
isSenren: {
fieldGrouping:
value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'auto',
},
}),
},
];
}
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiSenrenResolution } from './anki-connect/senren';
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
@@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
applyAnkiSenrenResolution(context);
applyAnkiLapisKikuResolution(context, ankiConnect);
}
@@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution(
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
isSenren: {
...context.resolved.ankiConnect.isSenren,
...(isObject(ankiConnect.isSenren)
? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren'])
: {}),
},
lapisKiku: {
...context.resolved.ankiConnect.lapisKiku,
},
+34
View File
@@ -0,0 +1,34 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyAnkiSenrenResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isSenren.fieldGrouping',
context.resolved.ankiConnect.isSenren.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isSenren.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping;
}
// Kiku and Senren field grouping write incompatible markup into the same note
// fields, so only one may be active; Kiku wins to preserve pre-existing setups.
if (
context.resolved.ankiConnect.isSenren.enabled === true &&
context.resolved.ankiConnect.isKiku.enabled === true
) {
context.warn(
'ankiConnect.isSenren.enabled',
true,
false,
'Kiku and Senren are mutually exclusive; disable isKiku.enabled to use Senren field grouping.',
);
context.resolved.ankiConnect.isSenren.enabled = false;
}
}
+6 -3
View File
@@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
];
assert.equal(miningSections[0], 'AnkiConnect');
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
const kikuLapis = fields.filter(
(candidate) => candidate.section === 'Kiku/Lapis/Senren Features',
);
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
kikuLapis.slice(0, 3).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
);
});
@@ -366,6 +368,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
+9 -3
View File
@@ -131,7 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
'AnkiConnect',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Kiku/Lapis/Senren Features',
'Anki AI',
'AnkiConnect Proxy',
'Jimaku',
@@ -163,6 +163,7 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'ankiConnect.isSenren.enabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
@@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'ankiConnect.isSenren.enabled': 'Enable Senren Features',
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
@@ -251,7 +253,9 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled':
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping. Mutually exclusive with Senren.',
'ankiConnect.isSenren.enabled':
'Enable Senren-specific duplicate handling: field grouping merges duplicates into Senren scene-switching markup (including miscInfo grouping). Mutually exclusive with Kiku; only one can be enabled at a time.',
'ankiConnect.isLapis.enabled':
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel':
@@ -407,9 +411,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (
path.startsWith('ankiConnect.isKiku.') ||
path.startsWith('ankiConnect.isLapis.') ||
path.startsWith('ankiConnect.isSenren.') ||
path.startsWith('ankiConnect.lapisKiku.')
) {
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
return { category: 'mining-anki', section: 'Kiku/Lapis/Senren Features' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
@@ -709,6 +714,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.isSenren.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' ||
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) {
patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
}
+2 -2
View File
@@ -205,8 +205,8 @@
<div id="kikuSelectionStep">
<div class="kiku-info-text">
A card with the same expression already exists. Select which card to keep. The other
card's content will be merged using Kiku field grouping. You can choose whether to
delete the duplicate.
card's content will be merged using field grouping. You can choose whether to delete
the duplicate.
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
+1
View File
@@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
'anki.kikuFieldGrouping',
'anki.senrenFieldGrouping',
'anki.nPlusOneMatchMode',
];
+5
View File
@@ -124,6 +124,11 @@ export interface AnkiConnectConfig {
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
isSenren?: {
enabled?: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
lapisKiku?: {
wordCardKind?: WordCardKind;
};
+5
View File
@@ -285,6 +285,11 @@ export interface ResolvedConfig {
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
isSenren: {
enabled: boolean;
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
lapisKiku: {
wordCardKind: WordCardKind;
};
+1
View File
@@ -6,6 +6,7 @@ export type RuntimeOptionId =
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency'
| 'anki.kikuFieldGrouping'
| 'anki.senrenFieldGrouping'
| 'anki.nPlusOneMatchMode';
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';