package main import ( "context" "encoding/json" "flag" "fmt" "net/url" "os" "sort" "strings" "time" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) // calendarCommand is the `school calendar` entry point (AGENTS.md §11 Phase 6). // It writes important school events to Sam's Home Assistant calendar and reads // the calendar back. The school stays read-only. func calendarCommand(args []string) { if len(args) == 0 { usage("school calendar entities | status | push [--child NAME] [--item N] [--limit N] [--dry-run] [--json] | events [--days N]") } switch args[0] { case "entities": calendarEntities(args[1:]) case "status": calendarStatus(args[1:]) case "push": calendarPush(args[1:]) case "events": calendarEvents(args[1:]) default: usage("school calendar entities | status | push | events") } } // openCalendar builds the store and the Home Assistant client. The token is // never printed: the caller only ever sees the host and the entity id. func openCalendar(ctx context.Context) (*store.Store, *config.DBConfig, *calendar.Client, error) { cfg, err := config.LoadDB() if err != nil { return nil, nil, nil, err } st, err := store.Open(cfg.DBPath) if err != nil { return nil, nil, nil, err } if err := st.Migrate(ctx); err != nil { _ = st.Close() return nil, nil, nil, err } client, err := calendar.New(cfg.Calendar, nil) if err != nil { _ = st.Close() return nil, nil, nil, err } return st, cfg, client, nil } // calendarEntities lists the calendar entities Home Assistant reports, so the // operator can choose SCHOOL_HA_CALENDAR_ENTITY from real values. func calendarEntities(args []string) { fs := flag.NewFlagSet("calendar entities", flag.ContinueOnError) _ = fs.Parse(args) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() st, cfg, client, err := openCalendar(ctx) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(2) } defer st.Close() if err := client.Healthy(ctx); err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } entities, err := client.Calendars(ctx) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL)) fmt.Printf("configured %s\n", cfg.Calendar.EntityID) for _, e := range entities { writable := "read-only" if e.CanWrite() { writable = "writable" } marker := " " if e.EntityID == cfg.Calendar.EntityID { marker = "*" } fmt.Printf("%s %-45s %-10s %s\n", marker, e.EntityID, writable, e.Name) } } // calendarStatus shows the configured target (never the token) and the last // recorded push, so a failure is visible outside the dashboard too (FR-12). func calendarStatus(args []string) { fs := flag.NewFlagSet("calendar status", flag.ContinueOnError) var asJSON bool fs.BoolVar(&asJSON, "json", false, "print the status as JSON") _ = fs.Parse(args) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() st, cfg, client, err := openCalendar(ctx) if err != nil { // A missing target is itself the status: report it, do not crash. fmt.Fprintf(os.Stderr, "school calendar: %v\n", err) os.Exit(2) } defer st.Close() summary, err := st.CalendarPushSummary(ctx) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } now := time.Now().In(cfg.Location) candidates, err := st.CalendarCandidates(ctx, nil, nil, calendar.StartOfDay(now), 0) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } // Count events, not rows: a school-wide event is stored against each // child but written once. pendingEvents := len(calendar.GroupDuplicates(candidates)) if asJSON { out := struct { BaseURL string `json:"base_url"` Entity string `json:"entity"` TokenSet bool `json:"token_set"` Pending int `json:"pending_events"` ReloadedAt string `json:"checked_at"` HomeAssist string `json:"ha_status"` LastPush *store.CalendarPush `json:"last_push,omitempty"` PushSummary store.CalendarPushStatus `json:"pushes"` }{ BaseURL: haHost(cfg.Calendar.BaseURL), Entity: cfg.Calendar.EntityID, TokenSet: cfg.Calendar.Token != "", Pending: pendingEvents, ReloadedAt: now.Format(time.RFC3339), PushSummary: summary, } if err := client.Healthy(ctx); err != nil { out.HomeAssist = "unreachable: " + err.Error() } else { out.HomeAssist = "ok" } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(out); err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } return } fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL)) fmt.Printf("entity %s\n", cfg.Calendar.EntityID) fmt.Printf("token set\n") if err := client.Healthy(ctx); err != nil { fmt.Printf("reachable no (%v)\n", err) } else { fmt.Printf("reachable yes\n") } fmt.Printf("pending events %d (important, not in the calendar yet)\n", pendingEvents) fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed) if summary.Last != nil { state := "ok" if !summary.Last.OK { state = "FAILED: " + summary.Last.Error } fmt.Printf("last push item %d %s %s\n", summary.Last.ItemID, summary.Last.CreatedAt.In(cfg.Location).Format("2006-01-02 15:04"), state) } else { fmt.Printf("last push never run\n") } } // calendarPush writes the pending important events and records every attempt. func calendarPush(args []string) { fs := flag.NewFlagSet("calendar push", flag.ContinueOnError) var ( child string itemID int64 limit int dryRun bool asJSON bool ) fs.StringVar(&child, "child", "", "limit to one child (Harry or Finn)") fs.Int64Var(&itemID, "item", 0, "push exactly one stored item id") fs.IntVar(&limit, "limit", 0, "cap how many events are written") fs.BoolVar(&dryRun, "dry-run", false, "select and report without writing") fs.BoolVar(&asJSON, "json", false, "print the result as JSON") _ = fs.Parse(args) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() st, cfg, client, err := openCalendar(ctx) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(2) } defer st.Close() opts := calendar.Options{Limit: limit, DryRun: dryRun, Now: time.Now().In(cfg.Location)} if child != "" { c, err := st.ChildByName(ctx, child) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } if c == nil { fmt.Fprintf(os.Stderr, "school calendar: unknown child %q\n", child) os.Exit(2) } opts.ChildID = &c.ID } if itemID > 0 { opts.ItemID = &itemID } res, err := calendar.Sync(ctx, st, client, opts, nil) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } if asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(res); err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } } else { printCalendarResult(res, cfg.Location) } if res.Failed > 0 { os.Exit(1) } } func printCalendarResult(res calendar.Result, loc *time.Location) { verb := "created" if res.DryRun { verb = "would create" } fmt.Printf("entity %s\n", res.Entity) fmt.Printf("candidates %d\n", res.Candidates) fmt.Printf("%-11s %d\n", verb, res.Created) fmt.Printf("skipped %d (already in the calendar)\n", res.Skipped) fmt.Printf("failed %d\n", res.Failed) for _, ev := range res.Events { start := ev.Start if t, err := time.Parse(time.RFC3339, ev.Start); err == nil { start = t.In(loc).Format("Mon 02 Jan 15:04") } uid := ev.UID if uid == "" { uid = "-" } fmt.Printf(" item %-5d %-6s %-10s %-45s uid=%s\n", ev.ItemID, ev.Child, start, truncate(ev.Title, 45), uid) } for _, msg := range res.Errors { fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg) } } // calendarEvents reads the calendar back, which is the evidence that an event // really is in Sam's Home Assistant calendar. func calendarEvents(args []string) { fs := flag.NewFlagSet("calendar events", flag.ContinueOnError) var ( days int asJSON bool ) fs.IntVar(&days, "days", 14, "how many days ahead to read") fs.BoolVar(&asJSON, "json", false, "print the events as JSON") _ = fs.Parse(args) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() st, cfg, client, err := openCalendar(ctx) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(2) } defer st.Close() now := time.Now().In(cfg.Location) end := now.AddDate(0, 0, days) events, err := client.Events(ctx, now.Add(-24*time.Hour), end) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } sort.Slice(events, func(i, j int) bool { return eventStart(events[i]).Before(eventStart(events[j])) }) if asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(events) return } if len(events) == 0 { fmt.Printf("no events on %s between %s and %s\n", cfg.Calendar.EntityID, now.Format("2006-01-02 15:04"), end.Format("2006-01-02 15:04")) return } for _, ev := range events { start := eventStart(ev) fmt.Printf("%s %-45s %s\n", start.In(cfg.Location).Format("Mon 02 Jan 15:04"), truncate(ev.Summary, 45), shortMarker(ev.Description)) } } func eventStart(ev calendar.Event) time.Time { if ev.Start != nil { return *ev.Start } return time.Time{} } // shortMarker shows only the compass-nhs marker of a description, so a read-back // stays a one-line list. func shortMarker(desc string) string { const open = "[compass-nhs:" i := strings.Index(desc, open) if i < 0 { return "" } j := strings.Index(desc[i:], "]") if j < 0 { return "" } return desc[i : i+j+1] } // haHost prints the Home Assistant host only. The token and any query string // stay out of the output (AGENTS.md §10 rule 3). func haHost(base string) string { u, err := url.Parse(base) if err != nil || u.Host == "" { return base } return u.Scheme + "://" + u.Host }