cards/packages/cards-domain/src/schemas/card.ts
Till JS 0791436107 feat(cards): typing Card-Type mit Fuzzy-Match
- 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>
2026-05-10 15:23:58 +02:00

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>;