using System.Text; namespace ClawdDotNet.Core.Tasks; /// /// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen /// ----Zeilen, gefolgt vom Rumpf. /// /// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist /// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten when, einer /// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt /// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die /// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten. /// public static class TaskFrontmatter { /// /// Zerlegt eine Task-Datei in ihre Definition. Gibt false mit einer Meldung /// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand /// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB. /// public static bool TryParse(string text, out TaskItem task, out string? error) { task = new TaskItem(); error = null; var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n"); // Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren). var lines = normalized.Split('\n'); var open = -1; for (var i = 0; i < lines.Length; i++) { var t = lines[i].TrimStart('').Trim(); if (t.Length == 0) continue; if (t == "---") { open = i; break; } break; // erste nicht-leere Zeile ist kein Fence } if (open < 0) { error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile)."; return false; } var close = -1; for (var i = open + 1; i < lines.Length; i++) { if (lines[i].Trim() == "---") { close = i; break; } } if (close < 0) { error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt)."; return false; } var block = lines[(open + 1)..close]; var body = string.Join('\n', lines[(close + 1)..]).Trim('\n'); var scalars = new Dictionary(StringComparer.OrdinalIgnoreCase); var when = new Dictionary(StringComparer.OrdinalIgnoreCase); var blockedBy = new List(); string? acceptance = null; var j = 0; while (j < block.Length) { var raw = block[j]; var trimmed = raw.Trim(); if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; } // Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen // gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen. if (Indent(raw) > 0) { j++; continue; } var colon = trimmed.IndexOf(':'); if (colon < 0) { j++; continue; } var key = trimmed[..colon].Trim(); var rest = trimmed[(colon + 1)..].Trim(); if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0) { j = CollectChildren(block, j + 1, child => { var c = child.IndexOf(':'); if (c < 0) return; when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim()); }); continue; } if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest)) { acceptance = ReadBlockScalar(block, ref j); continue; } if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase)) { if (rest.StartsWith('[')) { blockedBy.AddRange(ParseInlineList(rest)); j++; } else if (rest.Length == 0) { // Block-Liste: nachfolgende "- item"-Zeilen. j = CollectChildren(block, j + 1, child => { var item = child.TrimStart(); if (item.StartsWith('-')) blockedBy.Add(Unquote(item[1..].Trim())); }); } else { blockedBy.AddRange(ParseInlineList(rest)); j++; } continue; } scalars[key] = Unquote(rest); j++; } TaskWhen? whenValue = null; if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind) { whenValue = new TaskWhen { Kind = kind, Value = when.GetValueOrDefault("value", ""), TimeZone = when.GetValueOrDefault("tz", "") }; } task = new TaskItem { Id = scalars.GetValueOrDefault("id", ""), Title = scalars.GetValueOrDefault("title", ""), Status = TaskText.Status(scalars.GetValueOrDefault("status")), Type = TaskText.Type(scalars.GetValueOrDefault("type")), Priority = ParsePriority(scalars.GetValueOrDefault("priority")), Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human), When = whenValue, RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")), Acceptance = acceptance?.Trim() ?? "", BlockedBy = blockedBy, OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")), ToolName = scalars.GetValueOrDefault("tool_name", ""), JobTypeId = scalars.GetValueOrDefault("job_type", ""), CreatedBy = scalars.GetValueOrDefault("created_by", ""), ParentId = scalars.GetValueOrDefault("parent", ""), Body = body }; return true; } /// Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand /// bleibt außen vor — er gehört in die DB, nicht in die Datei. public static string Serialize(TaskItem task) { var sb = new StringBuilder(); sb.Append("---\n"); sb.Append($"id: {Scalar(task.Id)}\n"); sb.Append($"title: {Scalar(task.Title)}\n"); sb.Append($"status: {TaskText.Of(task.Status)}\n"); sb.Append($"type: {TaskText.Of(task.Type)}\n"); sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n"); sb.Append($"assignee: {Scalar(task.Assignee)}\n"); if (!string.IsNullOrWhiteSpace(task.CreatedBy)) sb.Append($"created_by: {Scalar(task.CreatedBy)}\n"); if (!string.IsNullOrWhiteSpace(task.ParentId)) sb.Append($"parent: {Scalar(task.ParentId)}\n"); if (task.When is { } w) { sb.Append("when:\n"); sb.Append($" kind: {TaskText.Of(w.Kind)}\n"); sb.Append($" value: {Scalar(w.Value)}\n"); sb.Append($" tz: {Scalar(w.TimeZone)}\n"); } // Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird. if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName)) { sb.Append($"tool_name: {Scalar(task.ToolName)}\n"); sb.Append($"job_type: {Scalar(task.JobTypeId)}\n"); } sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n"); if (!string.IsNullOrWhiteSpace(task.Acceptance)) { sb.Append("acceptance: |\n"); foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n')) sb.Append(" ").Append(line).Append('\n'); } if (task.BlockedBy.Count > 0) sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n"); sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n"); sb.Append("---\n"); if (!string.IsNullOrWhiteSpace(task.Body)) sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n'); return sb.ToString(); } // ─── Hilfsfunktionen ─── private static int Indent(string line) { var n = 0; while (n < line.Length && line[n] == ' ') n++; return n; } /// Ruft für jede eingerückte Folgezeile auf und /// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück. private static int CollectChildren(string[] block, int start, Action onChild) { var k = start; while (k < block.Length) { var line = block[k]; if (line.Trim().Length == 0) { k++; continue; } if (Indent(line) == 0) break; onChild(line.Trim()); k++; } return k; } private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-"; /// Liest einen eingerückten Block-Skalar und entfernt die gemeinsame /// Einrückung. Setzt hinter den Block. private static string ReadBlockScalar(string[] block, ref int j) { var collected = new List(); var k = j + 1; var commonIndent = int.MaxValue; while (k < block.Length) { var line = block[k]; if (line.Trim().Length == 0) { collected.Add(""); k++; continue; } if (Indent(line) == 0) break; commonIndent = Math.Min(commonIndent, Indent(line)); collected.Add(line); k++; } j = k; if (commonIndent == int.MaxValue) commonIndent = 0; var sb = new StringBuilder(); foreach (var line in collected) sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n'); return sb.ToString().TrimEnd('\n'); } private static IEnumerable ParseInlineList(string rest) { var inner = rest.Trim(); if (inner.StartsWith('[')) inner = inner[1..]; if (inner.EndsWith(']')) inner = inner[..^1]; return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(Unquote) .Where(s => s.Length > 0); } private static string Unquote(string value) { var v = value.Trim(); if (v.Length >= 2 && v[0] == '"' && v[^1] == '"') return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\"); if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'') return v[1..^1].Replace("''", "'"); return v; } /// Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes /// @ (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert /// werden. private static string Scalar(string? value) { var v = value ?? ""; if (v.Length == 0) return "\"\""; var needsQuote = v != v.Trim() || "@-[]{}>|*&!%#`,\"'".Contains(v[0]) || v.Contains(':') || v.Contains('#') || v.Contains('\n'); if (!needsQuote) return v; return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } private static bool ParseBool(string? value) => value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on"; private static int ParsePriority(string? value) => int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3; private static string EmptyToDefault(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); }