cards-native/Sources/Core/Domain/Cloze.swift
Till JS 3b861af3fb v0.3.0 — Phase β-2 Study-Loop
Voller Lern-Flow mit Web-Parität: fällige Karten via /reviews/due
laden, flip + rate (4 Buttons + Haptic), Grades via Offline-Queue
ans Server-FSRS schicken.

- Card/Review/DueReview DTOs mit snake_case + camelCase-deckId-
  Sonderfall im embedded card-Subobjekt
- CardType-Enum (alle 7 Typen), Rating-Enum mit deutschen Labels
- Cloze-Helper 1:1-Port aus cards-domain (extractClusterIds,
  subIndexCount, clusterId, renderPrompt/Answer, hint)
- CardsAPI.dueReviews(deckId:) + gradeReview(cardId,subIndex,rating,reviewedAt)
- PendingGrade SwiftData-Model + GradeQueue (FIFO-Drain, originaler
  Timestamp bleibt, bei Netzfehler in Queue, Retry beim nächsten Drain)
- StudySession @Observable State-Machine
- CardRenderer für basic, basic-reverse, cloze; Placeholder für
  image-occlusion/audio-front/typing/multiple-choice (β-3/β-4)
- RatingBar mit UIImpactFeedbackGenerator (medium/heavy)
- StudySessionView per NavigationLink aus DeckListView
- 9 neue Tests (Cloze: 8, Review-Decoding: 3), insgesamt 17 grün

Server-authoritative FSRS bleibt — kein ts-fsrs-Port.
Endurance-Test auf realem Gerät steht aus (siehe PLAN.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:16:11 +02:00

78 lines
2.9 KiB
Swift

import Foundation
/// Cloze-Helpers. 1:1-Port der Funktionen aus
/// `cards/packages/cards-domain/src/cloze.ts`.
///
/// **Web-Parität-Hinweis:** Web rendert Cloze client-side. Native macht
/// dasselbe, weil Server keinen Render-Endpoint dafür hat. Pure-String-
/// Manipulation, kein FSRS Mocking gegen die TS-Implementierung via
/// Fixture-Tests.
///
/// Markup: `{{cN::answer}}` oder `{{cN::answer::hint}}`. N ist
/// 1-basierte Cluster-ID. Mehrere Cluster pro Karte mehrere
/// Sub-Index-Reviews.
enum Cloze {
/// Pattern für `{{cN::answer(::hint)?}}`. Pro Call konstruiert,
/// weil `Regex` unter Strict-Concurrency nicht Sendable ist.
private static var clusterPattern: Regex<(Substring, Substring, Substring, Substring?)> {
#/\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/#
}
/// Distinct Cluster-IDs, sortiert.
static func extractClusterIds(_ text: String) -> [Int] {
var ids = Set<Int>()
for match in text.matches(of: clusterPattern) {
if let n = Int(match.output.1), n >= 1 {
ids.insert(n)
}
}
return ids.sorted()
}
/// Hint für einen Cluster (erstes Vorkommen gewinnt).
static func hint(for text: String, clusterId: Int) -> String? {
for match in text.matches(of: clusterPattern) {
if let n = Int(match.output.1), n == clusterId, let hint = match.output.3 {
return String(hint)
}
}
return nil
}
/// Anzahl distinct Cluster entspricht Sub-Index-Count.
static func subIndexCount(_ text: String) -> Int {
extractClusterIds(text).count
}
/// Mapping Sub-Index Cluster-ID.
static func clusterId(for text: String, subIndex: Int) -> Int? {
let ids = extractClusterIds(text)
guard ids.indices.contains(subIndex) else { return nil }
return ids[subIndex]
}
/// Prompt-Render: aktiver Cluster wird zu `[]` (oder `[hint]`),
/// alle anderen werden auf ihre Antwort expandiert.
static func renderPrompt(_ text: String, activeClusterId: Int) -> String {
text.replacing(clusterPattern) { match in
guard let n = Int(match.output.1) else { return String(match.output.0) }
if n == activeClusterId {
if let hint = match.output.3 {
return "[\(hint)]"
}
return "[…]"
}
return String(match.output.2)
}
}
/// Antwort-Render: alle Cluster expandiert. Aktiver Cluster wird
/// als Markdown-Bold markiert.
static func renderAnswer(_ text: String, activeClusterId: Int) -> String {
text.replacing(clusterPattern) { match in
guard let n = Int(match.output.1) else { return String(match.output.0) }
let answer = String(match.output.2)
return n == activeClusterId ? "**\(answer)**" : answer
}
}
}