feat(google-calendar): calendar time rules and rendering
Offset date-times are normalised to full RFC 3339 (seconds, T separator) before they are sent; the spec's time rules say so.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
|
||||
|
||||
internal enum CalendarTimeKind { Date, Local, Offset }
|
||||
|
||||
/// <summary>A parsed tool time. <see cref="Raw"/> is the RFC 3339 text sent for an <see cref="CalendarTimeKind.Offset"/> value.</summary>
|
||||
internal readonly record struct CalendarTimeValue(CalendarTimeKind Kind, string Raw, DateOnly Date, DateTime Local, DateTimeOffset Instant);
|
||||
|
||||
/// <summary>
|
||||
/// The tool's time rules: <c>YYYY-MM-DD</c> is all-day (end exclusive), a date-time with an offset is sent as-is, one
|
||||
/// without is wall time in the event zone (sent without an offset plus <c>timeZone</c>). Query bounds are RFC 3339.
|
||||
/// </summary>
|
||||
internal static class CalendarTime
|
||||
{
|
||||
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
|
||||
private static readonly string[] LocalFormats =
|
||||
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"];
|
||||
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
|
||||
private const string LocalFormat = "yyyy-MM-dd'T'HH:mm:ss";
|
||||
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'";
|
||||
|
||||
public static CalendarTimeValue Parse(string text, string field)
|
||||
{
|
||||
var s = (text ?? "").Trim();
|
||||
if (DateOnly.TryParseExact(s, "yyyy-MM-dd", Inv, DateTimeStyles.None, out var date))
|
||||
return new(CalendarTimeKind.Date, s, date, default, default);
|
||||
if (HasOffset(s))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(s, Inv, DateTimeStyles.None, out var instant))
|
||||
return new(CalendarTimeKind.Offset, Rfc3339(instant, IsUtcDesignator(s)), default, default, instant);
|
||||
}
|
||||
else if (DateTime.TryParseExact(s, LocalFormats, Inv, DateTimeStyles.None, out var local))
|
||||
{
|
||||
return new(CalendarTimeKind.Local, s, default, DateTime.SpecifyKind(local, DateTimeKind.Unspecified), default);
|
||||
}
|
||||
throw new ArgumentException(
|
||||
$"{field} must be YYYY-MM-DD, a local date-time like 2026-09-30T10:00, or one with an offset like 2026-09-30T10:00:00+02:00 (got \"{text}\").");
|
||||
}
|
||||
|
||||
public static JsonObject ToEventTime(CalendarTimeValue value, string zoneId) => value.Kind switch
|
||||
{
|
||||
CalendarTimeKind.Date => new JsonObject { ["date"] = value.Date.ToString("yyyy-MM-dd", Inv) },
|
||||
CalendarTimeKind.Local => new JsonObject { ["dateTime"] = value.Local.ToString(LocalFormat, Inv), ["timeZone"] = zoneId },
|
||||
_ => new JsonObject { ["dateTime"] = value.Raw, ["timeZone"] = zoneId },
|
||||
};
|
||||
|
||||
/// <summary>Start and end for a new event; a missing end is one day (all-day) or one hour (timed) after start.</summary>
|
||||
public static (JsonObject Start, JsonObject End) ForCreate(string start, string? end, string zoneId)
|
||||
{
|
||||
var s = Parse(start, "start");
|
||||
var e = end is null ? DefaultEnd(s) : Parse(end, "end");
|
||||
EnsureSameKind(s, e);
|
||||
return (ToEventTime(s, zoneId), ToEventTime(e, zoneId));
|
||||
}
|
||||
|
||||
/// <summary>Only the sides that were given; both given must be the same kind.</summary>
|
||||
public static (JsonObject? Start, JsonObject? End) ForUpdate(string? start, string? end, string zoneId)
|
||||
{
|
||||
CalendarTimeValue? s = start is null ? null : Parse(start, "start");
|
||||
CalendarTimeValue? e = end is null ? null : Parse(end, "end");
|
||||
if (s is { } sv && e is { } ev) EnsureSameKind(sv, ev);
|
||||
return (s is { } a ? ToEventTime(a, zoneId) : null, e is { } b ? ToEventTime(b, zoneId) : null);
|
||||
}
|
||||
|
||||
/// <summary>A query bound (<c>timeMin</c>/<c>timeMax</c>) as RFC 3339 with an offset; dates and local times are in <paramref name="zone"/>.</summary>
|
||||
public static string ToRfc3339(string text, TimeZoneInfo zone, string field)
|
||||
{
|
||||
var value = Parse(text, field);
|
||||
return value.Kind switch
|
||||
{
|
||||
CalendarTimeKind.Date => InZone(value.Date.ToDateTime(TimeOnly.MinValue), zone),
|
||||
CalendarTimeKind.Local => InZone(value.Local, zone),
|
||||
_ => value.Instant.ToString(OffsetFormat, Inv),
|
||||
};
|
||||
}
|
||||
|
||||
public static string Utc(DateTimeOffset instant) => instant.ToUniversalTime().ToString(UtcFormat, Inv);
|
||||
|
||||
/// <summary>An event's start/end as <c>Wed 30 Sep 10:00–11:00</c> in <paramref name="zone"/>, or an all-day range.</summary>
|
||||
public static string FormatRange(JsonElement start, JsonElement end, TimeZoneInfo zone)
|
||||
{
|
||||
if (start.TryGetProperty("date", out var startDate))
|
||||
{
|
||||
var s = DateOnly.ParseExact(startDate.GetString()!, "yyyy-MM-dd", Inv);
|
||||
var last = end.TryGetProperty("date", out var endDate)
|
||||
? DateOnly.ParseExact(endDate.GetString()!, "yyyy-MM-dd", Inv).AddDays(-1)
|
||||
: s;
|
||||
return last <= s ? $"{Day(s)} (all day)" : $"{Day(s)} – {Day(last)} (all day)";
|
||||
}
|
||||
var from = TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(start.GetProperty("dateTime").GetString()!, Inv), zone);
|
||||
var to = end.TryGetProperty("dateTime", out var endTime)
|
||||
? TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(endTime.GetString()!, Inv), zone)
|
||||
: from;
|
||||
return from.Date == to.Date
|
||||
? $"{from.ToString("ddd d MMM HH:mm", Inv)}–{to.ToString("HH:mm", Inv)}"
|
||||
: $"{from.ToString("ddd d MMM HH:mm", Inv)} – {to.ToString("ddd d MMM HH:mm", Inv)}";
|
||||
}
|
||||
|
||||
/// <summary>The header line of every tool result; names both zones when the calendar's differs from the chat's.</summary>
|
||||
public static string Header(TimeZoneInfo resolved, TimeZoneInfo chat)
|
||||
{
|
||||
var r = ZoneIds.Name(resolved);
|
||||
var c = ZoneIds.Name(chat);
|
||||
return r == c ? $"Times in {r}." : $"Times in {r} (the calendar's zone; this chat is in {c}).";
|
||||
}
|
||||
|
||||
private static string Day(DateOnly d) => d.ToString("ddd d MMM", Inv);
|
||||
|
||||
private static string InZone(DateTime local, TimeZoneInfo zone) =>
|
||||
new DateTimeOffset(local, zone.GetUtcOffset(local)).ToString(OffsetFormat, Inv);
|
||||
|
||||
private static string Rfc3339(DateTimeOffset instant, bool utc) =>
|
||||
utc && instant.Offset == TimeSpan.Zero ? instant.ToString(UtcFormat, Inv) : instant.ToString(OffsetFormat, Inv);
|
||||
|
||||
private static CalendarTimeValue DefaultEnd(CalendarTimeValue start) => start.Kind switch
|
||||
{
|
||||
CalendarTimeKind.Date => start with { Date = start.Date.AddDays(1) },
|
||||
CalendarTimeKind.Local => start with { Local = start.Local.AddHours(1) },
|
||||
_ => start with
|
||||
{
|
||||
Instant = start.Instant.AddHours(1),
|
||||
Raw = Rfc3339(start.Instant.AddHours(1), start.Raw.EndsWith('Z')),
|
||||
},
|
||||
};
|
||||
|
||||
private static void EnsureSameKind(CalendarTimeValue start, CalendarTimeValue end)
|
||||
{
|
||||
if ((start.Kind == CalendarTimeKind.Date) != (end.Kind == CalendarTimeKind.Date))
|
||||
throw new ArgumentException("start and end must both be dates (YYYY-MM-DD) or both date-times.");
|
||||
}
|
||||
|
||||
private static bool IsUtcDesignator(string s) => s.EndsWith('Z') || s.EndsWith('z');
|
||||
|
||||
private static bool HasOffset(string s)
|
||||
{
|
||||
if (IsUtcDesignator(s)) return true;
|
||||
var t = s.IndexOfAny(['T', ' ']);
|
||||
return t > 0 && s.AsSpan(t).IndexOfAny('+', '-') >= 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user