3af290152c
Radicale becomes a write-only render target, not a store. sqlite stays canonical: every poll mavcaldav reads the pending reminders out of core and publishes each one as a single-event iCal resource, withdrawing the ones that have fired or been cancelled. Losing the collection costs nothing — the next tick rebuilds it, and nothing is ever read back from it. It structurally cannot write to a calendar maven only reads. The render URL and credential are their own flags, and -render-url is refused at startup when it names the collection -url reads; the only paths it addresses carry the maven-reminder- prefix, so even aimed at the wrong collection it can only touch resources it created. Rendering is off unless -render-url is given. The calendar data model now lives in one place, internal/calendar: the Event, the iCal parse it comes from and the render it goes to, the fact key/value encoding, and the source constants that say which calendars may be written to. It was a parse inlined in cmd/mavcaldav and a Sprintf in two files; #126 and #128 both need to agree with it. Fixes a latent day-boundary bug moved out of that inline parse: it took the day number off a local clock reading but built the window boundaries in UTC, so on a box east of Greenwich part of the evening fell outside "today" and the poller saw an empty calendar after 20:00 UTC. Today is now the owner's day in the owner's location, which is what the busy gate and the day plan mean.
130 lines
4.9 KiB
Go
130 lines
4.9 KiB
Go
// Package calendar is the one calendar data model the rest of maven shares:
|
|
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
|
// encoding that puts it in the store.
|
|
//
|
|
// It exists because three separate features read or write the same events and
|
|
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
|
// #126/#127), the write-only render target that publishes maven's own
|
|
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
|
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
|
// format was a Sprintf in two places.
|
|
//
|
|
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
|
// impurity, the way internal/morning and internal/loop do.
|
|
package calendar
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Fact sources. A calendar event reaches the store as a
|
|
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
|
// the source is the whole provenance story:
|
|
//
|
|
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
|
// state stays in sqlite; the calendar is a render target (#127).
|
|
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
|
// maven ever writes to it: no code path pairs this source with a PUT.
|
|
// - SourceAmbient — inferred from an Android notification-listener relay
|
|
// rather than read from a server (#126). Confidence is below 1.0 because a
|
|
// notification is a signal about an event, not the event.
|
|
const (
|
|
SourcePersonal = "poll:caldav"
|
|
SourceWork = "poll:caldav:work"
|
|
SourceAmbient = "ambient:notif"
|
|
)
|
|
|
|
// AmbientConfidence — the confidence a notification-derived event is stored
|
|
// with. A parsed notification line is evidence, not a reading of the calendar,
|
|
// so it must never be indistinguishable from one (#126).
|
|
const AmbientConfidence = 0.6
|
|
|
|
// Sources lists every source a calendar event may legitimately carry, for the
|
|
// store query that reads the calendar back out. Ordered from most to least
|
|
// trusted.
|
|
func Sources() []string {
|
|
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
|
}
|
|
|
|
// ReadOnlySource reports whether events from this source may never be written
|
|
// back. The work calendar is read-only by definition — see #126: maven holding
|
|
// a credential that can write to an employer's calendar is the thing the task
|
|
// exists to avoid.
|
|
func ReadOnlySource(source string) bool {
|
|
return source == SourceWork || source == SourceAmbient
|
|
}
|
|
|
|
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
|
// from a server and the identity maven renders under when it publishes one;
|
|
// Start/End are instants. All-day events are not modelled: the busy gate and
|
|
// the day plan both need a time of day, and an all-day marker answers neither.
|
|
type Event struct {
|
|
UID string
|
|
Summary string
|
|
Start time.Time
|
|
End time.Time
|
|
}
|
|
|
|
// FactKey is the store key for an event: one key per day per summary, stable
|
|
// across polls so re-reading an unchanged calendar rewrites nothing.
|
|
//
|
|
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
|
// by key prefix, not by a timestamp column.
|
|
func FactKey(e Event) string {
|
|
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
|
}
|
|
|
|
// FactValue is the human-readable rendering stored as the fact value, and the
|
|
// string the day plan and the query path read back.
|
|
func FactValue(e Event) string {
|
|
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
|
}
|
|
|
|
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
|
// range-scans between two of these.
|
|
func KeyPrefixForDay(day time.Time) string {
|
|
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
|
}
|
|
|
|
// Busy reports whether any event covers the instant now — the read the loop
|
|
// gate uses to suppress nudges during a meeting.
|
|
func Busy(events []Event, now time.Time) bool {
|
|
for _, e := range events {
|
|
if !now.Before(e.Start) && now.Before(e.End) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Overlapping returns the events intersecting [from, to), sorted by start.
|
|
func Overlapping(events []Event, from, to time.Time) []Event {
|
|
var out []Event
|
|
for _, e := range events {
|
|
if e.End.After(from) && e.Start.Before(to) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
|
return out
|
|
}
|
|
|
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
|
// the day prefix carries the identity and this only disambiguates within a day.
|
|
func safeKey(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
|
b.WriteRune(r)
|
|
case r == ' ' || r == '_':
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|