- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
prueft wie das Tool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
12 KiB
C#
320 lines
12 KiB
C#
using System.Text;
|
||
|
||
namespace ClawdDotNet.Core.Tasks;
|
||
|
||
/// <summary>
|
||
/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
|
||
/// <c>---</c>-Zeilen, gefolgt vom Rumpf.
|
||
///
|
||
/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
|
||
/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten <c>when</c>, 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.
|
||
/// </summary>
|
||
public static class TaskFrontmatter
|
||
{
|
||
/// <summary>
|
||
/// Zerlegt eine Task-Datei in ihre Definition. Gibt <c>false</c> 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.
|
||
/// </summary>
|
||
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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
var when = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
var blockedBy = new List<string>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
|
||
/// bleibt außen vor — er gehört in die DB, nicht in die Datei.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Ruft <paramref name="onChild"/> für jede eingerückte Folgezeile auf und
|
||
/// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.</summary>
|
||
private static int CollectChildren(string[] block, int start, Action<string> 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 ">-";
|
||
|
||
/// <summary>Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
|
||
/// Einrückung. Setzt <paramref name="j"/> hinter den Block.</summary>
|
||
private static string ReadBlockScalar(string[] block, ref int j)
|
||
{
|
||
var collected = new List<string>();
|
||
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<string> 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;
|
||
}
|
||
|
||
/// <summary>Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
|
||
/// <c>@</c> (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
|
||
/// werden.</summary>
|
||
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();
|
||
}
|