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

This commit is contained in:
2026-09-01 22:18:30 -07:00
committed by GitHub
parent 87b01155df
commit c0a78ef008
38 changed files with 695 additions and 101 deletions
@@ -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 word-card update uses configured fields with Lapis and Ki
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
});
@@ -275,8 +273,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
});
@@ -338,8 +335,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;
@@ -638,8 +637,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,8 @@ function createCollaborator(
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
fieldGroupingProvider:
options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider,
}),
getCurrentSubtitleText: () => options.currentSubtitleText,
resolveFieldName,
@@ -251,7 +254,218 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
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="group2">次<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="group2">Show EP2 (0:02:00)</span>',
);
});
test('Senren merge warns for invalid source audio when kept audio is empty', async () => {
const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = [];
const { collaborator } = createCollaborator({
fieldGroupingProvider: 'senren',
warnings,
});
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, { SentenceAudio: '' }),
makeNote(200, { SentenceAudio: 'invalid audio' }),
false,
);
assert.equal(merged.SentenceAudio, 'invalid audio');
assert.deepEqual(warnings, [
{
fieldName: 'SentenceAudio',
reason: 'missing-sound-tag',
detail: undefined,
},
]);
});
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="group3">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="group3">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 rebases plain source groups after empty and sparse kept fields', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
sentence: '',
miscInfo: '<span class="group">keep first</span>',
}),
makeNote(200, {
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
sentence: '<span class="group">source first</span>',
miscInfo: '<span class="group">source first</span><span class="group2">source second</span>',
}),
false,
);
assert.equal(merged.sentence, '<span class="group3">source first</span>');
assert.equal(
merged.miscInfo,
'<span class="group">keep first</span><span class="group3">source first</span>' +
'<span class="group4">source second</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="group4">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('Senren merge closes unclosed trailing markup before appending later scenes', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, { miscInfo: 'leading<span class="highlight">tail' }),
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
false,
);
assert.equal(
merged.miscInfo,
'<span class="group">leading<span class="highlight">tail</span></span>' +
'<span class="group">next</span>',
);
});
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -268,3 +482,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note
assert.equal(merged.SentenceFurigana, '');
});
test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: '',
}),
makeNote(200, {
SentenceFurigana: 'duplicate furigana',
}),
false,
);
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
});
+154 -1
View File
@@ -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) {
@@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator {
if (!existingValue.trim() && !newValue.trim()) continue;
if (keepFieldNormalized === 'sentencefurigana') {
const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0;
const usesSenrenGrouping =
this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren';
mergedFields[keepFieldName] =
existingValue.trim() && newValue.trim()
hasBothValues || usesSenrenGrouping
? this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
)
: '';
continue;
@@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else if (existingValue.trim() && newValue.trim()) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
@@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else {
if (!newValue.trim()) continue;
@@ -342,13 +356,152 @@ 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 rebaseSenrenGroup(entry: string, sceneOffset: number, sourceEntryIndex: 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) => {
if (classToken === 'group') {
return `group${sceneOffset + sourceEntryIndex + 1}`;
}
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)}${'</span>'.repeat(depth)}`);
}
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 (added && !/\[sound:[^\]]+\]/.test(added)) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
if (!existing || !added) return existing || added;
return existing + added;
}
const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry, sourceEntryIndex) =>
this.rebaseSenrenGroup(entry, sourceSceneOffset, sourceEntryIndex),
);
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 }>) => {
@@ -136,7 +136,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
fieldGroupingMode: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
@@ -193,7 +193,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 () =>
[
@@ -226,7 +226,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 () =>
@@ -262,7 +262,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 () =>
@@ -317,7 +317,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 () =>
[
@@ -360,7 +360,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
@@ -40,7 +40,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);
@@ -350,7 +348,7 @@ export class NoteUpdateWorkflow {
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
@@ -359,7 +357,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 }