Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
275
internal/calendar/sync.go
Normal file
275
internal/calendar/sync.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
||||
)
|
||||
|
||||
// DefaultDuration is how long a school event is blocked out when Compass gives
|
||||
// only a start time. The week grid stores a start, not an end.
|
||||
const DefaultDuration = time.Hour
|
||||
|
||||
// Marker builds the idempotency marker stored in an event's description. It
|
||||
// carries the SQLite item id, so a re-run can recognise its own event even when
|
||||
// Home Assistant reports no event id.
|
||||
func Marker(itemID int64) string {
|
||||
return fmt.Sprintf("[compass-nhs:%d]", itemID)
|
||||
}
|
||||
|
||||
// ItemMarker is the full description tag written to the calendar.
|
||||
func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) }
|
||||
|
||||
// Options narrows one sync run.
|
||||
type Options struct {
|
||||
// ChildID limits the run to one child (nil means all children).
|
||||
ChildID *int64
|
||||
// ItemID pushes exactly one item (used by the item screen).
|
||||
ItemID *int64
|
||||
// Limit caps how many events are written in one run (0 means no cap).
|
||||
Limit int
|
||||
// DryRun selects the candidates and reports them without writing.
|
||||
DryRun bool
|
||||
// Now is the clock, so tests are deterministic.
|
||||
Now time.Time
|
||||
// Duration overrides DefaultDuration.
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Created is one event that the run wrote (or would write on a dry run).
|
||||
type Created struct {
|
||||
ItemID int64 `json:"item_id"`
|
||||
Child string `json:"child"`
|
||||
Title string `json:"title"`
|
||||
Start string `json:"start"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Result is the outcome of one sync run. Failed is non-zero when a write
|
||||
// failed; the caller then reports loudly (FR-12).
|
||||
type Result struct {
|
||||
Entity string `json:"entity"`
|
||||
Candidates int `json:"candidates"`
|
||||
Created int `json:"created"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Events []Created `json:"events,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// Sync pushes every important school event that is not in the calendar yet.
|
||||
//
|
||||
// It is idempotent in two ways: an item that already carries a Home Assistant
|
||||
// event id is not a candidate, and an event whose description carries this
|
||||
// item's marker is adopted instead of written again. Every attempt is recorded
|
||||
// in the append-only calendar_pushes table, so a failure is visible and loud
|
||||
// rather than swallowed (FR-12).
|
||||
func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) {
|
||||
if st == nil {
|
||||
return Result{}, errors.New("calendar: store is required")
|
||||
}
|
||||
if c == nil {
|
||||
return Result{}, errors.New("calendar: client is required")
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
duration := opts.Duration
|
||||
if duration <= 0 {
|
||||
duration = DefaultDuration
|
||||
}
|
||||
|
||||
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
|
||||
|
||||
candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, now, opts.Limit)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Candidates = len(candidates)
|
||||
if len(candidates) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// One window covers every candidate, so the marker scan is a single call.
|
||||
windowEnd := now.Add(24 * time.Hour)
|
||||
for _, it := range candidates {
|
||||
if it.DueAt != nil && it.DueAt.After(windowEnd) {
|
||||
windowEnd = it.DueAt.Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
existing, err := c.Events(ctx, now.Add(-24*time.Hour), windowEnd)
|
||||
if err != nil {
|
||||
// A failed read is not a licence to write blind: it would risk
|
||||
// duplicates, so record one loud failure and stop.
|
||||
msg := fmt.Sprintf("calendar: reading existing events failed: %v", err)
|
||||
res.Failed = len(candidates)
|
||||
res.Errors = append(res.Errors, msg)
|
||||
for _, it := range candidates {
|
||||
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, msg); rerr != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", rerr)
|
||||
}
|
||||
}
|
||||
log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
|
||||
return res, nil
|
||||
}
|
||||
known := indexByMarker(existing)
|
||||
|
||||
for _, it := range candidates {
|
||||
if it.DueAt == nil {
|
||||
continue
|
||||
}
|
||||
marker := Marker(it.ID)
|
||||
|
||||
if ev, ok := known[marker]; ok {
|
||||
// Adopt an event this run (or an earlier one) already wrote.
|
||||
res.Skipped++
|
||||
uid := ev.UID
|
||||
if uid == "" {
|
||||
// No id from Home Assistant: keep the item out of the next
|
||||
// candidate set by adopting the marker as its reference.
|
||||
uid = marker
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
|
||||
log.Error("mark calendar item", "item", it.ID, "err", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", err)
|
||||
}
|
||||
}
|
||||
res.Events = append(res.Events, Created{
|
||||
ItemID: it.ID, Child: it.Child, Title: it.Title,
|
||||
Start: it.DueAt.In(time.Local).Format(time.RFC3339), UID: uid,
|
||||
Summary: "already in the calendar",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
created := Created{
|
||||
ItemID: it.ID, Child: it.Child, Title: it.Title,
|
||||
Start: it.DueAt.In(time.Local).Format(time.RFC3339),
|
||||
DryRun: opts.DryRun,
|
||||
}
|
||||
if opts.DryRun {
|
||||
res.Created++
|
||||
res.Events = append(res.Events, created)
|
||||
continue
|
||||
}
|
||||
|
||||
uid, err := c.CreateEvent(ctx, Event{
|
||||
Summary: eventSummary(it),
|
||||
Description: eventDescription(it),
|
||||
Start: it.DueAt,
|
||||
End: ptrTime(it.DueAt.Add(duration)),
|
||||
})
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
msg := fmt.Sprintf("item %d (%s %q): %v", it.ID, it.Child, it.Title, err)
|
||||
res.Errors = append(res.Errors, msg)
|
||||
log.Error("calendar write failed", "item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity(), "err", err)
|
||||
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, err.Error()); rerr != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", rerr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if uid == "" {
|
||||
// Home Assistant did not report an event id. The marker in the
|
||||
// description still makes the next run idempotent, but say so.
|
||||
log.Warn("home assistant returned no event id; relying on the description marker",
|
||||
"item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity())
|
||||
}
|
||||
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
|
||||
log.Error("mark calendar item", "item", it.ID, "err", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
|
||||
log.Error("record calendar push", "item", it.ID, "err", err)
|
||||
}
|
||||
res.Created++
|
||||
created.UID = uid
|
||||
res.Events = append(res.Events, created)
|
||||
}
|
||||
|
||||
sort.Slice(res.Events, func(i, j int) bool { return res.Events[i].Start < res.Events[j].Start })
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// eventSummary is the calendar entry title. The child's name is prefixed, so a
|
||||
// shared family calendar says who the event belongs to.
|
||||
func eventSummary(it store.CalendarItem) string {
|
||||
title := strings.TrimSpace(it.Title)
|
||||
if it.Child != "" {
|
||||
return it.Child + " — " + title
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// eventDescription carries the school detail, the Compass link and the
|
||||
// idempotency marker. The marker is what makes a re-run safe.
|
||||
func eventDescription(it store.CalendarItem) string {
|
||||
var b strings.Builder
|
||||
body := strings.TrimSpace(it.Body)
|
||||
if body != "" && body != strings.TrimSpace(it.Title) {
|
||||
b.WriteString(body)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if it.URL != "" {
|
||||
b.WriteString(it.URL)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if it.Subject != "" {
|
||||
b.WriteString("Subject: " + it.Subject + "\n")
|
||||
}
|
||||
b.WriteString("\n" + ItemMarker(it.ID))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// indexByMarker maps a marker to the event that carries it.
|
||||
func indexByMarker(events []Event) map[string]Event {
|
||||
out := make(map[string]Event, len(events))
|
||||
for _, ev := range events {
|
||||
if m := findMarker(ev.Description); m != "" {
|
||||
out[m] = ev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ptrTime returns a pointer to t, so an event end can hold a computed time.
|
||||
func ptrTime(t time.Time) *time.Time { return &t }
|
||||
|
||||
// findMarker returns the first compass-nhs marker in a text, or "".
|
||||
func findMarker(text string) string {
|
||||
const open, closeTok = "[compass-nhs:", "]"
|
||||
i := strings.Index(text, open)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
start := i + len(open)
|
||||
j := strings.Index(text[start:], closeTok)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
value := text[start : start+j]
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return open + value + closeTok
|
||||
}
|
||||
Reference in New Issue
Block a user