← All source files

V10 / 66cebf3528e0

CalendarSession.cs

Lilith/Core/Calendar/CalendarSession.cs

Changed in this version
CS
using System.Globalization;
using System.Text.Json;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Calendar;

/// <summary>Single-host calendar; detection persists notices without invoking a conversation.</summary>
public sealed class CalendarSession : IDisposable
{
    private readonly object _sync = new();
    private readonly FileStream _lease;
    private readonly string _file;
    private readonly TimeProvider _clock;
    private CalendarState _state;
    private bool _faulted, _disposed;
    public CalendarOptions Options { get; }

    public CalendarSession(CalendarOptions options, TimeProvider? clock = null)
    {
        if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown calendar access mode.");
        Options = options with { RootDirectory = Path.GetFullPath(options.RootDirectory) };
        Options.RootDirectory.EnsureNoLinks();
        Directory.CreateDirectory(Options.RootDirectory);
        _clock = clock ?? TimeProvider.System;
        _file = Path.Combine(Options.RootDirectory, "calendar.json");
        _lease = new(Path.Combine(Options.RootDirectory, "host.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
        try
        {
            _state = File.Exists(_file) ? JsonSerializer.Deserialize<CalendarState>(File.ReadAllText(_file))
                ?? throw new InvalidDataException("Invalid calendar state.") : new();
            if (_state.Entries.Count > 1000 || _state.Pending.Count > 100 || _state.Notices.Count > 1000)
                throw new InvalidDataException("Calendar limits exceeded.");
            // A crash may have left a queued notice. Retain one missed notice, never run actions.
            _state.Notices = _state.Notices.Select(n => n with { Missed = true, Message = MissedMessage(n.Title) }).ToList();
            Detect(true);
            Save();
        }
        catch { _lease.Dispose(); throw; }
    }

    public CalendarResult Execute(CalendarRequest request)
    {
        lock (_sync)
        {
            Check();
            return request.Operation switch
            {
                "create" or "update" or "delete" => Prepare(request),
                "read" => new("completed", "Calendar event.", Find(request.Id)),
                "search" => new("completed", "Calendar matches (up to 20).", _state.Entries
                    .Where(e => e.Title.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Notes.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Date == request.Query)
                    .OrderBy(e => e.Date).ThenBy(e => e.Time).Take(20).Select(Summary).ToArray()),
                "agenda" => Agenda(),
                "pending" => new("completed", "Pending calendar changes.", _state.Pending.ToArray()),
                _ => throw new ArgumentException("Unknown calendar operation.")
            };
        }
    }

    private CalendarResult Agenda()
    {
        var now = _clock.GetUtcNow();
        var events = _state.Entries.Where(e => e.Date == TimeZoneInfo.ConvertTime(now, Zone(e.TimeZone)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))
            .OrderBy(e => e.Time).ToArray();
        var overdue = _state.Notices.Where(n => n.DueUtc <= now).ToArray();
        return new("completed", "Today's events and overdue reminders.", new { has_items = events.Length + overdue.Length > 0,
            event_count = events.Length, overdue_count = overdue.Length, events = events.Take(20).Select(Summary).ToArray(), overdue = overdue.Take(20).ToArray() });
    }

    private CalendarResult Prepare(CalendarRequest request)
    {
        // Persist exact absolute times and stable ID at preparation, never reinterpret on approval.
        if (request.Operation == "create" && request.Id.Length == 0) request = request with { Id = Guid.NewGuid().ToString("N") };
        CalendarEntry? prepared = Validate(request);
        if (prepared is not null) request = request with { TimeZone = prepared.TimeZone };
        if (Options.Mode == WorkspaceAccessMode.Yolo)
        {
            var result = Commit(request); Save(); return result;
        }
        if (_state.Pending.Count >= 100) throw new InvalidOperationException("Review existing calendar proposals first.");
        string id = Guid.NewGuid().ToString("N");
        _state.Pending.Add(new(id, request, _state.Entries.SingleOrDefault(e => e.Id == request.Id)?.Revision));
        Save();
        return new("needs_approval", $"Approve calendar {request.Operation}: {request.Title switch { "" => Find(request.Id).Title, _ => request.Title }}.", request, id);
    }

    public CalendarProposal Review(string id)
    {
        lock (_sync) { Check(); return _state.Pending.SingleOrDefault(p => p.Id == id) ?? throw new ArgumentException("Unknown calendar proposal."); }
    }
    public CalendarResult Approve(string id)
    {
        lock (_sync)
        {
            var proposal = Review(id);
            if (_state.Entries.SingleOrDefault(e => e.Id == proposal.Request.Id)?.Revision != proposal.ExpectedRevision)
                throw new InvalidOperationException("Calendar event changed. Reject this proposal and prepare another.");
            Validate(proposal.Request);
            var result = Commit(proposal.Request);
            _state.Pending.Remove(proposal); Save(); return result;
        }
    }
    public void Reject(string id) { lock (_sync) { var proposal = Review(id); _state.Pending.Remove(proposal); Save(); } }

    private CalendarEntry? Validate(CalendarRequest request)
    {
        if (request.Id.Length is < 1 or > 64 || request.Id.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('_' or '-')))
            throw new ArgumentException("Event ID must contain 1-64 letters, digits, underscores or hyphens.");
        if (request.Operation == "create")
        {
            if (_state.Entries.Count >= 1000 || _state.Entries.Any(e => e.Id == request.Id)) throw new ArgumentException("Calendar ID exists or event limit reached.");
        }
        else
        {
            _ = Find(request.Id);
            if (_state.Notices.Any(n => n.EventId == request.Id)) throw new InvalidOperationException("Deliver this event's outstanding reminder before changing it.");
        }
        if (request.Operation == "delete") return null;
        if (request.Title.Length is < 1 or > 200 || string.IsNullOrWhiteSpace(request.Title) || request.Title.Any(char.IsControl) || request.Notes.Length > 2000 || request.Source.Length > 2000)
            throw new ArgumentException("Provide a title (1-200 visible characters), notes/source up to 2000 characters.");
        if (request.Source.Length > 0 && (!Uri.TryCreate(request.Source, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http")))
            throw new ArgumentException("Source must be an HTTP(S) URL.");
        if (!DateOnly.TryParseExact(request.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) throw new ArgumentException("Date must be yyyy-MM-dd.");
        if (_state.Entries.Any(e => e.Id != request.Id && e.Title.Equals(request.Title, StringComparison.OrdinalIgnoreCase) && e.Date == request.Date && e.Time == request.Time))
            throw new ArgumentException("This title/date/time is already in the calendar. Search and update the existing event instead.");
        var zone = Zone(request.TimeZone.Length == 0 ? TimeZoneInfo.Local.Id : request.TimeZone);
        DateTimeOffset? starts = request.Time.Length == 0 ? null : At(date, request.Time, zone);
        DateTimeOffset? reminder = request.Reminder switch
        {
            "none" => null,
            "" => throw new ArgumentException("Ask the user for a reminder time, or explicit none. Do not choose a default."),
            _ => ParseReminder(request.Reminder, zone)
        };
        if (reminder <= _clock.GetUtcNow()) throw new ArgumentException("Reminder must be in the future; ask for a new reminder time or none.");
        return new(request.Id, request.Title, request.Date, request.Time, zone.Id, starts, reminder, request.Notes, request.Source,
            request.Operation == "create" ? 1 : Find(request.Id).Revision + 1);
    }

    private static TimeZoneInfo Zone(string id) => TimeZoneInfo.FindSystemTimeZoneById(id);
    private static DateTimeOffset At(DateOnly date, string time, TimeZoneInfo zone)
    {
        if (!TimeOnly.TryParseExact(time, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) throw new ArgumentException("Time must be HH:mm.");
        var local = date.ToDateTime(parsed, DateTimeKind.Unspecified);
        if (zone.IsInvalidTime(local) || zone.IsAmbiguousTime(local)) throw new ArgumentException("Local time is invalid or ambiguous at a daylight-saving change. Choose an unambiguous local time.");
        return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, zone), TimeSpan.Zero);
    }
    private static DateTimeOffset ParseReminder(string value, TimeZoneInfo zone)
    {
        if (value.Length != 16 || value[10] != 'T' || !DateOnly.TryParseExact(value[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
            throw new ArgumentException("Reminder must be yyyy-MM-ddTHH:mm in the event time zone, or none.");
        return At(date, value[11..], zone);
    }
    private CalendarResult Commit(CalendarRequest request)
    {
        var entry = Validate(request);
        string title = entry?.Title ?? Find(request.Id).Title;
        _state.Entries.RemoveAll(e => e.Id == request.Id);
        if (entry is not null) _state.Entries.Add(entry);
        return new("completed", $"Calendar {request.Operation} completed{(entry is null ? ": " + title : "")}.", entry);
    }
    public void Tick() { lock (_sync) { Check(); if (Detect(false)) Save(); } }
    private bool Detect(bool missed)
    {
        var due = _state.Entries.Where(e => !e.ReminderQueued && e.ReminderUtc <= _clock.GetUtcNow()).ToArray();
        foreach (var entry in due)
        {
            _state.Entries[_state.Entries.IndexOf(entry)] = entry with { ReminderQueued = true, Revision = entry.Revision + 1 };
            _state.Notices.Add(new(Guid.NewGuid().ToString("N"), entry.Id, entry.Title, entry.ReminderUtc!.Value, missed,
                missed ? MissedMessage(entry.Title) : $"Calendar reminder: {entry.Title}, {entry.Date}{(entry.Time.Length == 0 ? " (all day)" : " at " + entry.Time + " " + entry.TimeZone)}."));
        }
        return due.Length > 0;
    }
    private static string MissedMessage(string title) => $"Missed calendar reminder while Lilith was closed: {title}. No action was replayed.";
    public CalendarNotice? PeekNotice() { lock (_sync) { Check(); return _state.Notices.FirstOrDefault(); } }
    public void CompleteNotice(string id) { lock (_sync) { Check(); _state.Notices.RemoveAll(n => n.Id == id); Save(); } }
    private CalendarEntry Find(string id) => _state.Entries.SingleOrDefault(e => e.Id == id) ?? throw new ArgumentException("Unknown calendar event ID. Search the calendar first.");
    private static object Summary(CalendarEntry entry) => new { entry.Id, entry.Title, entry.Date, entry.Time, entry.TimeZone, entry.ReminderUtc };
    private void Save()
    {
        string temp = _file + "." + Guid.NewGuid().ToString("N") + ".tmp";
        try { File.WriteAllText(temp, JsonSerializer.Serialize(_state)); File.Move(temp, _file, true); }
        catch { _faulted = true; throw; }
    }
    private void Check() { ObjectDisposedException.ThrowIf(_disposed, this); if (_faulted) throw new IOException("Calendar storage failed. Fix storage and restart."); }
    public void Dispose() { lock (_sync) { if (_disposed) return; _disposed = true; _lease.Dispose(); } }
}