feat(google-calendar): create, update and delete with recurring scope resolution

Spec §7: the reference's calendar.ts is not in this repository, so the error and reply texts are now listed in the spec.
This commit is contained in:
EugeneTes
2026-09-26 20:11:47 +02:00
parent edcfa690bd
commit c2048a822e
3 changed files with 359 additions and 7 deletions

View File

@@ -206,7 +206,117 @@ internal sealed partial class CalendarTool(GoogleCalendarSettings settings, Goog
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
/// <summary>create, update, delete: Task 6.</summary>
private static Task<ToolResult> WriteAsync(string action, Run run) =>
throw new ArgumentException($"{action} is not available yet.");
internal enum EventKind { Single, Instance, Series }
private static readonly string[] RecurrencePrefixes = ["RRULE:", "EXDATE:", "RDATE:", "EXRULE:"];
private static Task<ToolResult> WriteAsync(string action, Run run) => action switch
{
"create" => CreateAsync(run),
"update" => UpdateAsync(run),
_ => DeleteAsync(run),
};
private static async Task<ToolResult> CreateAsync(Run run)
{
var a = run.Args;
var summary = Require(a.Summary, "create needs summary.");
var startText = Require(a.Start, "create needs start.");
if (a.Recurrence is not null) ValidateRecurrence(a.Recurrence);
var (start, end) = CalendarTime.ForCreate(startText, a.End, run.EventZoneName);
var body = new JsonObject { ["summary"] = summary, ["start"] = start, ["end"] = end };
if (a.Description is not null) body["description"] = a.Description;
if (a.Location is not null) body["location"] = a.Location;
if (a.Recurrence is { Count: > 0 }) body["recurrence"] = RecurrenceJson(a.Recurrence);
var created = await run.Api.SendAsync(HttpMethod.Post, GoogleCalendarApi.Events(run.CalendarId), body, new CalendarCall("create", run.CalendarId), run.Ct).ConfigureAwait(false);
return Result("create", $"{run.Header}\nCreated: {Line(created, run.Zone)}", [created]);
}
private static async Task<ToolResult> UpdateAsync(Run run)
{
var a = run.Args;
var id = Require(a.EventId, "update needs eventId.");
var call = new CalendarCall("update", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, a.Scope, id);
var patch = new JsonObject();
if (a.Summary is not null) patch["summary"] = a.Summary;
if (a.Description is not null) patch["description"] = a.Description;
if (a.Location is not null) patch["location"] = a.Location;
var (start, end) = CalendarTime.ForUpdate(a.Start, a.End, run.EventZoneName);
if (start is not null) patch["start"] = start;
if (end is not null) patch["end"] = end;
if (a.Recurrence is not null)
{
if (kind == EventKind.Instance)
throw new ArgumentException($"recurrence can't be set on a single occurrence; update the series (eventId={Str(ev, "recurringEventId")}) instead.");
ValidateRecurrence(a.Recurrence);
patch["recurrence"] = RecurrenceJson(a.Recurrence);
}
if (patch.Count == 0)
throw new ArgumentException("update needs at least one field to change (summary, description, location, start, end or recurrence).");
var updated = await run.Api.SendAsync(HttpMethod.Patch, path, patch, call, run.Ct).ConfigureAwait(false);
var what = kind switch
{
EventKind.Instance => "Updated this occurrence only",
EventKind.Series => "Updated the whole series",
_ => "Updated",
};
return Result("update", $"{run.Header}\n{what}: {Line(updated, run.Zone)}", [updated]);
}
private static async Task<ToolResult> DeleteAsync(Run run)
{
var id = Require(run.Args.EventId, "delete needs eventId.");
var call = new CalendarCall("delete", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, run.Args.Scope, id);
await run.Api.SendAsync(HttpMethod.Delete, path, null, call, run.Ct).ConfigureAwait(false);
var summary = Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)";
var range = ev.TryGetProperty("start", out var start) ? CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, run.Zone) : "";
var text = kind switch
{
EventKind.Instance => $"Cancelled one occurrence of \"{summary}\" ({range}); the rest of the series is unchanged.",
EventKind.Series => $"Deleted the recurring series \"{summary}\" and all its occurrences.",
_ => $"Deleted \"{summary}\" ({range}).",
};
return Result("delete", $"{run.Header}\n{text}", [ev]);
}
/// <summary><c>recurringEventId</c> → instance; <c>recurrence</c> → series; neither → single (explicit scope ignored).</summary>
internal static EventKind ResolveScope(JsonElement ev, string? requested, string eventId)
{
var scope = requested?.Trim().ToLowerInvariant();
if (scope is not (null or "" or "instance" or "series")) throw new ArgumentException("scope must be instance or series.");
if (Str(ev, "recurringEventId") is { Length: > 0 } series)
{
if (scope == "series")
throw new ArgumentException($"Event {eventId} is one occurrence of the recurring series {series}. To change the whole series, pass eventId={series} with scope=series.");
return EventKind.Instance;
}
if (Recurrence(ev) is { Count: > 0 })
{
if (scope == "instance")
throw new ArgumentException($"Event {eventId} is a recurring series. To change one date, call list_instances with eventId={eventId}, then pass that occurrence's id with scope=instance.");
return EventKind.Series;
}
return EventKind.Single;
}
/// <summary>Every line must be an RFC 5545 RRULE, EXDATE, RDATE or EXRULE property.</summary>
internal static void ValidateRecurrence(IReadOnlyList<string> lines)
{
foreach (var line in lines)
{
var trimmed = (line ?? "").Trim();
if (!RecurrencePrefixes.Any(p => trimmed.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException($"Recurrence line \"{line}\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.");
}
}
private static JsonArray RecurrenceJson(IReadOnlyList<string> lines) => new(lines.Select(r => (JsonNode)r.Trim()).ToArray());
}