- typing.ts: checkTypingAnswer (exact / close / wrong) + Levenshtein- Impl; close = Distanz ≤ max(1, floor(len * 0.2)); Alias-Support via komma-separiertes aliases-Feld - CardTypeSchema: 'typing' ergänzt; validateFieldsForType: front+answer required - subIndexCount: 'typing' → 1 - TypingView.svelte: Input-Feld + Submit + Result-Badge + Antwort-Markdown + kontext-spezifische Grade-Buttons (correct: Weiter; close: Nochmal/War richtig; wrong: volle 4 Buttons); svelte:window für Keyboard - Study-Page: TypingView eingebunden, Action-Bar bei typing ausgeblendet, onKey delegiert zu TypingView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
103 lines
2.9 KiB
TypeScript
103 lines
2.9 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt,
|
|
* image-occlusion in Phase 9l. Weitere Erweiterung (type-in, audio,
|
|
* multiple-choice) bleibt im CardTypeFutureSchema vorbereitet.
|
|
*/
|
|
export const CardTypeSchema = z.enum([
|
|
'basic',
|
|
'basic-reverse',
|
|
'cloze',
|
|
'image-occlusion',
|
|
'audio-front',
|
|
'typing',
|
|
]);
|
|
export type CardType = z.infer<typeof CardTypeSchema>;
|
|
|
|
/** Future-Set für Schema-Migration-Vorbereitung. */
|
|
export const CardTypeFutureSchema = z.enum([
|
|
'basic',
|
|
'basic-reverse',
|
|
'cloze',
|
|
'type-in',
|
|
'image-occlusion',
|
|
'audio-front',
|
|
'multiple-choice',
|
|
]);
|
|
|
|
/**
|
|
* Generischer Field-Slot. Konkrete Field-Sets pro Type werden runtime
|
|
* via `validateFieldsForType()` geprüft (siehe unten).
|
|
*/
|
|
export const CardFieldsSchema = z.record(z.string(), z.string());
|
|
export type CardFields = z.infer<typeof CardFieldsSchema>;
|
|
|
|
/**
|
|
* Field-Set-Validierung pro CardType. Keine z.discriminatedUnion, weil
|
|
* der Type-Slot generisch bleibt — neue CardTypes können ohne Schema-
|
|
* Bruch hinzugefügt werden.
|
|
*/
|
|
export function validateFieldsForType(
|
|
type: CardType | z.infer<typeof CardTypeFutureSchema>,
|
|
fields: CardFields
|
|
): { ok: true } | { ok: false; missing: string[] } {
|
|
const required: Record<string, string[]> = {
|
|
basic: ['front', 'back'],
|
|
'basic-reverse': ['front', 'back'],
|
|
cloze: ['text'],
|
|
'type-in': ['question', 'expected'],
|
|
'image-occlusion': ['image_ref', 'mask_regions'],
|
|
'audio-front': ['audio_ref', 'back'],
|
|
typing: ['front', 'answer'],
|
|
'multiple-choice': ['question', 'options', 'correct_index'],
|
|
};
|
|
const need = required[type] ?? [];
|
|
const missing = need.filter((k) => !(k in fields));
|
|
return missing.length === 0 ? { ok: true } : { ok: false, missing };
|
|
}
|
|
|
|
export const CardSchema = z
|
|
.object({
|
|
id: z.string().min(1),
|
|
deck_id: z.string().min(1),
|
|
user_id: z.string().min(1),
|
|
type: CardTypeSchema,
|
|
fields: CardFieldsSchema,
|
|
media_refs: z.array(z.string()).default([]),
|
|
content_hash: z.string().optional().nullable(),
|
|
created_at: z.string().datetime(),
|
|
updated_at: z.string().datetime(),
|
|
})
|
|
.strict();
|
|
export type Card = z.infer<typeof CardSchema>;
|
|
|
|
export const CardCreateSchema = z
|
|
.object({
|
|
deck_id: z.string().min(1),
|
|
type: CardTypeSchema,
|
|
fields: CardFieldsSchema,
|
|
tags: z.array(z.string()).optional(),
|
|
media_refs: z.array(z.string()).optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((val, ctx) => {
|
|
const check = validateFieldsForType(val.type, val.fields);
|
|
if (!check.ok) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['fields'],
|
|
message: `missing fields for type=${val.type}: ${check.missing.join(', ')}`,
|
|
});
|
|
}
|
|
});
|
|
export type CardCreate = z.infer<typeof CardCreateSchema>;
|
|
|
|
export const CardUpdateSchema = z
|
|
.object({
|
|
fields: CardFieldsSchema.optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
media_refs: z.array(z.string()).optional(),
|
|
})
|
|
.strict();
|
|
export type CardUpdate = z.infer<typeof CardUpdateSchema>;
|