Two corrections found while preparing the first live run: - A scan that runs late in the day still records today's school events; the candidate window now starts at the local start of day instead of "now". - The Compass week calendar is school-wide, so one event is stored against each child. Write it once, with no child prefix in the title, and record the same Home Assistant event id on every child's item. A per-child event still keeps the child's name. Also report pending events as events, not rows, in `school calendar status`. Tests cover the window and the de-duplication. Co-Authored-By: Paperclip <noreply@paperclip.ing>
368 lines
10 KiB
Go
368 lines
10 KiB
Go
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
|
|
}
|