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:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user