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:
364
cmd/school/calendar.go
Normal file
364
cmd/school/calendar.go
Normal file
@@ -0,0 +1,364 @@
|
||||
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, now, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "school calendar:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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: len(candidates),
|
||||
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", len(candidates))
|
||||
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
|
||||
}
|
||||
@@ -56,6 +56,9 @@ func main() {
|
||||
case "brief":
|
||||
briefCommand(os.Args[2:])
|
||||
return
|
||||
case "calendar":
|
||||
calendarCommand(os.Args[2:])
|
||||
return
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user