mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
feat(anki): add configurable word card type for Kiku/Lapis (#175)
This commit is contained in:
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
||||
import path from 'path';
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
type CardKind,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
NotificationOptions,
|
||||
type WordCardKind,
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
withUpdateProgress,
|
||||
UiFeedbackState,
|
||||
} from './anki-integration/ui-feedback';
|
||||
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||
import { PollingRunner } from './anki-integration/polling';
|
||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
wordCardKind: WordCardKind;
|
||||
} {
|
||||
const lapis = this.getLapisConfig();
|
||||
const kiku = this.getKikuConfig();
|
||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
||||
kikuEnabled: kiku.enabled,
|
||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
||||
availableFieldNames: string[],
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
const audioFlagNames = ['IsAudioCard'];
|
||||
|
||||
if (cardKind === 'word-and-sentence') {
|
||||
const wordAndSentenceFlag = this.resolveFieldName(
|
||||
availableFieldNames,
|
||||
'IsWordAndSentenceCard',
|
||||
);
|
||||
if (!wordAndSentenceFlag) {
|
||||
return;
|
||||
}
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||
updatedFields[sentenceFlag] = '';
|
||||
}
|
||||
|
||||
for (const audioFlagName of audioFlagNames) {
|
||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardKind === 'sentence') {
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag) {
|
||||
updatedFields[sentenceFlag] = 'x';
|
||||
}
|
||||
|
||||
for (const audioFlagName of audioFlagNames) {
|
||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||
if (resolved && resolved !== sentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const wordAndSentenceFlag = this.resolveFieldName(
|
||||
availableFieldNames,
|
||||
'IsWordAndSentenceCard',
|
||||
);
|
||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
||||
updatedFields[wordAndSentenceFlag] = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAudioFlags = Array.from(
|
||||
new Set(
|
||||
audioFlagNames
|
||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||
this.resolveFieldName(availableFieldNames, preferredName),
|
||||
);
|
||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
||||
if (audioFlagName) {
|
||||
updatedFields[audioFlagName] = 'x';
|
||||
}
|
||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
||||
updatedFields[extraAudioFlag] = '';
|
||||
}
|
||||
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
||||
updatedFields[sentenceFlag] = '';
|
||||
}
|
||||
|
||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
||||
updatedFields[wordAndSentenceFlag] = '';
|
||||
}
|
||||
}
|
||||
|
||||
private async showNotification(
|
||||
|
||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
if (cardKind !== 'word-and-sentence') return;
|
||||
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
applyCardKindFlagFields(
|
||||
updatedFields,
|
||||
cardKind,
|
||||
(preferredName) =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
setCardTypeFields,
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
interface CardCreationClient {
|
||||
addNote(
|
||||
deck: string,
|
||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
||||
if (sentenceField) {
|
||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||
if (wordCardKind) {
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
function resolverFor(availableFieldNames: string[]) {
|
||||
return (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||
|
||||
test('flags the requested card kind and clears the others', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||
|
||||
assert.deepEqual(fields, {
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('matches flag fields case-insensitively', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||
|
||||
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||
});
|
||||
|
||||
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(
|
||||
fields,
|
||||
'click',
|
||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||
);
|
||||
|
||||
assert.deepEqual(fields, {});
|
||||
});
|
||||
|
||||
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(
|
||||
fields,
|
||||
'audio',
|
||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||
);
|
||||
|
||||
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||
});
|
||||
|
||||
test('does not blank the target flag it just set', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||
|
||||
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
|
||||
/**
|
||||
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||
*/
|
||||
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||
click: 'IsClickCard',
|
||||
sentence: 'IsSentenceCard',
|
||||
audio: 'IsAudioCard',
|
||||
};
|
||||
|
||||
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||
'word-and-sentence',
|
||||
'click',
|
||||
'sentence',
|
||||
'audio',
|
||||
'none',
|
||||
];
|
||||
|
||||
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||
|
||||
/**
|
||||
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||
* when the note type actually carries the matching flag field, so plain note types keep
|
||||
* their fields untouched.
|
||||
*/
|
||||
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||
|
||||
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||
}
|
||||
|
||||
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||
* never ends up claiming to be two kinds of card at once.
|
||||
*/
|
||||
export function applyCardKindFlagFields(
|
||||
updatedFields: Record<string, string>,
|
||||
cardKind: CardKind,
|
||||
resolveFieldName: (preferredName: string) => string | null,
|
||||
): void {
|
||||
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||
return;
|
||||
}
|
||||
if (targetFlag) {
|
||||
updatedFields[targetFlag] = 'x';
|
||||
}
|
||||
|
||||
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||
if (kind === cardKind) continue;
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== targetFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||
|
||||
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||
const defaults: Record<string, string> = {
|
||||
Expression: '単語',
|
||||
Sentence: '',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsClickCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
};
|
||||
return {
|
||||
fields: Object.fromEntries(
|
||||
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||
'word-and-sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('honors the configured word card kind', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
'click',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when the word card kind is "none"', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
wordCardKind: 'none',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the default kind for an unrecognized setting', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'bogus' as never,
|
||||
}),
|
||||
'word-and-sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||
const note: NoteFieldValueInfo = {
|
||||
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
null,
|
||||
flagField,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'sentence',
|
||||
}),
|
||||
'sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('overrides a differently flagged word card', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
'click',
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,13 @@
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
CARD_KIND_FLAG_FIELDS,
|
||||
DEFAULT_WORD_CARD_KIND,
|
||||
resolveWordCardKindSetting,
|
||||
} from './card-kinds';
|
||||
|
||||
const log = createLogger('anki').child('integration.note-fields');
|
||||
|
||||
export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||
}
|
||||
|
||||
export function shouldMarkWordAndSentenceCard(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||
): boolean {
|
||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||
return false;
|
||||
}
|
||||
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||
|
||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||
if (wordAndSentenceValue === null) {
|
||||
return false;
|
||||
const warnedMissingFlagFields = new Set<CardKind>();
|
||||
|
||||
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||
return;
|
||||
}
|
||||
if (wordAndSentenceValue.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||
warnedMissingFlagFields.add(wordCardKind);
|
||||
log.warn(
|
||||
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||
*/
|
||||
export function resolveWordCardKind(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
sentenceCardConfig: {
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
wordCardKind?: WordCardKind;
|
||||
},
|
||||
): CardKind | null {
|
||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||
if (wordCardKind === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||
if (flagValue === null) {
|
||||
// Note type has no flag field for the configured kind.
|
||||
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||
return null;
|
||||
}
|
||||
if (flagValue.trim().length > 0) {
|
||||
return wordCardKind;
|
||||
}
|
||||
|
||||
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||
(fieldName) =>
|
||||
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||
);
|
||||
return alreadyExplicitCard ? null : wordCardKind;
|
||||
}
|
||||
|
||||
@@ -6,26 +6,21 @@ import {
|
||||
type NoteUpdateWorkflowNoteInfo,
|
||||
} from './note-update-workflow';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
assert.equal(cardKind, 'word-and-sentence');
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
applyCardKindFlagFields(
|
||||
updatedFields,
|
||||
cardKind,
|
||||
(preferredName) =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkflowHarness() {
|
||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||
false,
|
||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
setCardTypeFields,
|
||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||
if (!preferred) return null;
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
wordCardKind: 'click',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: 'x' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
wordCardKind: 'none',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
setCardTypeFields: (
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
) => void;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||
if (wordCardKind) {
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||
...(config.isKiku ?? {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||
...(config.lapisKiku ?? {}),
|
||||
},
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
||||
patch.isKiku !== undefined
|
||||
? { ...this.config.isKiku, ...patch.isKiku }
|
||||
: this.config.isKiku,
|
||||
lapisKiku:
|
||||
patch.lapisKiku !== undefined
|
||||
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||
: this.config.lapisKiku,
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
|
||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"isKiku": { "enabled": true },
|
||||
"lapisKiku": { "wordCardKind": "click" }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||
assert.equal(service.getWarnings().length, 0);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'word-and-sentence',
|
||||
},
|
||||
},
|
||||
jimaku: {
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||
description: 'Note type name used by Lapis sentence cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||
kind: 'enum',
|
||||
enumValues: WORD_CARD_KINDS,
|
||||
enumLabels: {
|
||||
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||
click: 'Click card (IsClickCard)',
|
||||
sentence: 'Sentence card (IsSentenceCard)',
|
||||
audio: 'Audio card (IsAudioCard)',
|
||||
none: 'Leave card type flags untouched',
|
||||
},
|
||||
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||
description:
|
||||
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.metadata.pattern',
|
||||
kind: 'string',
|
||||
|
||||
@@ -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, and isKiku.fieldGrouping 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, 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.',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResolveContext } from './context';
|
||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||
applyAnkiKikuResolution(context);
|
||||
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||
}
|
||||
|
||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||
: {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...context.resolved.ankiConnect.lapisKiku,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||
import { DEFAULT_CONFIG } from '../../definitions';
|
||||
import type { ResolveContext } from '../context';
|
||||
import { isObject } from '../shared';
|
||||
|
||||
export function applyAnkiLapisKikuResolution(
|
||||
context: ResolveContext,
|
||||
ankiConnect: Record<string, unknown>,
|
||||
): void {
|
||||
if (!isObject(ankiConnect.lapisKiku)) {
|
||||
if (ankiConnect.lapisKiku !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.lapisKiku',
|
||||
ankiConnect.lapisKiku,
|
||||
context.resolved.ankiConnect.lapisKiku,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||
if (wordCardKind === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isWordCardKind(wordCardKind)) {
|
||||
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||
return;
|
||||
}
|
||||
|
||||
context.warn(
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
wordCardKind,
|
||||
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||
);
|
||||
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||
}
|
||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'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':
|
||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||
'ankiConnect.lapisKiku.wordCardKind':
|
||||
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||
'subtitleStyle.css':
|
||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleStyle.secondary.css':
|
||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('ankiConnect.media.')) {
|
||||
return { category: 'mining-anki', section: 'Media Capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
if (
|
||||
path.startsWith('ankiConnect.isKiku.') ||
|
||||
path.startsWith('ankiConnect.isLapis.') ||
|
||||
path.startsWith('ankiConnect.lapisKiku.')
|
||||
) {
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
|
||||
@@ -2454,6 +2454,80 @@ Aligned English subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(
|
||||
async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
addYomitanNote: async () => 777,
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
isKiku: {
|
||||
enabled: true,
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'click',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||
assert.equal(fields.IsClickCard, 'x');
|
||||
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||
assert.equal(fields.IsSentenceCard, '');
|
||||
assert.equal(fields.IsAudioCard, '');
|
||||
},
|
||||
{
|
||||
notesInfoFields: {
|
||||
Expression: { value: '猫' },
|
||||
Sentence: { value: '' },
|
||||
Picture: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
|
||||
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
] as const;
|
||||
|
||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
} from '../secondary-subtitle-sidecar.js';
|
||||
import {
|
||||
applyStatsWordAndSentenceCardFields,
|
||||
applyStatsWordCardFields,
|
||||
createStatsMiningContext,
|
||||
getStatsDirectMiningAudioFieldNames,
|
||||
getStatsWordMiningAudioFieldName,
|
||||
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
|
||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MediaGenerator } from '../../../media-generator.js';
|
||||
import type { AnkiConnectConfig } from '../../../types.js';
|
||||
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
|
||||
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
|
||||
import { createLogger } from '../../../logger.js';
|
||||
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
||||
|
||||
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
|
||||
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||
}
|
||||
|
||||
export function applyStatsWordAndSentenceCardFields(
|
||||
export function applyStatsWordCardFields(
|
||||
fields: Record<string, string>,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
): void {
|
||||
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
||||
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
if (!noteInfo) return;
|
||||
const cardKind = resolveWordCardKind(noteInfo, {
|
||||
lapisEnabled: ankiConfig.isLapis?.enabled === true,
|
||||
kikuEnabled: ankiConfig.isKiku?.enabled === true,
|
||||
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
|
||||
});
|
||||
if (!cardKind) return;
|
||||
|
||||
fields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
|
||||
}
|
||||
applyCardKindFlagFields(fields, cardKind, (preferredName) =>
|
||||
resolveStatsNoteFieldName(noteInfo, preferredName),
|
||||
);
|
||||
}
|
||||
|
||||
export function getStatsDirectMiningAudioFieldNames(
|
||||
|
||||
@@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||
config.ankiConnect.lapisKiku.wordCardKind = 'click';
|
||||
config.logging.level = 'debug';
|
||||
config.logging.rotation = 14;
|
||||
config.logging.files.mpv = true;
|
||||
@@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
'ankiConnect.fields.miscInfo',
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
'ankiConnect.isKiku.fieldGrouping',
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
'logging.level',
|
||||
'logging.rotation',
|
||||
'logging.files.mpv',
|
||||
@@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
||||
},
|
||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||
isKiku: { fieldGrouping: 'manual' },
|
||||
lapisKiku: { wordCardKind: 'click' },
|
||||
},
|
||||
]);
|
||||
assert.ok(calls.includes('invalidate:tokens'));
|
||||
|
||||
@@ -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.lapisKiku.wordCardKind')) {
|
||||
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
|
||||
}
|
||||
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { AiFeatureConfig } from './integrations';
|
||||
import type { NotificationType } from './notification';
|
||||
import type { NPlusOneMatchMode } from './subtitle';
|
||||
|
||||
/**
|
||||
* Card that a Kiku/Lapis note generates. The note types mark this with mutually
|
||||
* exclusive `Is...Card` flag fields, so only one kind may be flagged per note.
|
||||
*/
|
||||
export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||
|
||||
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||
export type WordCardKind = CardKind | 'none';
|
||||
|
||||
export interface NotificationOptions {
|
||||
body?: string;
|
||||
icon?: string;
|
||||
@@ -115,4 +124,7 @@ export interface AnkiConnectConfig {
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
lapisKiku?: {
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
import type { AnkiConnectConfig } from './anki';
|
||||
import type { AnkiConnectConfig, WordCardKind } from './anki';
|
||||
import type {
|
||||
AiConfig,
|
||||
AiFeatureConfig,
|
||||
@@ -285,6 +285,9 @@ export interface ResolvedConfig {
|
||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
lapisKiku: {
|
||||
wordCardKind: WordCardKind;
|
||||
};
|
||||
};
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
|
||||
Reference in New Issue
Block a user