feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Ausführungszustand des Taskboards in der Instanz-Datenbank.
|
||||
///
|
||||
/// Der Kern ist <see cref="TryClaimAsync"/>: ein bedingtes <c>UPDATE</c>, das genau eine
|
||||
/// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau
|
||||
/// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht
|
||||
/// bei den Task-Dateien.
|
||||
///
|
||||
/// Zeitangaben werden als sortierbares ISO-UTC (<c>"O"</c>) abgelegt, damit
|
||||
/// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst.
|
||||
/// </summary>
|
||||
public sealed class SqliteTaskRepository : ITaskRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteTaskRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task<TaskItem> UpsertAsync(TaskItem task, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await ReadAsync(conn, task.Id, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
using var insert = conn.CreateCommand();
|
||||
insert.CommandText = """
|
||||
INSERT INTO Tasks
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
|
||||
""";
|
||||
BindDefinition(insert, task);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
insert.Parameters.AddWithValue("@createdAt", Format(now));
|
||||
insert.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await insert.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nur die Definition aktualisieren. Status und Ausführungszustand
|
||||
// (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden
|
||||
// oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen
|
||||
// laufen über SetStatusAsync bzw. den Scanner.
|
||||
using var update = conn.CreateCommand();
|
||||
update.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee,
|
||||
WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz,
|
||||
RequireApproval = @requireApproval, Acceptance = @acceptance,
|
||||
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
|
||||
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
BindDefinition(update, task);
|
||||
update.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await update.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
return (await ReadAsync(conn, task.Id, ct))!;
|
||||
}, ct);
|
||||
|
||||
public async Task<TaskItem?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
return await ReadAsync(conn, id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1");
|
||||
|
||||
if (query.Status is { } status)
|
||||
{
|
||||
sql.Append(" AND Status = @status");
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
}
|
||||
else if (!query.IncludeArchived)
|
||||
{
|
||||
sql.Append(" AND Status <> 'archived'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Assignee))
|
||||
{
|
||||
sql.Append(" AND Assignee = @assignee COLLATE NOCASE");
|
||||
cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE"
|
||||
+ " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)");
|
||||
cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%");
|
||||
}
|
||||
|
||||
// Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält.
|
||||
sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit");
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500));
|
||||
cmd.CommandText = sql.ToString();
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<bool> TryClaimAsync(
|
||||
string id, string occurrenceKey, string claimToken,
|
||||
DateTime now, DateTime leaseCutoff, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress',
|
||||
LastOccurrence = @occ, UpdatedAt = @now
|
||||
WHERE Id = @id
|
||||
AND Status = 'todo'
|
||||
AND (LastOccurrence IS NULL OR LastOccurrence < @occ)
|
||||
AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@occ", occurrenceKey);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) == 1;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> CompleteClaimAsync(
|
||||
string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Id = @id AND ClaimToken = @token
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns
|
||||
+ " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'";
|
||||
cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%");
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Tasks";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", task.Id);
|
||||
cmd.Parameters.AddWithValue("@title", task.Title);
|
||||
cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type));
|
||||
cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5));
|
||||
cmd.Parameters.AddWithValue("@assignee", task.Assignee);
|
||||
cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@acceptance", task.Acceptance);
|
||||
cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy));
|
||||
cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@body", task.Body);
|
||||
cmd.Parameters.AddWithValue("@fileName", task.FileName);
|
||||
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
|
||||
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
|
||||
}
|
||||
|
||||
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? Read(reader) : null;
|
||||
}
|
||||
|
||||
private static TaskItem Read(SqliteDataReader r)
|
||||
{
|
||||
TaskWhen? when = null;
|
||||
if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind)
|
||||
{
|
||||
when = new TaskWhen
|
||||
{
|
||||
Kind = kind,
|
||||
Value = r.IsDBNull(7) ? "" : r.GetString(7),
|
||||
TimeZone = r.IsDBNull(8) ? "" : r.GetString(8)
|
||||
};
|
||||
}
|
||||
|
||||
return new TaskItem
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
Title = r.GetString(1),
|
||||
Status = TaskText.Status(r.GetString(2)),
|
||||
Type = TaskText.Type(r.GetString(3)),
|
||||
Priority = r.GetInt32(4),
|
||||
Assignee = r.GetString(5),
|
||||
When = when,
|
||||
RequireApproval = r.GetInt32(9) != 0,
|
||||
Acceptance = r.GetString(10),
|
||||
BlockedBy = DeserializeBlockedBy(r.GetString(11)),
|
||||
OnlyWhenMarketOpen = r.GetInt32(12) != 0,
|
||||
Body = r.GetString(13),
|
||||
FileName = r.GetString(14),
|
||||
ToolName = r.GetString(15),
|
||||
JobTypeId = r.GetString(16),
|
||||
LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17),
|
||||
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
|
||||
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
|
||||
CreatedAt = Parse(r.GetString(20)),
|
||||
UpdatedAt = Parse(r.GetString(21))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids,
|
||||
/// ohne dass <c>t-1</c> auch <c>t-10</c> trifft.</summary>
|
||||
private static string SerializeBlockedBy(IReadOnlyList<string> ids)
|
||||
{
|
||||
var cleaned = ids
|
||||
.Select(x => x.Trim().Replace("|", ""))
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeBlockedBy(string raw)
|
||||
=> raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace(@"\", @"\\")
|
||||
.Replace("%", @"\%")
|
||||
.Replace("_", @"\_");
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
Reference in New Issue
Block a user