using System.Text.RegularExpressions; namespace QuestionsApi.Services.Flashcards; public sealed record ClozeDeletion(int Number, string Answer, string? Hint); // {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1; // each distinct number becomes its own independently scheduled card (a "sibling"). public static partial class FlashcardClozeParser { [GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)] private static partial Regex ClozeRegex(); public static List Parse(string text) { if (string.IsNullOrEmpty(text)) return []; var results = new List(); foreach (Match match in ClozeRegex().Matches(text)) { if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1) continue; var inner = match.Groups[2].Value; var parts = inner.Split("::", 2); var answer = parts[0].Trim(); var hint = parts.Length > 1 ? parts[1].Trim() : null; if (answer.Length == 0) continue; results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint)); } return results; } public static List DistinctClozeNumbers(string text) => Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList(); public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0; // Renders the front for a given cloze number: that number's answer is hidden (replaced // by its hint or "..."), all other cloze numbers are revealed as their answer text. public static string RenderFront(string text, int clozeNumber) => ClozeRegex().Replace(text, match => { if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value; var inner = match.Groups[2].Value; var parts = inner.Split("::", 2); if (number != clozeNumber) return parts[0].Trim(); var hint = parts.Length > 1 ? parts[1].Trim() : ""; return hint.Length > 0 ? $"[{hint}]" : "[...]"; }); public static string RenderBack(string text, int clozeNumber) => ClozeRegex().Replace(text, match => { var inner = match.Groups[2].Value; var parts = inner.Split("::", 2); return parts[0].Trim(); }); }