Fetch and parse Harry and Finn with a stored Compass session per child and store every item against the right children.id. - cmd/school/session.go + internal/store/session.go: read and write the per-child harness session state in the SQLite `session` table. `show` prints metadata only; the state is never printed. - scripts/compass-fetch-patchright.mjs: reuse the stored session, refresh it after login, resolve the child's userId from the parent page by name, and keep the shared calendar out of the per-child run (it would leak the eldest's timetable into the younger child). - scripts/scan-compass.sh + make scan: scan one child or both; restore the session, fetch, persist the session, then parse. A failing child is recorded in `scans` with ok = 0 and the script exits non-zero (FR-12). - cmd/school/scan.go: `school scan record` writes a fetch failure to the scans table. cmd/school/items.go: `school items purge` removes rows that a shared page stored against the wrong child. - internal/store/store_test.go: prove items and sessions are separated by child. docs/phase-4-both-children-notes.md; README + image tag 0.3.0-phase4. Live evidence: Harry 70 tasks / 1 message / 35 events (6 overdue); Finn 42 tasks / 1 message / 0 events (2 overdue). Finn's subjects are Year 7, Harry's are Year 11. Both sessions stored. Co-Authored-By: Paperclip <noreply@paperclip.ing>
239 lines
6.2 KiB
Go
239 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
|
|
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
|
|
)
|
|
|
|
// sessionCommand is the `school session` entry point. It reads and writes the
|
|
// per-child harness session state in the SQLite `session` table (AGENTS.md §7).
|
|
//
|
|
// The state is a secret (browser cookies / localStorage). `show` prints only a
|
|
// summary; the state itself is only ever written to the file named by --out or
|
|
// read from the file named by --file, with mode 0600.
|
|
func sessionCommand(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, sessionUsage)
|
|
os.Exit(2)
|
|
}
|
|
sub := args[0]
|
|
fs := flag.NewFlagSet("session "+sub, flag.ContinueOnError)
|
|
var (
|
|
child string
|
|
out string
|
|
file string
|
|
asJSON bool
|
|
)
|
|
fs.StringVar(&child, "child", "", "child name (Harry or Finn)")
|
|
fs.StringVar(&out, "out", "", "get: write the state to this file (mode 0600)")
|
|
fs.StringVar(&file, "file", "", "put: read the state from this file")
|
|
fs.BoolVar(&asJSON, "json", false, "print the summary as JSON")
|
|
_ = fs.Parse(args[1:])
|
|
|
|
switch sub {
|
|
case "get":
|
|
if child == "" || out == "" {
|
|
fmt.Fprintln(os.Stderr, "school session get: --child and --out are required")
|
|
os.Exit(2)
|
|
}
|
|
case "put":
|
|
if child == "" || file == "" {
|
|
fmt.Fprintln(os.Stderr, "school session put: --child and --file are required")
|
|
os.Exit(2)
|
|
}
|
|
case "show", "clear":
|
|
if child == "" && sub == "clear" {
|
|
fmt.Fprintln(os.Stderr, "school session clear: --child is required")
|
|
os.Exit(2)
|
|
}
|
|
default:
|
|
fmt.Fprintln(os.Stderr, sessionUsage)
|
|
os.Exit(2)
|
|
}
|
|
|
|
if err := runSession(sub, child, out, file, asJSON); err != nil {
|
|
fmt.Fprintln(os.Stderr, "school session:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
const sessionUsage = `usage:
|
|
school session get --child NAME --out FILE
|
|
school session put --child NAME --file FILE
|
|
school session show [--child NAME] [--json]
|
|
school session clear --child NAME`
|
|
|
|
func runSession(sub, child, out, file string, asJSON bool) error {
|
|
ctx := context.Background()
|
|
|
|
cfg, err := config.LoadDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st, err := store.Open(cfg.DBPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer st.Close()
|
|
if err := st.Migrate(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch sub {
|
|
case "get":
|
|
return sessionGet(ctx, st, child, out)
|
|
case "put":
|
|
return sessionPut(ctx, st, child, file)
|
|
case "show":
|
|
return sessionShow(ctx, st, child, asJSON)
|
|
case "clear":
|
|
return sessionClear(ctx, st, child)
|
|
}
|
|
return fmt.Errorf("unknown session subcommand %q", sub)
|
|
}
|
|
|
|
func sessionGet(ctx context.Context, st *store.Store, child, out string) error {
|
|
c, err := st.ChildByName(ctx, child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("unknown child %q", child)
|
|
}
|
|
sess, err := st.GetSession(ctx, c.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sess == nil {
|
|
// No stored session is a normal state: the fetcher logs in fresh.
|
|
fmt.Printf("child=%s no stored session\n", c.Name)
|
|
return nil
|
|
}
|
|
if err := os.WriteFile(out, []byte(sess.State), 0o600); err != nil {
|
|
return fmt.Errorf("write session file: %w", err)
|
|
}
|
|
fmt.Printf("child=%s bytes=%d out=%s\n", c.Name, len(sess.State), out)
|
|
return nil
|
|
}
|
|
|
|
func sessionPut(ctx context.Context, st *store.Store, child, file string) error {
|
|
c, err := st.ChildByName(ctx, child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("unknown child %q", child)
|
|
}
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return fmt.Errorf("read session file: %w", err)
|
|
}
|
|
if len(raw) == 0 {
|
|
return fmt.Errorf("session file %s is empty", file)
|
|
}
|
|
if err := st.PutSession(ctx, c.ID, string(raw)); err != nil {
|
|
return err
|
|
}
|
|
cookies, origins, ok := describeState(string(raw))
|
|
fmt.Printf("child=%s bytes=%d cookies=%d origins=%d valid_json=%t\n", c.Name, len(raw), cookies, origins, ok)
|
|
return nil
|
|
}
|
|
|
|
func sessionClear(ctx context.Context, st *store.Store, child string) error {
|
|
c, err := st.ChildByName(ctx, child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("unknown child %q", child)
|
|
}
|
|
if err := st.DeleteSession(ctx, c.ID); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("child=%s cleared\n", c.Name)
|
|
return nil
|
|
}
|
|
|
|
// sessionSummary is what `show` prints: never the state itself.
|
|
type sessionSummary struct {
|
|
Child string `json:"child"`
|
|
Bytes int `json:"bytes"`
|
|
Cookies int `json:"cookies"`
|
|
Origins int `json:"origins"`
|
|
ValidJSON bool `json:"valid_json"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func sessionShow(ctx context.Context, st *store.Store, child string, asJSON bool) error {
|
|
var sessions []store.ChildSession
|
|
if child != "" {
|
|
c, err := st.ChildByName(ctx, child)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("unknown child %q", child)
|
|
}
|
|
sess, err := st.GetSession(ctx, c.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sess != nil {
|
|
sessions = append(sessions, *sess)
|
|
}
|
|
} else {
|
|
var err error
|
|
sessions, err = st.ListSessions(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
out := make([]sessionSummary, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
cookies, origins, ok := describeState(s.State)
|
|
out = append(out, sessionSummary{
|
|
Child: s.Child,
|
|
Bytes: len(s.State),
|
|
Cookies: cookies,
|
|
Origins: origins,
|
|
ValidJSON: ok,
|
|
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
if asJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
if len(out) == 0 {
|
|
fmt.Println("no stored sessions")
|
|
return nil
|
|
}
|
|
for _, s := range out {
|
|
fmt.Printf("%-6s bytes=%-7d cookies=%-3d origins=%-3d valid_json=%-5t updated_at=%s\n",
|
|
s.Child, s.Bytes, s.Cookies, s.Origins, s.ValidJSON, s.UpdatedAt)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// describeState reports the shape of a fetched storage state without revealing
|
|
// any value. It lets `session show` prove a usable session without printing a
|
|
// secret.
|
|
func describeState(state string) (cookies, origins int, ok bool) {
|
|
var parsed struct {
|
|
Cookies []json.RawMessage `json:"cookies"`
|
|
Origins []json.RawMessage `json:"origins"`
|
|
}
|
|
if err := json.Unmarshal([]byte(state), &parsed); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return len(parsed.Cookies), len(parsed.Origins), true
|
|
}
|