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>
78 lines
2.9 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|