;
@@ -48,6 +49,7 @@ export function validateFieldsForType(
'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] ?? [];
diff --git a/packages/cards-domain/src/typing.ts b/packages/cards-domain/src/typing.ts
new file mode 100644
index 0000000..bcfcb24
--- /dev/null
+++ b/packages/cards-domain/src/typing.ts
@@ -0,0 +1,49 @@
+export type TypingMatchResult = 'correct' | 'close' | 'wrong';
+
+/**
+ * Vergleicht eine getippte Antwort mit der erwarteten Antwort.
+ * Normalisiert: lowercase, trim, NFD-Diakritika-Stripping.
+ * "close" = Levenshtein-Distanz ≤ max(1, floor(answer.length * 0.2)).
+ */
+export function checkTypingAnswer(
+ input: string,
+ answer: string,
+ aliases?: string,
+): TypingMatchResult {
+ const norm = (s: string) =>
+ s
+ .trim()
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/\p{Mn}/gu, '');
+
+ const normInput = norm(input);
+ const candidates = [answer, ...(aliases ? aliases.split(',') : [])]
+ .map(norm)
+ .filter((s) => s.length > 0);
+
+ if (candidates.some((c) => c === normInput)) return 'correct';
+
+ const shortestLen = Math.min(...candidates.map((c) => c.length));
+ const threshold = Math.max(1, Math.floor(shortestLen * 0.2));
+ if (candidates.some((c) => levenshtein(normInput, c) <= threshold)) return 'close';
+
+ return 'wrong';
+}
+
+function levenshtein(a: string, b: string): number {
+ const m = a.length;
+ const n = b.length;
+ const row = Array.from({ length: n + 1 }, (_, j) => j);
+ for (let i = 1; i <= m; i++) {
+ let prev = row[0];
+ row[0] = i;
+ for (let j = 1; j <= n; j++) {
+ const tmp = row[j];
+ row[j] =
+ a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, row[j], row[j - 1]);
+ prev = tmp;
+ }
+ }
+ return row[n];
+}