- TaskSchedule.OccurrencesBetween zaehlt, wie oft eine Aufgabe bis zu einem Zeitpunkt noch laeuft, einschliesslich eines jetzt faelligen Laufs. - BudgetHeadroom (ILateRunGate): Ein mitgenommener oder nachzuholender Lauf startet nur, wenn er in den Spielraum passt: Grenze minus Verbrauch minus Reservierung minus Bedarf der restlichen geplanten Laeufe bis Mitternacht minus 10 % Reserve. Das gilt je Agent und je Instanz, fuer Kosten und Tokens. Anderes Spaetwerk zaehlt nicht als Bedarf, sonst blockierten sich mitgenommene Aufgaben gegenseitig. - Der Scanner fragt den Spielraum nur fuer Spaetwerk und protokolliert, wenn es warten muss. Die Engine gibt ihren BudgetGuard heraus, damit Scanner und Engine dieselben Reservierungen sehen. - Ersetzt den pauschalen 25-%-Anteil aus dem Konzept (dort nachgezogen): Ein fester Anteil ist an vollen Tagen zu viel, an ruhigen zu wenig. Drei weitere stille Kappungen bei 500 beseitigt (ListAllAsync, ungekappt): Kostenprognose (Aufgaben ueber 500 fehlten), Waisenabgleich beim Import und task_find aus #674, wo Dubletten unter aelteren Aufgaben mit niedriger Prioritaet unsichtbar wurden. Tests: BudgetHeadroomTests (9), Scanner-Pforte (1), Dublette hinter 500 Aufgaben (1). 1.257 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
436 lines
19 KiB
C#
436 lines
19 KiB
C#
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> ReleaseClaimAsync(
|
|
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct)
|
|
=> _storage.WriteAsync(async conn =>
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
UPDATE Tasks
|
|
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
|
|
LastOccurrence = @occ, UpdatedAt = @now
|
|
WHERE Id = @id AND ClaimToken = @token
|
|
""";
|
|
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
|
|
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<bool> CarryOverAsync(
|
|
string id, string claimToken, string? previousOccurrence, DateTime deferredUntil,
|
|
DateTime now, CancellationToken ct)
|
|
=> _storage.WriteAsync(async conn =>
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
UPDATE Tasks
|
|
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
|
|
LastOccurrence = @occ, UpdatedAt = @now,
|
|
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
|
|
DeferredUntil = @deferred
|
|
WHERE Id = @id AND ClaimToken = @token
|
|
""";
|
|
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@now", Format(now));
|
|
cmd.Parameters.AddWithValue("@deferred", Format(deferredUntil));
|
|
cmd.Parameters.AddWithValue("@id", id);
|
|
cmd.Parameters.AddWithValue("@token", claimToken);
|
|
|
|
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
|
}, ct);
|
|
|
|
public Task<bool> RecordBudgetMissAsync(
|
|
string id, string claimToken, DateTime? catchUpUntil, DateTime now, CancellationToken ct)
|
|
=> _storage.WriteAsync(async conn =>
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
// A pending catch-up keeps its date: several misses still mean one catch-up run.
|
|
cmd.CommandText = """
|
|
UPDATE Tasks
|
|
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now,
|
|
BudgetMissCount = BudgetMissCount + 1, LastBudgetMissAt = @now,
|
|
CatchUpPending = CASE WHEN @catchUp = 1 THEN 1 ELSE CatchUpPending END,
|
|
DeferredUntil = CASE WHEN @catchUp = 1 AND CatchUpPending = 0 THEN @deferred
|
|
ELSE DeferredUntil END
|
|
WHERE Id = @id AND ClaimToken = @token
|
|
""";
|
|
cmd.Parameters.AddWithValue("@now", Format(now));
|
|
cmd.Parameters.AddWithValue("@catchUp", catchUpUntil is null ? 0 : 1);
|
|
cmd.Parameters.AddWithValue("@deferred", catchUpUntil is { } until ? Format(until) : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@id", id);
|
|
cmd.Parameters.AddWithValue("@token", claimToken);
|
|
|
|
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
|
}, ct);
|
|
|
|
public Task<bool> ClearBudgetMissAsync(string id, DateTime now, CancellationToken ct)
|
|
=> _storage.WriteAsync(async conn =>
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = """
|
|
UPDATE Tasks
|
|
SET BudgetMissCount = 0, CatchUpPending = 0, DeferredUntil = NULL, UpdatedAt = @now
|
|
WHERE Id = @id
|
|
""";
|
|
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>> ListAllAsync(bool includeArchived, CancellationToken ct)
|
|
{
|
|
await using var conn = await _storage.OpenConnectionAsync(ct);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT " + Columns + " FROM Tasks"
|
|
+ (includeArchived ? "" : " WHERE Status <> 'archived'")
|
|
+ " ORDER BY Priority DESC, UpdatedAt DESC";
|
|
|
|
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<IReadOnlyList<TaskItem>> ListRunnableAsync(CancellationToken ct)
|
|
{
|
|
await using var conn = await _storage.OpenConnectionAsync(ct);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Status = @status";
|
|
cmd.Parameters.AddWithValue("@status", TaskText.Of(TaskItemStatus.Todo));
|
|
|
|
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<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, "
|
|
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
|
|
|
|
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)),
|
|
BudgetMissCount = r.GetInt32(22),
|
|
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
|
|
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
|
|
CatchUpPending = r.GetInt32(25) != 0
|
|
};
|
|
}
|
|
|
|
/// <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;
|
|
}
|