- Parse each child's own "Today" day widget for the timetable (FR-16); drop the school-wide week calendar's lesson grid and clean the leaked routine rows. - Add the editable codes map (FR-17): subjects, rooms, teachers and period labels, seeded, an unknown code falls back to the raw code; `school codes` CLI. - Brief: Finn's block first, then Harry's, four sections always present (FR-9); periods decoded into plain English; the undated tail is one counted line (FR-18). - Calendar: write periods + work due + work overdue + major events, per child, on every pass. Identity is the stable per-item key, so a re-run never re-inserts (FR-8). morning-brief.sh now runs scan -> calendar push -> notify. - Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods. Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing. Co-Authored-By: Paperclip <noreply@paperclip.ing>
532 lines
16 KiB
Go
532 lines
16 KiB
Go
// Package store owns the SQLite database (AGENTS.md §7). It is the single
|
|
// source of truth for stored items, scans, notifications and session state.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationFS embed.FS
|
|
|
|
// Store wraps the database handle.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Open opens (creating if needed) the SQLite database at path.
|
|
func Open(path string) (*Store, error) {
|
|
dsn := fmt.Sprintf(
|
|
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
|
|
path,
|
|
)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
// modernc/sqlite serialises writes; one connection avoids lock churn.
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// DB exposes the handle for read-only queries.
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
// Ping verifies the database is reachable.
|
|
func (s *Store) Ping(ctx context.Context) error {
|
|
return s.db.PingContext(ctx)
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// Migrate applies every embedded migration that has not run yet. It tracks the
|
|
// applied version with PRAGMA user_version, so reruns are safe.
|
|
func (s *Store) Migrate(ctx context.Context) error {
|
|
files, err := migrationFiles()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var version int
|
|
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
|
return fmt.Errorf("read user_version: %w", err)
|
|
}
|
|
|
|
for i := version; i < len(files); i++ {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin migration %d: %w", i+1, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %s: %w", files[i].name, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("set user_version %d: %w", i+1, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type migration struct {
|
|
name string
|
|
sql string
|
|
}
|
|
|
|
func migrationFiles() ([]migration, error) {
|
|
entries, err := fs.ReadDir(migrationFS, "migrations")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read migrations: %w", err)
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
names = append(names, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(names) // 001_, 002_, ... apply in order
|
|
|
|
out := make([]migration, 0, len(names))
|
|
for _, n := range names {
|
|
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read migration %s: %w", n, err)
|
|
}
|
|
out = append(out, migration{name: n, sql: string(b)})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Child is one school child (AGENTS.md §7).
|
|
type Child struct {
|
|
ID int64
|
|
Name string
|
|
Year string
|
|
CompassID string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// SeedChildren inserts the given names if they are not present yet. It is
|
|
// idempotent, so it is safe to call on every start. It never deletes a child.
|
|
func (s *Store) SeedChildren(ctx context.Context, names ...string) error {
|
|
for _, name := range names {
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO children (name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name); err != nil {
|
|
return fmt.Errorf("seed child %q: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListChildren returns every child, oldest first.
|
|
func (s *Store) ListChildren(ctx context.Context) ([]Child, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
|
|
FROM children ORDER BY id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list children: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Child
|
|
for rows.Next() {
|
|
var c Child
|
|
var created string
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
|
|
return nil, fmt.Errorf("scan child: %w", err)
|
|
}
|
|
c.CreatedAt = parseTime(created)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Scan is one row of the append-only scan log (FR-13).
|
|
type Scan struct {
|
|
ID int64 `json:"id"`
|
|
ChildID *int64 `json:"child_id,omitempty"`
|
|
ChildName string `json:"child,omitempty"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
PagesFetched int `json:"pages_fetched"`
|
|
ItemsFound int `json:"items_found"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ScanStatus summarises scan health for the dashboard. It exists so that a
|
|
// failed scan is shown as an explicit failure and never as an empty day
|
|
// (FR-12).
|
|
type ScanStatus struct {
|
|
// EverRun is false until the first scan is recorded.
|
|
EverRun bool `json:"ever_run"`
|
|
// Total is the number of scan rows.
|
|
Total int `json:"total"`
|
|
// Failed is the number of rows with ok = 0.
|
|
Failed int `json:"failed"`
|
|
// Last is the most recent scan, when one exists.
|
|
Last *Scan `json:"last,omitempty"`
|
|
}
|
|
|
|
// LastScan returns the most recent scan row, or nil when none exists.
|
|
func (s *Store) LastScan(ctx context.Context) (*Scan, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
|
|
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
|
|
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
|
|
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`)
|
|
sc, err := scanRow(row)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &sc, nil
|
|
}
|
|
|
|
// ScanSummary builds the scan health status shown on the dashboard.
|
|
func (s *Store) ScanSummary(ctx context.Context) (ScanStatus, error) {
|
|
var st ScanStatus
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM scans`).
|
|
Scan(&st.Total, &st.Failed); err != nil {
|
|
return st, fmt.Errorf("scan summary: %w", err)
|
|
}
|
|
st.EverRun = st.Total > 0
|
|
last, err := s.LastScan(ctx)
|
|
if err != nil {
|
|
return st, err
|
|
}
|
|
st.Last = last
|
|
return st, nil
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanRow(r rowScanner) (Scan, error) {
|
|
var sc Scan
|
|
var childID sql.NullInt64
|
|
var started string
|
|
var finished sql.NullString
|
|
var ok int
|
|
if err := r.Scan(&sc.ID, &childID, &sc.ChildName, &started, &finished,
|
|
&sc.PagesFetched, &sc.ItemsFound, &ok, &sc.Error); err != nil {
|
|
return sc, err
|
|
}
|
|
if childID.Valid {
|
|
sc.ChildID = &childID.Int64
|
|
}
|
|
sc.StartedAt = parseTime(started)
|
|
if finished.Valid {
|
|
t := parseTime(finished.String)
|
|
sc.FinishedAt = &t
|
|
}
|
|
sc.OK = ok == 1
|
|
return sc, nil
|
|
}
|
|
|
|
// parseTime accepts SQLite's default datetime format and RFC3339.
|
|
func parseTime(s string) time.Time {
|
|
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
|
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// RecordScan appends one scan row. It exists so later phases and the tests can
|
|
// prove the fail-loudly path. A non-empty errMsg records a failure even when
|
|
// nothing was fetched.
|
|
func (s *Store) RecordScan(ctx context.Context, childID *int64, pages, items int, ok bool, errMsg string) (int64, error) {
|
|
okInt := 0
|
|
if ok {
|
|
okInt = 1
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO scans (child_id, finished_at, pages_fetched, items_found, ok, error)
|
|
VALUES (?, datetime('now'), ?, ?, ?, NULLIF(?, ''))`,
|
|
childID, pages, items, okInt, errMsg)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("record scan: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// Item is one row of the items table (AGENTS.md §7). It mirrors parse.Item
|
|
// without importing the parse package, so the store stays independent of the
|
|
// scraper.
|
|
type Item struct {
|
|
ID int64
|
|
ChildID int64
|
|
Child string
|
|
Source string
|
|
Type string
|
|
Subject string
|
|
Teacher string
|
|
Title string
|
|
Body string
|
|
DueAt *time.Time
|
|
URL string
|
|
SourcePage string
|
|
Importance string
|
|
ExternalRef string
|
|
}
|
|
|
|
// UpsertItems writes every item for one child, de-duplicating on
|
|
// (child_id, source, type, external_ref). It returns how many rows were
|
|
// inserted and how many were refreshed, so a re-parse never duplicates an item.
|
|
func (s *Store) UpsertItems(ctx context.Context, childID int64, items []Item) (inserted, updated int, err error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("upsert items: begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
for _, it := range items {
|
|
if it.ExternalRef == "" {
|
|
return 0, 0, fmt.Errorf("upsert items: %q has no external reference", it.Title)
|
|
}
|
|
var existing int64
|
|
err := tx.QueryRowContext(ctx,
|
|
`SELECT id FROM items WHERE child_id = ? AND source = ? AND type = ? AND external_ref = ?`,
|
|
childID, it.Source, it.Type, it.ExternalRef).Scan(&existing)
|
|
switch {
|
|
case err == sql.ErrNoRows:
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO items (child_id, source, type, subject, teacher, title, body,
|
|
due_at, url, source_page, importance, external_ref)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
childID, it.Source, it.Type, it.Subject, it.Teacher, it.Title, it.Body,
|
|
formatDue(it.DueAt), it.URL, it.SourcePage, it.Importance, it.ExternalRef); err != nil {
|
|
return 0, 0, fmt.Errorf("insert item %q: %w", it.Title, err)
|
|
}
|
|
inserted++
|
|
case err != nil:
|
|
return 0, 0, fmt.Errorf("look up item %q: %w", it.Title, err)
|
|
default:
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE items SET subject = ?, teacher = ?, title = ?, body = ?, due_at = ?,
|
|
url = ?, source_page = ?, importance = ?, updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
it.Subject, it.Teacher, it.Title, it.Body, formatDue(it.DueAt),
|
|
it.URL, it.SourcePage, it.Importance, existing); err != nil {
|
|
return 0, 0, fmt.Errorf("update item %q: %w", it.Title, err)
|
|
}
|
|
updated++
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, fmt.Errorf("upsert items: commit: %w", err)
|
|
}
|
|
return inserted, updated, nil
|
|
}
|
|
|
|
// formatDue stores a due time in UTC RFC3339 so ordering is DST-safe, or NULL
|
|
// when there is no due date.
|
|
func formatDue(t *time.Time) any {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// DeleteChildCompassTasksFromPages removes a child's Compass task rows that came
|
|
// from the given source pages. It is used when an authoritative full task list
|
|
// replaces a partial grid: the legacy grid is capped
|
|
// (LearningTask_Default_Records_Count) and a stale row must not survive as if it
|
|
// were the whole set (AUD-114, FR-12). Rows from any other source page are left
|
|
// untouched.
|
|
func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID int64, sourcePages ...string) (int64, error) {
|
|
if len(sourcePages) == 0 {
|
|
return 0, nil
|
|
}
|
|
placeholders := strings.Repeat("?,", len(sourcePages)-1) + "?"
|
|
args := make([]any, 0, len(sourcePages)+1)
|
|
args = append(args, childID)
|
|
for _, p := range sourcePages {
|
|
args = append(args, p)
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM items
|
|
WHERE child_id = ? AND source = 'compass' AND type = 'task'
|
|
AND source_page IN (`+placeholders+`)`, args...)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete partial task rows: %w", err)
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
// DeleteItemsFromPages is DeleteItems narrowed to the given source pages. It is
|
|
// used to drop a stale snapshot (for example a child's previously parsed day
|
|
// widget) without touching the same child's other rows.
|
|
func (s *Store) DeleteItemsFromPages(ctx context.Context, childID int64, source, itemType string, pages ...string) (int64, error) {
|
|
if len(pages) == 0 {
|
|
return 0, nil
|
|
}
|
|
placeholders := strings.Repeat("?, ", len(pages))
|
|
placeholders = strings.TrimSuffix(placeholders, ", ")
|
|
q := `DELETE FROM items WHERE child_id = ?`
|
|
args := []any{childID}
|
|
if source != "" {
|
|
q += ` AND source = ?`
|
|
args = append(args, source)
|
|
}
|
|
if itemType != "" {
|
|
q += ` AND type = ?`
|
|
args = append(args, itemType)
|
|
}
|
|
q += ` AND source_page IN (` + placeholders + `)`
|
|
for _, p := range pages {
|
|
args = append(args, p)
|
|
}
|
|
res, err := s.db.ExecContext(ctx, q, args...)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete items for child %d from pages: %w", childID, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// TypeCount is the number of items of one type for one child.
|
|
type TypeCount struct {
|
|
Child string
|
|
Type string
|
|
Count int
|
|
}
|
|
|
|
// CountItemsByType returns per-child, per-type row counts, ordered by child then
|
|
// type. It is the evidence query for Phase 3.
|
|
func (s *Store) CountItemsByType(ctx context.Context) ([]TypeCount, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT COALESCE(ch.name, '(unknown)'), i.type, COUNT(*)
|
|
FROM items i LEFT JOIN children ch ON ch.id = i.child_id
|
|
GROUP BY ch.name, i.type
|
|
ORDER BY ch.name, i.type`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []TypeCount
|
|
for rows.Next() {
|
|
var tc TypeCount
|
|
if err := rows.Scan(&tc.Child, &tc.Type, &tc.Count); err != nil {
|
|
return nil, fmt.Errorf("scan item count: %w", err)
|
|
}
|
|
out = append(out, tc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SampleItems returns up to limit items for a child, newest due date first.
|
|
func (s *Store) SampleItems(ctx context.Context, childID int64, limit int) ([]Item, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
|
|
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
|
|
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
|
|
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, '')
|
|
FROM items i LEFT JOIN children ch ON ch.id = i.child_id
|
|
WHERE i.child_id = ?
|
|
ORDER BY i.type, i.due_at DESC, i.id
|
|
LIMIT ?`, childID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sample items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Item
|
|
for rows.Next() {
|
|
var it Item
|
|
var due sql.NullString
|
|
if err := rows.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
|
|
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
|
|
&it.SourcePage, &it.Importance, &it.ExternalRef); err != nil {
|
|
return nil, fmt.Errorf("scan item: %w", err)
|
|
}
|
|
if due.Valid {
|
|
t := parseTime(due.String)
|
|
it.DueAt = &t
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ChildByName returns one child by name.
|
|
func (s *Store) ChildByName(ctx context.Context, name string) (*Child, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
|
|
FROM children WHERE name = ?`, name)
|
|
var c Child
|
|
var created string
|
|
if err := row.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("child %q: %w", name, err)
|
|
}
|
|
c.CreatedAt = parseTime(created)
|
|
return &c, nil
|
|
}
|
|
|
|
// SetChildCompassID records the child's Compass userId, discovered by the
|
|
// fetcher from the parent landing page (AGENTS.md §7).
|
|
func (s *Store) SetChildCompassID(ctx context.Context, childID int64, compassID string) error {
|
|
if compassID == "" {
|
|
return nil
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE children SET compass_id = ? WHERE id = ?`, compassID, childID); err != nil {
|
|
return fmt.Errorf("set compass id for child %d: %w", childID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteItems removes items for one child, optionally limited to a source and a
|
|
// type. It exists so a scan can drop rows that were stored against the wrong
|
|
// child (Phase 4: nothing may leak between children) and so a later Delete
|
|
// action has a home. It returns how many rows were removed.
|
|
func (s *Store) DeleteItems(ctx context.Context, childID int64, source, itemType string) (int64, error) {
|
|
q := `DELETE FROM items WHERE child_id = ?`
|
|
args := []any{childID}
|
|
if source != "" {
|
|
q += ` AND source = ?`
|
|
args = append(args, source)
|
|
}
|
|
if itemType != "" {
|
|
q += ` AND type = ?`
|
|
args = append(args, itemType)
|
|
}
|
|
res, err := s.db.ExecContext(ctx, q, args...)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete items for child %d: %w", childID, err)
|
|
}
|
|
return n, nil
|
|
}
|