Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- 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>
This commit is contained in:
@@ -47,6 +47,52 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
|
||||
AND i.due_at IS NOT NULL
|
||||
AND i.due_at >= ?`
|
||||
args := []any{from.UTC().Format(time.RFC3339)}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// CalendarPeriodCandidates returns the per-child periods for one day that are
|
||||
// not in the calendar yet. They come from the child's own day widget
|
||||
// (source_page child-profile / learning-tasks), never from the school-wide week
|
||||
// calendar (FR-16). A period is a routine lesson, so it carries importance
|
||||
// "normal"; school events are written by CalendarCandidates.
|
||||
func (s *Store) CalendarPeriodCandidates(ctx context.Context, childID, itemID *int64, dayStart, dayEnd time.Time, limit int) ([]CalendarItem, error) {
|
||||
q := calendarSelect + `
|
||||
WHERE i.type = 'event'
|
||||
AND i.source = 'compass'
|
||||
AND i.importance = 'normal'
|
||||
AND COALESCE(i.ha_event_uid, '') = ''
|
||||
AND i.source_page IN ('child-profile', 'learning-tasks')
|
||||
AND i.due_at IS NOT NULL
|
||||
AND i.due_at >= ? AND i.due_at < ?`
|
||||
args := []any{dayStart.UTC().Format(time.RFC3339), dayEnd.UTC().Format(time.RFC3339)}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// CalendarTaskCandidates returns the outstanding work that carries a due date
|
||||
// and is not in the calendar yet: the work due in the future and the work
|
||||
// overdue (FR-8). Submitted work is filtered by the caller, which holds the
|
||||
// body-status rule.
|
||||
func (s *Store) CalendarTaskCandidates(ctx context.Context, childID, itemID *int64, limit int) ([]CalendarItem, error) {
|
||||
q := calendarSelect + `
|
||||
WHERE i.type = 'task'
|
||||
AND i.source = 'compass'
|
||||
AND COALESCE(i.ha_event_uid, '') = ''
|
||||
AND i.due_at IS NOT NULL`
|
||||
args := []any{}
|
||||
q, args = narrowCalendarQuery(q, args, childID, itemID)
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
q, args = capCalendarQuery(q, args, limit)
|
||||
return s.queryCalendarItems(ctx, q, args)
|
||||
}
|
||||
|
||||
// narrowCalendarQuery appends the optional child and item filters.
|
||||
func narrowCalendarQuery(q string, args []any, childID, itemID *int64) (string, []any) {
|
||||
if childID != nil {
|
||||
q += ` AND i.child_id = ?`
|
||||
args = append(args, *childID)
|
||||
@@ -55,12 +101,19 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
|
||||
q += ` AND i.id = ?`
|
||||
args = append(args, *itemID)
|
||||
}
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
return q, args
|
||||
}
|
||||
|
||||
func capCalendarQuery(q string, args []any, limit int) (string, []any) {
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
|
||||
// queryCalendarItems runs a candidate query and reads every row.
|
||||
func (s *Store) queryCalendarItems(ctx context.Context, q string, args []any) ([]CalendarItem, error) {
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calendar candidates: %w", err)
|
||||
@@ -118,6 +171,45 @@ func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem,
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
// DeleteChildRoutineCalendarEvents removes a child's routine class periods that
|
||||
// came from the school-wide week calendar (source_page "calendar", importance
|
||||
// "normal"). The week calendar's lesson grid belongs to the eldest child, so
|
||||
// storing it against a younger child is a leak (FR-16); the child's own day
|
||||
// widget is the per-child source. Real school events (importance "high") were
|
||||
// written with the same page and are kept.
|
||||
func (s *Store) DeleteChildRoutineCalendarEvents(ctx context.Context, childID int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM items
|
||||
WHERE child_id = ? AND type = 'event' AND source_page = 'calendar' AND importance = 'normal'`,
|
||||
childID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete routine calendar events: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteChildDayWidgetEvents removes a child's previously parsed day-widget
|
||||
// periods so a re-parse replaces the snapshot instead of accumulating stale
|
||||
// days or wrong times. It only touches events from the child's own pages.
|
||||
func (s *Store) DeleteChildDayWidgetEvents(ctx context.Context, childID int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM items
|
||||
WHERE child_id = ? AND type = 'event' AND source_page IN ('child-profile', 'learning-tasks')`,
|
||||
childID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete day-widget events: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkItemCalendarSynced records the Home Assistant event id and the push time
|
||||
// on one item. After this the item is no longer a calendar candidate.
|
||||
func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error {
|
||||
|
||||
@@ -113,3 +113,36 @@ func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
|
||||
t.Fatalf("last = %+v", summary.Last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodesSeedAndEdit proves the decode map is seeded, editable and that a
|
||||
// correction survives a re-seed (FR-17).
|
||||
func TestCodesSeedAndEdit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
|
||||
codes, err := st.Codes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("codes: %v", err)
|
||||
}
|
||||
if codes.Subject["7SCF"] != "Year 7 Science" {
|
||||
t.Errorf("7SCF = %q, want the seeded Year 7 Science", codes.Subject["7SCF"])
|
||||
}
|
||||
if codes.Subject["NOPE"] != "" {
|
||||
t.Errorf("unknown code should be missing, got %q", codes.Subject["NOPE"])
|
||||
}
|
||||
|
||||
if _, err := st.SetCode(ctx, "teacher", "KNI", "Mr Smith"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
again, err := st.Codes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("codes again: %v", err)
|
||||
}
|
||||
if again.Teacher["KNI"] != "Mr Smith" {
|
||||
t.Errorf("KNI = %q, want the correction to survive the re-seed", again.Teacher["KNI"])
|
||||
}
|
||||
|
||||
if _, err := st.SetCode(ctx, "bogus", "X", "Y"); err == nil {
|
||||
t.Error("an unknown kind should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
263
internal/store/codes.go
Normal file
263
internal/store/codes.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file owns the editable decode map (AGENTS.md §4 FR-17, §7): the raw
|
||||
// Compass subject, room, teacher and period codes and the plain-English names
|
||||
// they render as. The map is data because the codes change between years and
|
||||
// new subjects appear each term; the seed below only fills gaps, so a
|
||||
// correction Sam makes in the dashboard is never overwritten.
|
||||
|
||||
// Code kinds stored in the codes table.
|
||||
const (
|
||||
CodeSubject = "subject"
|
||||
CodeRoom = "room"
|
||||
CodeTeacher = "teacher"
|
||||
CodePeriod = "period"
|
||||
)
|
||||
|
||||
// CodeMap is the whole decode table, grouped by kind. A missing code is not an
|
||||
// error: the renderer falls back to the raw code (never blank).
|
||||
type CodeMap struct {
|
||||
Subject map[string]string
|
||||
Room map[string]string
|
||||
Teacher map[string]string
|
||||
Period map[string]string
|
||||
}
|
||||
|
||||
// Lookup returns the mapped name for one kind and code, or "" when the code is
|
||||
// not mapped.
|
||||
func (m CodeMap) Lookup(kind, code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
switch kind {
|
||||
case CodeSubject:
|
||||
return m.Subject[code]
|
||||
case CodeRoom:
|
||||
return m.Room[code]
|
||||
case CodeTeacher:
|
||||
return m.Teacher[code]
|
||||
case CodePeriod:
|
||||
return m.Period[code]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// seedCodes are the mappings the project ships with. Subjects use the school's
|
||||
// own names; rooms are "Room <code>"; teacher entries are placeholders because
|
||||
// Compass only supplies initials, so Sam replaces them with surnames.
|
||||
var seedCodes = []struct {
|
||||
Kind string
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
// Subjects seen in the Phase 2/4 captures (AGENTS.md §7 seed table plus the
|
||||
// real captures).
|
||||
{CodeSubject, "7SCF", "Year 7 Science"},
|
||||
{CodeSubject, "7DESF", "Year 7 Design"},
|
||||
{CodeSubject, "7TUTF", "Year 7 Tutorial"},
|
||||
{CodeSubject, "7ENF", "Year 7 English"},
|
||||
{CodeSubject, "7PEF", "Year 7 Physical Education"},
|
||||
{CodeSubject, "7DRAF", "Year 7 Drama"},
|
||||
{CodeSubject, "7HU", "Year 7 Humanities"},
|
||||
{CodeSubject, "7EN", "Year 7 English"},
|
||||
{CodeSubject, "7PE", "Year 7 Physical Education"},
|
||||
{CodeSubject, "7CHI", "Year 7 Chinese"},
|
||||
{CodeSubject, "11MGMAC2", "Year 11 General Maths"},
|
||||
{CodeSubject, "11XMUSA6", "Year 11 Music"},
|
||||
{CodeSubject, "11SBIOA1", "Year 11 Biology"},
|
||||
{CodeSubject, "11HLEGB5", "Year 11 Legal Studies"},
|
||||
{CodeSubject, "11AACPA4", "Year 11 Art Creative Practice"},
|
||||
{CodeSubject, "11EENGB3", "Year 11 English"},
|
||||
{CodeSubject, "11TUTB", "Year 11 Tutorial"},
|
||||
{CodeSubject, "11MGMA", "Year 11 General Maths"},
|
||||
{CodeSubject, "11XMUS", "Year 11 Music"},
|
||||
{CodeSubject, "11SBIO", "Year 11 Biology"},
|
||||
{CodeSubject, "11HLEG", "Year 11 Legal Studies"},
|
||||
{CodeSubject, "11EENG", "Year 11 English"},
|
||||
{CodeSubject, "11AACP", "Year 11 Art Creative Practice"},
|
||||
|
||||
// Rooms seen in the captures (AGENTS.md §7: B11 / M6 / A1 read as
|
||||
// "Room B11" / "Room M6" / "Room A1").
|
||||
{CodeRoom, "H14LAB", "Room H14LAB"},
|
||||
{CodeRoom, "B11", "Room B11"},
|
||||
{CodeRoom, "M6", "Room M6"},
|
||||
{CodeRoom, "A1", "Room A1"},
|
||||
{CodeRoom, "G7", "Room G7"},
|
||||
{CodeRoom, "B1", "Room B1"},
|
||||
{CodeRoom, "C1", "Room C1"},
|
||||
{CodeRoom, "H3", "Room H3"},
|
||||
{CodeRoom, "M4", "Room M4"},
|
||||
{CodeRoom, "D8", "Room D8"},
|
||||
{CodeRoom, "IM1", "Room IM1"},
|
||||
{CodeRoom, "IM3", "Room IM3"},
|
||||
{CodeRoom, "IM5", "Room IM5"},
|
||||
{CodeRoom, "B13", "Room B13"},
|
||||
{CodeRoom, "GYM4", "Gym 4"},
|
||||
{CodeRoom, "THEATRE", "Theatre"},
|
||||
{CodeRoom, "HALL", "Hall"},
|
||||
{CodeRoom, "ENS", "Ensemble Room"},
|
||||
|
||||
// Teacher initials. Compass gives only initials, so these are placeholders
|
||||
// in the AGENTS.md "Mr GT" form; Sam replaces them with surnames.
|
||||
{CodeTeacher, "KNI", "Mr KNI"},
|
||||
{CodeTeacher, "PSI", "Mr PSI"},
|
||||
{CodeTeacher, "KDI", "Mr KDI"},
|
||||
{CodeTeacher, "AFR", "Mr AFR"},
|
||||
{CodeTeacher, "LHT", "Mr LHT"},
|
||||
{CodeTeacher, "RLE", "Mr RLE"},
|
||||
{CodeTeacher, "JBR", "Mr JBR"},
|
||||
{CodeTeacher, "GT", "Mr GT"},
|
||||
{CodeTeacher, "ANE", "Mr ANE"},
|
||||
{CodeTeacher, "BDR", "Mr BDR"},
|
||||
{CodeTeacher, "CHA", "Mr CHA"},
|
||||
{CodeTeacher, "HZH", "Mr HZH"},
|
||||
{CodeTeacher, "SLW", "Mr SLW"},
|
||||
{CodeTeacher, "KWO", "Mr KWO"},
|
||||
{CodeTeacher, "HHL", "Mr HHL"},
|
||||
{CodeTeacher, "OFG", "Mr OFG"},
|
||||
{CodeTeacher, "DCA", "Mr DCA"},
|
||||
{CodeTeacher, "LTA", "Mr LTA"},
|
||||
{CodeTeacher, "KSH", "Mr KSH"},
|
||||
{CodeTeacher, "CFR", "Mr CFR"},
|
||||
{CodeTeacher, "CMA", "Mr CMA"},
|
||||
{CodeTeacher, "KSM", "Mr KSM"},
|
||||
|
||||
// Period labels that are words rather than numbers (AGENTS.md §7: C and
|
||||
// "RLE GROUP D" are tutorials, Connect or assembly). A number is rendered
|
||||
// as "Period N" without a table entry; anything else falls back to raw.
|
||||
{CodePeriod, "C", "Connect"},
|
||||
{CodePeriod, "RLE GROUP D", "RLE Group D"},
|
||||
}
|
||||
|
||||
// SeedCodes inserts the shipped mappings that are not present yet. It is
|
||||
// idempotent and never overwrites a value, so Sam's corrections survive every
|
||||
// start and every scan.
|
||||
func (s *Store) SeedCodes(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed codes: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, c := range seedCodes {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO codes (kind, code, name) VALUES (?, ?, ?)
|
||||
ON CONFLICT(kind, code) DO NOTHING`,
|
||||
c.Kind, c.Code, c.Name); err != nil {
|
||||
return fmt.Errorf("seed code %s %q: %w", c.Kind, c.Code, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("seed codes: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Codes returns the whole decode map. It seeds the shipped defaults first, so a
|
||||
// fresh database renders the known captures and a corrected row is kept.
|
||||
func (s *Store) Codes(ctx context.Context) (CodeMap, error) {
|
||||
if err := s.SeedCodes(ctx); err != nil {
|
||||
return CodeMap{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT kind, code, name FROM codes`)
|
||||
if err != nil {
|
||||
return CodeMap{}, fmt.Errorf("codes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := CodeMap{
|
||||
Subject: map[string]string{},
|
||||
Room: map[string]string{},
|
||||
Teacher: map[string]string{},
|
||||
Period: map[string]string{},
|
||||
}
|
||||
for rows.Next() {
|
||||
var kind, code, name string
|
||||
if err := rows.Scan(&kind, &code, &name); err != nil {
|
||||
return CodeMap{}, fmt.Errorf("scan code: %w", err)
|
||||
}
|
||||
switch kind {
|
||||
case CodeSubject:
|
||||
out.Subject[code] = name
|
||||
case CodeRoom:
|
||||
out.Room[code] = name
|
||||
case CodeTeacher:
|
||||
out.Teacher[code] = name
|
||||
case CodePeriod:
|
||||
out.Period[code] = name
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Code is one row of the decode map, for the `school codes` CLI.
|
||||
type Code struct {
|
||||
Kind string `json:"kind"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListCodes returns every mapping, ordered by kind then code.
|
||||
func (s *Store) ListCodes(ctx context.Context) ([]Code, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT kind, code, name FROM codes ORDER BY kind, code`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list codes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Code
|
||||
for rows.Next() {
|
||||
var c Code
|
||||
if err := rows.Scan(&c.Kind, &c.Code, &c.Name); err != nil {
|
||||
return nil, fmt.Errorf("scan code: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetCode creates or corrects one mapping and returns the row. It is how Sam
|
||||
// edits the decode map (FR-17).
|
||||
func (s *Store) SetCode(ctx context.Context, kind, code, name string) (Code, error) {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
code = strings.TrimSpace(code)
|
||||
name = strings.TrimSpace(name)
|
||||
switch kind {
|
||||
case CodeSubject, CodeRoom, CodeTeacher, CodePeriod:
|
||||
default:
|
||||
return Code{}, fmt.Errorf("unknown code kind %q (want subject, room, teacher or period)", kind)
|
||||
}
|
||||
if code == "" {
|
||||
return Code{}, fmt.Errorf("code is required")
|
||||
}
|
||||
if name == "" {
|
||||
return Code{}, fmt.Errorf("name is required (an empty mapping renders blank)")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO codes (kind, code, name, updated_at) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(kind, code) DO UPDATE SET name = excluded.name, updated_at = datetime('now')`,
|
||||
kind, code, name); err != nil {
|
||||
return Code{}, fmt.Errorf("set code %s %q: %w", kind, code, err)
|
||||
}
|
||||
return Code{Kind: kind, Code: code, Name: name}, nil
|
||||
}
|
||||
|
||||
// DeleteCode removes one mapping, so the code falls back to raw. It reports
|
||||
// whether a row existed.
|
||||
func (s *Store) DeleteCode(ctx context.Context, kind, code string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM codes WHERE kind = ? AND code = ?`,
|
||||
strings.ToLower(strings.TrimSpace(kind)), strings.TrimSpace(code))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete code: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
19
internal/store/migrations/005_codes.sql
Normal file
19
internal/store/migrations/005_codes.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration 005 — the editable decode map (AGENTS.md §4 FR-17, §7).
|
||||
--
|
||||
-- Compass writes a lesson as "[period] - [subject code] - [room code] -
|
||||
-- [teacher initials]", for example "1 - 7SCF - H14LAB - KNI". The brief must
|
||||
-- read that as plain English ("Period 1 — Year 7 Science — Room H14LAB — Mr
|
||||
-- KNI"), so the mapping is data, not code. School codes change between years,
|
||||
-- so the table is editable and the seed only fills the gaps.
|
||||
--
|
||||
-- A code that is not in the table renders as the raw Compass code, never blank.
|
||||
CREATE TABLE IF NOT EXISTS codes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('subject', 'room', 'teacher', 'period')),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (kind, code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_codes_kind ON codes(kind, code);
|
||||
@@ -375,6 +375,40 @@ func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID in
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user