guards: turn the AGENTS.md rules into tests, and obey them
Rules enforced by memory are rules that get forgotten, so each one in AGENTS.md that a test can check now has one: the 300-line cap, the sessions.UserID ban, no cgo SQLite driver, every --rst-* token we use being declared by a vendored stylesheet, this app's CSS not claiming another namespace's prefix, and the seven class-only utilities never being written in attribute position. The utilities guard earned its place on the first run: event.html had <span rst-grow>, which is one of the seven with no attribute twin, so it silently did nothing and that reminder row would not have expanded. Three files were over the cap, so they were split before the guard was written rather than the guard being softened to fit what existed: handlers_events into events/form/delete, ics_import into import/parse, store_event into write/read. Two guards are about the guards. TestEveryLineCapExemptionIsLive fails on an exemption naming a file that no longer exists, and the token test refuses to run if it parsed fewer than twenty tokens — sheets shipped a walk that skipped vendor/ and therefore proved nothing at all, and a guard that silently checks nothing is worse than no guard. sessions.UserID is found by an AST walk rather than a grep, so the comment explaining the rule does not need an exemption from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
10 files changed,
+613
−318
.impeccable/hook.cache.json+1 −1internal/calendar/handlers_event_delete.go+79 −0internal/calendar/handlers_event_form.go+106 −0internal/calendar/handlers_events.go+0 −170internal/calendar/ics_import.go+0 −90internal/calendar/ics_parse.go+101 −0internal/calendar/store_event.go+0 −56internal/calendar/store_event_read.go+68 −0internal/calendar/templates/event.html+4 −1internal/guardstest/guards_test.go+254 −0
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json| index 8efc281..c6734fa 100644 |
| --- a/.impeccable/hook.cache.json |
| +++ b/.impeccable/hook.cache.json |
| @@ -1 +1 @@ |
| -{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788307346881,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
| +{"version":1,"sessions":{"f915c070-9e97-443d-986d-1eacbb36518a":{"updatedAt":1788307905253,"files":{"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/static/calendar.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/layout.html":{"editCount":4,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/_chip.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/day.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/week.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/month.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/agenda.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_form.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event.html":{"editCount":3,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/event_delete.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/calendars.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/share.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/import_done.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/oficina/calendar/.claude/worktrees/calendar-first-screens/internal/calendar/templates/settings.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
diff --git a/internal/calendar/handlers_event_delete.go b/internal/calendar/handlers_event_delete.go| new file mode 100644 |
| index 0000000..6931676 |
| --- /dev/null |
| +++ b/internal/calendar/handlers_event_delete.go |
| @@ -0,0 +1,79 @@ |
| +package calendar |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// deleteEvent handles POST /events/{id}/delete. |
| +func (h *handlers) deleteEvent(w http.ResponseWriter, r *http.Request) { |
| + p, _ := CurrentPerson(r) |
| + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| + if err != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + _ = r.ParseForm() |
| + scope := r.FormValue("scope") |
| + occurrence := r.FormValue("occurrence") |
| + |
| + // "Just this one" is the default, and it is first in the form. The |
| + // destructive end of the scale — every occurrence, with its count |
| + // named — is last, so the cheap answer is never one keystroke away |
| + // from the expensive one. |
| + if scope == "this" && occurrence != "" { |
| + if err := h.store.ExcludeOccurrence(r.Context(), p.ID, id, occurrence); err != nil { |
| + h.deleteFailed(w, r, err) |
| + return |
| + } |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| + return |
| + } |
| + if err := h.store.DeleteEvent(r.Context(), p.ID, id); err != nil { |
| + h.deleteFailed(w, r, err) |
| + return |
| + } |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| +} |
| + |
| +func (h *handlers) deleteFailed(w http.ResponseWriter, r *http.Request, err error) { |
| + switch { |
| + case NotFound(err): |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + case err == ErrReadOnly: |
| + ErrorPage(w, r, http.StatusForbidden, "") |
| + default: |
| + h.fail(w, r, "delete event", err) |
| + } |
| +} |
| + |
| +// confirmDelete is the destructive action's own URL, never a modal — |
| +// so it works with no script, can be linked to, and can be backed out |
| +// of with the browser's own back button. |
| +func (h *handlers) confirmDelete(w http.ResponseWriter, r *http.Request) { |
| + p, _ := CurrentPerson(r) |
| + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| + if err != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + e, err := h.store.Event(r.Context(), p.ID, id) |
| + if NotFound(err) { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + if err != nil { |
| + h.fail(w, r, "read event", err) |
| + return |
| + } |
| + count, forever := h.store.CountOccurrences(e) |
| + render(w, "event_delete", h.page(r, "Delete", map[string]any{ |
| + "Event": e, |
| + "Occurrence": r.URL.Query().Get("occurrence"), |
| + "Series": e.RRule != "", |
| + "Count": count, |
| + "Forever": forever, |
| + })) |
| +} |
diff --git a/internal/calendar/handlers_event_form.go b/internal/calendar/handlers_event_form.go| new file mode 100644 |
| index 0000000..914a101 |
| --- /dev/null |
| +++ b/internal/calendar/handlers_event_form.go |
| @@ -0,0 +1,106 @@ |
| +package calendar |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// eventForm is the create/edit form's view data. It carries the |
| +// submitted values back on a validation failure, so a rejected form |
| +// never loses what somebody typed — the single most common way a form |
| +// wastes a person's time. |
| +type eventForm struct { |
| + Action string |
| + Title string |
| + Heading string |
| + Submit string |
| + Calendars []CalendarView |
| + |
| + EventID int64 |
| + CalendarID int64 |
| + EventTitle string |
| + Location string |
| + Notes string |
| + AllDay bool |
| + StartLocal string |
| + EndLocal string |
| + TZ string |
| + Kind string |
| + Status string |
| + |
| + Repeat string |
| + RepeatEnd string |
| + |
| + Error string |
| + ErrorField string |
| + |
| + // Series is set when editing something that recurs, so the form can |
| + // ask which occurrences the edit applies to. |
| + Series bool |
| + |
| + // Occurrence is which one is being edited, when the edit arrived |
| + // from an occurrence URL. Empty means the series itself. |
| + Occurrence string |
| +} |
| + |
| +// readEventForm parses and validates a submission, returning the form |
| +// data to re-render on failure. |
| +func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventForm, bool) { |
| + _ = r.ParseForm() |
| + calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64) |
| + allDay := r.FormValue("all_day") == "1" |
| + start := strings.TrimSpace(r.FormValue("start")) |
| + |
| + in := EventInput{ |
| + CalendarID: calID, |
| + Title: r.FormValue("title"), |
| + Location: r.FormValue("location"), |
| + Notes: r.FormValue("notes"), |
| + AllDay: allDay, |
| + StartLocal: start, |
| + EndLocal: strings.TrimSpace(r.FormValue("end")), |
| + TZ: p.Zone, |
| + Kind: r.FormValue("kind"), |
| + Status: r.FormValue("status"), |
| + RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, p.Zone, allDay), |
| + } |
| + f := eventForm{ |
| + CalendarID: calID, |
| + EventTitle: in.Title, |
| + Location: in.Location, |
| + Notes: in.Notes, |
| + AllDay: allDay, |
| + StartLocal: in.StartLocal, |
| + EndLocal: in.EndLocal, |
| + TZ: p.Zone, |
| + Kind: in.Kind, |
| + Status: in.Status, |
| + Repeat: r.FormValue("repeat"), |
| + RepeatEnd: r.FormValue("repeat_end"), |
| + Occurrence: r.FormValue("occurrence"), |
| + Series: r.FormValue("scope") != "", |
| + } |
| + if err := ValidateEventInput(&in); err != nil { |
| + if ve, ok := Invalid(err); ok { |
| + f.Error, f.ErrorField = ve.Message, ve.Field |
| + } else { |
| + f.Error = "That did not work." |
| + } |
| + return in, f, false |
| + } |
| + return in, f, true |
| +} |
| + |
| +// writable is the calendars somebody may actually create an event on. |
| +// A dropdown offering a calendar the server would refuse is a dropdown |
| +// that teaches people the app is broken. |
| +func writable(cals []CalendarView) []CalendarView { |
| + out := make([]CalendarView, 0, len(cals)) |
| + for _, c := range cals { |
| + if CanWrite(c.Role) { |
| + out = append(out, c) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go| index 5fa899e..b386eb9 100644 |
| --- a/internal/calendar/handlers_events.go |
| +++ b/internal/calendar/handlers_events.go |
| @@ -9,44 +9,6 @@ import ( |
| "github.com/go-chi/chi/v5" |
| ) |
| -// eventForm is the create/edit form's view data. It carries the |
| -// submitted values back on a validation failure, so a rejected form |
| -// never loses what somebody typed — the single most common way a form |
| -// wastes a person's time. |
| -type eventForm struct { |
| - Action string |
| - Title string |
| - Heading string |
| - Submit string |
| - Calendars []CalendarView |
| - |
| - EventID int64 |
| - CalendarID int64 |
| - EventTitle string |
| - Location string |
| - Notes string |
| - AllDay bool |
| - StartLocal string |
| - EndLocal string |
| - TZ string |
| - Kind string |
| - Status string |
| - |
| - Repeat string |
| - RepeatEnd string |
| - |
| - Error string |
| - ErrorField string |
| - |
| - // Series is set when editing something that recurs, so the form can |
| - // ask which occurrences the edit applies to. |
| - Series bool |
| - |
| - // Occurrence is which one is being edited, when the edit arrived |
| - // from an occurrence URL. Empty means the series itself. |
| - Occurrence string |
| -} |
| - |
| // newEvent renders the create form, prefilled from the query so that a |
| // click on an empty slot arrives with its time already set. |
| func (h *handlers) newEvent(w http.ResponseWriter, r *http.Request) { |
| @@ -275,135 +237,3 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther) |
| } |
| } |
| - |
| -// deleteEvent handles POST /events/{id}/delete. |
| -func (h *handlers) deleteEvent(w http.ResponseWriter, r *http.Request) { |
| - p, _ := CurrentPerson(r) |
| - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| - if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| - return |
| - } |
| - _ = r.ParseForm() |
| - scope := r.FormValue("scope") |
| - occurrence := r.FormValue("occurrence") |
| - |
| - // "Just this one" is the default, and it is first in the form. The |
| - // destructive end of the scale — every occurrence, with its count |
| - // named — is last, so the cheap answer is never one keystroke away |
| - // from the expensive one. |
| - if scope == "this" && occurrence != "" { |
| - if err := h.store.ExcludeOccurrence(r.Context(), p.ID, id, occurrence); err != nil { |
| - h.deleteFailed(w, r, err) |
| - return |
| - } |
| - http.Redirect(w, r, "/", http.StatusSeeOther) |
| - return |
| - } |
| - if err := h.store.DeleteEvent(r.Context(), p.ID, id); err != nil { |
| - h.deleteFailed(w, r, err) |
| - return |
| - } |
| - http.Redirect(w, r, "/", http.StatusSeeOther) |
| -} |
| - |
| -func (h *handlers) deleteFailed(w http.ResponseWriter, r *http.Request, err error) { |
| - switch { |
| - case NotFound(err): |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| - case err == ErrReadOnly: |
| - ErrorPage(w, r, http.StatusForbidden, "") |
| - default: |
| - h.fail(w, r, "delete event", err) |
| - } |
| -} |
| - |
| -// confirmDelete is the destructive action's own URL, never a modal — |
| -// so it works with no script, can be linked to, and can be backed out |
| -// of with the browser's own back button. |
| -func (h *handlers) confirmDelete(w http.ResponseWriter, r *http.Request) { |
| - p, _ := CurrentPerson(r) |
| - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| - if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| - return |
| - } |
| - e, err := h.store.Event(r.Context(), p.ID, id) |
| - if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| - return |
| - } |
| - if err != nil { |
| - h.fail(w, r, "read event", err) |
| - return |
| - } |
| - count, forever := h.store.CountOccurrences(e) |
| - render(w, "event_delete", h.page(r, "Delete", map[string]any{ |
| - "Event": e, |
| - "Occurrence": r.URL.Query().Get("occurrence"), |
| - "Series": e.RRule != "", |
| - "Count": count, |
| - "Forever": forever, |
| - })) |
| -} |
| - |
| -// readEventForm parses and validates a submission, returning the form |
| -// data to re-render on failure. |
| -func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventForm, bool) { |
| - _ = r.ParseForm() |
| - calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64) |
| - allDay := r.FormValue("all_day") == "1" |
| - start := strings.TrimSpace(r.FormValue("start")) |
| - |
| - in := EventInput{ |
| - CalendarID: calID, |
| - Title: r.FormValue("title"), |
| - Location: r.FormValue("location"), |
| - Notes: r.FormValue("notes"), |
| - AllDay: allDay, |
| - StartLocal: start, |
| - EndLocal: strings.TrimSpace(r.FormValue("end")), |
| - TZ: p.Zone, |
| - Kind: r.FormValue("kind"), |
| - Status: r.FormValue("status"), |
| - RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, p.Zone, allDay), |
| - } |
| - f := eventForm{ |
| - CalendarID: calID, |
| - EventTitle: in.Title, |
| - Location: in.Location, |
| - Notes: in.Notes, |
| - AllDay: allDay, |
| - StartLocal: in.StartLocal, |
| - EndLocal: in.EndLocal, |
| - TZ: p.Zone, |
| - Kind: in.Kind, |
| - Status: in.Status, |
| - Repeat: r.FormValue("repeat"), |
| - RepeatEnd: r.FormValue("repeat_end"), |
| - Occurrence: r.FormValue("occurrence"), |
| - Series: r.FormValue("scope") != "", |
| - } |
| - if err := ValidateEventInput(&in); err != nil { |
| - if ve, ok := Invalid(err); ok { |
| - f.Error, f.ErrorField = ve.Message, ve.Field |
| - } else { |
| - f.Error = "That did not work." |
| - } |
| - return in, f, false |
| - } |
| - return in, f, true |
| -} |
| - |
| -// writable is the calendars somebody may actually create an event on. |
| -// A dropdown offering a calendar the server would refuse is a dropdown |
| -// that teaches people the app is broken. |
| -func writable(cals []CalendarView) []CalendarView { |
| - out := make([]CalendarView, 0, len(cals)) |
| - for _, c := range cals { |
| - if CanWrite(c.Role) { |
| - out = append(out, c) |
| - } |
| - } |
| - return out |
| -} |
diff --git a/internal/calendar/ics_import.go b/internal/calendar/ics_import.go| index cbad614..a8cf774 100644 |
| --- a/internal/calendar/ics_import.go |
| +++ b/internal/calendar/ics_import.go |
| @@ -267,93 +267,3 @@ func (e *icsEvent) impliedEnd(zone string) string { |
| return e.start |
| } |
| } |
| - |
| -// splitLine breaks "DTSTART;TZID=Europe/Dublin:20260902T090000" into |
| -// its name, parameters and value. |
| -func splitLine(s string) (name, params, value string) { |
| - colon := strings.Index(s, ":") |
| - if colon < 0 { |
| - return strings.ToUpper(strings.TrimSpace(s)), "", "" |
| - } |
| - head, value := s[:colon], s[colon+1:] |
| - if semi := strings.Index(head, ";"); semi >= 0 { |
| - return strings.ToUpper(head[:semi]), head[semi+1:], value |
| - } |
| - return strings.ToUpper(head), "", value |
| -} |
| - |
| -func tzidOf(params string) string { |
| - for _, p := range strings.Split(params, ";") { |
| - if k, v, ok := strings.Cut(p, "="); ok && strings.EqualFold(k, "TZID") { |
| - return strings.Trim(strings.TrimSpace(v), `"`) |
| - } |
| - } |
| - return "" |
| -} |
| - |
| -// parseICSTime reads the three forms a DTSTART can take, and reports |
| -// whether it was a date. |
| -func parseICSTime(v, params string) (local string, allDay bool, ok bool) { |
| - v = strings.TrimSpace(v) |
| - if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(v) == 8 { |
| - if t, err := time.Parse("20060102", v); err == nil { |
| - return t.Format(LayoutDate), true, true |
| - } |
| - } |
| - // A trailing Z is UTC. The wall time is kept as written and the |
| - // zone recorded separately, so the record stays "what was said". |
| - if strings.HasSuffix(v, "Z") { |
| - if t, err := time.Parse("20060102T150405Z", v); err == nil { |
| - return t.Format(LayoutLocal), false, true |
| - } |
| - } |
| - if t, err := time.Parse("20060102T150405", v); err == nil { |
| - return t.Format(LayoutLocal), false, true |
| - } |
| - return "", false, false |
| -} |
| - |
| -// parseDurationMinutes reads the subset of ISO 8601 durations a |
| -// calendar actually emits. |
| -func parseDurationMinutes(v string) int { |
| - v = strings.TrimSpace(strings.ToUpper(v)) |
| - v = strings.TrimPrefix(v, "P") |
| - var total, n int |
| - timePart := false |
| - for _, c := range v { |
| - switch { |
| - case c >= '0' && c <= '9': |
| - n = n*10 + int(c-'0') |
| - case c == 'T': |
| - timePart = true |
| - n = 0 |
| - case c == 'W': |
| - total += n * 7 * 24 * 60 |
| - n = 0 |
| - case c == 'D': |
| - total += n * 24 * 60 |
| - n = 0 |
| - case c == 'H': |
| - total += n * 60 |
| - n = 0 |
| - case c == 'M' && timePart: |
| - total += n |
| - n = 0 |
| - default: |
| - n = 0 |
| - } |
| - } |
| - return total |
| -} |
| - |
| -// unescapeText reverses the RFC's TEXT escaping. Backslash LAST, the |
| -// mirror of escapeText's first — otherwise "\\n" (an escaped |
| -// backslash followed by an n) becomes a newline. |
| -func unescapeText(s string) string { |
| - s = strings.ReplaceAll(s, `\n`, "\n") |
| - s = strings.ReplaceAll(s, `\N`, "\n") |
| - s = strings.ReplaceAll(s, `\,`, ",") |
| - s = strings.ReplaceAll(s, `\;`, ";") |
| - s = strings.ReplaceAll(s, `\\`, `\`) |
| - return s |
| -} |
diff --git a/internal/calendar/ics_parse.go b/internal/calendar/ics_parse.go| new file mode 100644 |
| index 0000000..a9565d7 |
| --- /dev/null |
| +++ b/internal/calendar/ics_parse.go |
| @@ -0,0 +1,101 @@ |
| +package calendar |
| + |
| +import ( |
| + "strings" |
| + "time" |
| +) |
| + |
| +// The low-level readers: one content line into name, parameters and |
| +// value, and the handful of value formats iCalendar uses. Split from |
| +// ics_import.go so neither file passes the 300-line cap, and because |
| +// these are the pieces with no knowledge of what a VEVENT is. |
| + |
| +// splitLine breaks "DTSTART;TZID=Europe/Dublin:20260902T090000" into |
| +// its name, parameters and value. |
| +func splitLine(s string) (name, params, value string) { |
| + colon := strings.Index(s, ":") |
| + if colon < 0 { |
| + return strings.ToUpper(strings.TrimSpace(s)), "", "" |
| + } |
| + head, value := s[:colon], s[colon+1:] |
| + if semi := strings.Index(head, ";"); semi >= 0 { |
| + return strings.ToUpper(head[:semi]), head[semi+1:], value |
| + } |
| + return strings.ToUpper(head), "", value |
| +} |
| + |
| +func tzidOf(params string) string { |
| + for _, p := range strings.Split(params, ";") { |
| + if k, v, ok := strings.Cut(p, "="); ok && strings.EqualFold(k, "TZID") { |
| + return strings.Trim(strings.TrimSpace(v), `"`) |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// parseICSTime reads the three forms a DTSTART can take, and reports |
| +// whether it was a date. |
| +func parseICSTime(v, params string) (local string, allDay bool, ok bool) { |
| + v = strings.TrimSpace(v) |
| + if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(v) == 8 { |
| + if t, err := time.Parse("20060102", v); err == nil { |
| + return t.Format(LayoutDate), true, true |
| + } |
| + } |
| + // A trailing Z is UTC. The wall time is kept as written and the |
| + // zone recorded separately, so the record stays "what was said". |
| + if strings.HasSuffix(v, "Z") { |
| + if t, err := time.Parse("20060102T150405Z", v); err == nil { |
| + return t.Format(LayoutLocal), false, true |
| + } |
| + } |
| + if t, err := time.Parse("20060102T150405", v); err == nil { |
| + return t.Format(LayoutLocal), false, true |
| + } |
| + return "", false, false |
| +} |
| + |
| +// parseDurationMinutes reads the subset of ISO 8601 durations a |
| +// calendar actually emits. |
| +func parseDurationMinutes(v string) int { |
| + v = strings.TrimSpace(strings.ToUpper(v)) |
| + v = strings.TrimPrefix(v, "P") |
| + var total, n int |
| + timePart := false |
| + for _, c := range v { |
| + switch { |
| + case c >= '0' && c <= '9': |
| + n = n*10 + int(c-'0') |
| + case c == 'T': |
| + timePart = true |
| + n = 0 |
| + case c == 'W': |
| + total += n * 7 * 24 * 60 |
| + n = 0 |
| + case c == 'D': |
| + total += n * 24 * 60 |
| + n = 0 |
| + case c == 'H': |
| + total += n * 60 |
| + n = 0 |
| + case c == 'M' && timePart: |
| + total += n |
| + n = 0 |
| + default: |
| + n = 0 |
| + } |
| + } |
| + return total |
| +} |
| + |
| +// unescapeText reverses the RFC's TEXT escaping. Backslash LAST, the |
| +// mirror of escapeText's first — otherwise "\\n" (an escaped |
| +// backslash followed by an n) becomes a newline. |
| +func unescapeText(s string) string { |
| + s = strings.ReplaceAll(s, `\n`, "\n") |
| + s = strings.ReplaceAll(s, `\N`, "\n") |
| + s = strings.ReplaceAll(s, `\,`, ",") |
| + s = strings.ReplaceAll(s, `\;`, ";") |
| + s = strings.ReplaceAll(s, `\\`, `\`) |
| + return s |
| +} |
diff --git a/internal/calendar/store_event.go b/internal/calendar/store_event.go| index 243f580..ed92260 100644 |
| --- a/internal/calendar/store_event.go |
| +++ b/internal/calendar/store_event.go |
| @@ -160,32 +160,6 @@ func (s *Store) buildEvent(personID int64, in EventInput) (Event, error) { |
| return e, nil |
| } |
| -// Event reads one event, scoped through a grant on its calendar. |
| -func (s *Store) Event(ctx context.Context, personID, eventID int64) (EventView, error) { |
| - type row struct { |
| - Event |
| - Role string |
| - CalendarName []byte |
| - CalendarHue int |
| - } |
| - var r row |
| - err := s.g.WithContext(ctx). |
| - Model(&Event{}). |
| - Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue"). |
| - Joins("JOIN calendars ON calendars.id = events.calendar_id"). |
| - Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID). |
| - Where("events.id = ? AND events.deleted_at IS NULL", eventID). |
| - Limit(1). |
| - Scan(&r).Error |
| - if err != nil { |
| - return EventView{}, fmt.Errorf("calendar: read event: %w", err) |
| - } |
| - if r.Event.ID == 0 { |
| - return EventView{}, ErrNotFound |
| - } |
| - return s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue) |
| -} |
| - |
| // UpdateEvent replaces the editable fields of an existing event. |
| func (s *Store) UpdateEvent(ctx context.Context, personID, eventID int64, in EventInput) error { |
| existing, err := s.Event(ctx, personID, eventID) |
| @@ -279,36 +253,6 @@ func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error |
| }) |
| } |
| -func (s *Store) openEvent(e Event, role string, calName []byte, hue int) (EventView, error) { |
| - title, err := s.seal.OpenString(seal.PurposeEventTitle, e.TitleSealed) |
| - if err != nil { |
| - return EventView{}, fmt.Errorf("calendar: open title (event %d): %w", e.ID, err) |
| - } |
| - loc, err := s.seal.OpenString(seal.PurposeEventLocation, e.LocationSealed) |
| - if err != nil { |
| - return EventView{}, fmt.Errorf("calendar: open location (event %d): %w", e.ID, err) |
| - } |
| - notes, err := s.seal.OpenString(seal.PurposeEventNotes, e.NotesSealed) |
| - if err != nil { |
| - return EventView{}, fmt.Errorf("calendar: open notes (event %d): %w", e.ID, err) |
| - } |
| - name, err := s.seal.OpenString(seal.PurposeCalendarName, calName) |
| - if err != nil { |
| - return EventView{}, fmt.Errorf("calendar: open calendar name (event %d): %w", e.ID, err) |
| - } |
| - return EventView{ |
| - Event: e, |
| - Title: title, |
| - Location: loc, |
| - Notes: notes, |
| - OccStartUTC: e.StartUTC, |
| - OccEndUTC: e.EndUTC, |
| - CalendarName: name, |
| - CalendarHue: hue, |
| - Role: role, |
| - }, nil |
| -} |
| - |
| // newUID mints an iCalendar UID. Random rather than derived from the |
| // content, because a UID must survive every edit the content makes. |
| func newUID() string { |
diff --git a/internal/calendar/store_event_read.go b/internal/calendar/store_event_read.go| new file mode 100644 |
| index 0000000..86c0848 |
| --- /dev/null |
| +++ b/internal/calendar/store_event_read.go |
| @@ -0,0 +1,68 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + |
| + "amadan.net/oficina/calendar/internal/calendar/seal" |
| +) |
| + |
| +// The read side of an event: fetching one through a grant, and opening |
| +// its sealed columns. Split from store_event.go so neither passes the |
| +// 300-line cap. |
| + |
| +// Event reads one event, scoped through a grant on its calendar. |
| +func (s *Store) Event(ctx context.Context, personID, eventID int64) (EventView, error) { |
| + type row struct { |
| + Event |
| + Role string |
| + CalendarName []byte |
| + CalendarHue int |
| + } |
| + var r row |
| + err := s.g.WithContext(ctx). |
| + Model(&Event{}). |
| + Select("events.*, grants.role as role, calendars.name_sealed as calendar_name, calendars.hue as calendar_hue"). |
| + Joins("JOIN calendars ON calendars.id = events.calendar_id"). |
| + Joins("JOIN grants ON grants.calendar_id = events.calendar_id AND grants.person_id = ?", personID). |
| + Where("events.id = ? AND events.deleted_at IS NULL", eventID). |
| + Limit(1). |
| + Scan(&r).Error |
| + if err != nil { |
| + return EventView{}, fmt.Errorf("calendar: read event: %w", err) |
| + } |
| + if r.Event.ID == 0 { |
| + return EventView{}, ErrNotFound |
| + } |
| + return s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue) |
| +} |
| + |
| +func (s *Store) openEvent(e Event, role string, calName []byte, hue int) (EventView, error) { |
| + title, err := s.seal.OpenString(seal.PurposeEventTitle, e.TitleSealed) |
| + if err != nil { |
| + return EventView{}, fmt.Errorf("calendar: open title (event %d): %w", e.ID, err) |
| + } |
| + loc, err := s.seal.OpenString(seal.PurposeEventLocation, e.LocationSealed) |
| + if err != nil { |
| + return EventView{}, fmt.Errorf("calendar: open location (event %d): %w", e.ID, err) |
| + } |
| + notes, err := s.seal.OpenString(seal.PurposeEventNotes, e.NotesSealed) |
| + if err != nil { |
| + return EventView{}, fmt.Errorf("calendar: open notes (event %d): %w", e.ID, err) |
| + } |
| + name, err := s.seal.OpenString(seal.PurposeCalendarName, calName) |
| + if err != nil { |
| + return EventView{}, fmt.Errorf("calendar: open calendar name (event %d): %w", e.ID, err) |
| + } |
| + return EventView{ |
| + Event: e, |
| + Title: title, |
| + Location: loc, |
| + Notes: notes, |
| + OccStartUTC: e.StartUTC, |
| + OccEndUTC: e.EndUTC, |
| + CalendarName: name, |
| + CalendarHue: hue, |
| + Role: role, |
| + }, nil |
| +} |
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html| index 589292b..f176e4f 100644 |
| --- a/internal/calendar/templates/event.html |
| +++ b/internal/calendar/templates/event.html |
| @@ -54,7 +54,10 @@ |
| <form method="post" action="/events/{{$.Data.Event.ID}}/reminders/remove" |
| style="display:flex;align-items:center;gap:var(--rst-sp-2)"> |
| <input type="hidden" name="reminder_id" value="{{.ID}}"> |
| - <span rst-grow>Email {{minutesLabel .OffsetMin}}</span> |
| + {{/* class, not an attribute: rst-grow is one of the seven |
| + rastrillo utilities that keep the class spelling and have no |
| + attribute twin. Written bare it silently does nothing. */}} |
| + <span class="rst-grow">Email {{minutesLabel .OffsetMin}}</span> |
| <button rst-btn="ghost compact" type="submit">Remove</button> |
| </form> |
| {{end}} |
diff --git a/internal/guardstest/guards_test.go b/internal/guardstest/guards_test.go| new file mode 100644 |
| index 0000000..f4d37d5 |
| --- /dev/null |
| +++ b/internal/guardstest/guards_test.go |
| @@ -0,0 +1,254 @@ |
| +// Package guardstest turns the prose rules in AGENTS.md into tests. |
| +// |
| +// CARLOS's own rule: "a guard a shell step can satisfy is a guard a |
| +// shell step can be wrong about — put guards inside the test binary." |
| +// Every rule here is one AGENTS.md states, and the point is that |
| +// nobody has to remember it. |
| +// |
| +// There is no production code in this package. |
| +package guardstest |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "runtime" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// repoRoot is found from this file's own path, never from the working |
| +// directory: `go test ./...` and a run from an editor have different |
| +// working directories and only one of them would be right. |
| +func repoRoot(t *testing.T) string { |
| + t.Helper() |
| + _, file, _, ok := runtime.Caller(0) |
| + if !ok { |
| + t.Fatal("runtime.Caller failed") |
| + } |
| + return filepath.Dir(filepath.Dir(filepath.Dir(file))) |
| +} |
| + |
| +// walk visits every file under root with one of the given extensions, |
| +// skipping the directories that hold nothing we wrote. |
| +func walk(t *testing.T, root string, exts []string, fn func(path string, body []byte)) { |
| + t.Helper() |
| + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if info.IsDir() { |
| + switch info.Name() { |
| + case ".git", "node_modules", "releases", ".claude", "vendor": |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + for _, ext := range exts { |
| + if strings.HasSuffix(path, ext) { |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + fn(path, b) |
| + return nil |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("walk: %v", err) |
| + } |
| +} |
| + |
| +// The 300-line cap, on everything we write. |
| +// |
| +// Exemptions are by EXACT PATH and each carries its reason. The |
| +// vendored scripts are rastrillo's, delivered by the scaffold and |
| +// pinned byte-for-byte by vendored_test.go — editing them to satisfy a |
| +// line cap would break that pin, which is the more valuable guard. |
| +var lineCapExempt = map[string]string{ |
| + "internal/calendar/static/datetime.js": "vendored from rastrillo; pinned byte-for-byte", |
| + "internal/calendar/static/rastrillo.js": "vendored from rastrillo; pinned byte-for-byte", |
| + "internal/calendar/static/select.js": "vendored from rastrillo; pinned byte-for-byte", |
| + "internal/calendar/static/tokens.css": "vendored from rastrillo; pinned byte-for-byte", |
| + "internal/calendar/static/theme.css": "vendored from rastrillo; pinned byte-for-byte", |
| +} |
| + |
| +func TestNoFileExceedsTheLineCap(t *testing.T) { |
| + const cap = 300 |
| + root := repoRoot(t) |
| + walk(t, root, []string{".go", ".js"}, func(path string, body []byte) { |
| + rel, _ := filepath.Rel(root, path) |
| + rel = filepath.ToSlash(rel) |
| + if _, ok := lineCapExempt[rel]; ok { |
| + return |
| + } |
| + if n := len(strings.Split(string(body), "\n")); n > cap { |
| + t.Errorf("%s is %d lines, cap is %d — split it", rel, n, cap) |
| + } |
| + }) |
| +} |
| + |
| +// An exemption for a file that no longer exists proves nothing, and |
| +// quietly accumulates. Sheets shipped a walk that skipped vendor/ and |
| +// therefore proved nothing at all; this is the test that catches the |
| +// equivalent here. |
| +func TestEveryLineCapExemptionIsLive(t *testing.T) { |
| + root := repoRoot(t) |
| + for rel, reason := range lineCapExempt { |
| + if _, err := os.Stat(filepath.Join(root, rel)); err != nil { |
| + t.Errorf("line-cap exemption %q (%s) names a file that does not exist", rel, reason) |
| + } |
| + } |
| +} |
| + |
| +// sessions.UserID returns (0, false) under the magic-link plugin, |
| +// because the Subject is a verified address rather than a numeric id. |
| +// Every owner-scoped query built on it silently filters to person 0 — |
| +// a calendar that looks empty rather than broken, which is the more |
| +// expensive failure. |
| +// |
| +// An AST walk rather than a grep, so this comment — which names the |
| +// call it forbids — does not need an exemption from its own rule. |
| +func TestNeverSessionsUserID(t *testing.T) { |
| + root := repoRoot(t) |
| + fset := token.NewFileSet() |
| + |
| + walk(t, root, []string{".go"}, func(path string, body []byte) { |
| + rel, _ := filepath.Rel(root, path) |
| + f, err := parser.ParseFile(fset, path, body, 0) |
| + if err != nil { |
| + return // a file that does not parse is the compiler's problem |
| + } |
| + // Only if the file actually imports the sessions package. |
| + imported := "" |
| + for _, im := range f.Imports { |
| + if strings.Trim(im.Path.Value, `"`) == "github.com/carlosframework/rastrillo/sessions" { |
| + imported = "sessions" |
| + if im.Name != nil { |
| + imported = im.Name.Name |
| + } |
| + } |
| + } |
| + if imported == "" { |
| + return |
| + } |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + sel, ok := n.(*ast.SelectorExpr) |
| + if !ok || sel.Sel.Name != "UserID" { |
| + return true |
| + } |
| + if id, ok := sel.X.(*ast.Ident); ok && id.Name == imported { |
| + t.Errorf("%s calls %s.UserID — use auth.From(r), and CurrentPerson(r) for the id", |
| + filepath.ToSlash(rel), imported) |
| + } |
| + return true |
| + }) |
| + }) |
| +} |
| + |
| +// A cgo SQLite driver would break CGO_ENABLED=0, and the failure is a |
| +// binary that will not build for the platform rather than one that |
| +// misbehaves — but it is cheap to catch here and expensive to discover |
| +// at ship time. |
| +func TestNoCgoSQLiteDriver(t *testing.T) { |
| + root := repoRoot(t) |
| + forbidden := []string{"gorm.io/driver/sqlite", "github.com/mattn/go-sqlite3", "glebarez"} |
| + walk(t, root, []string{".go", ".mod"}, func(path string, body []byte) { |
| + rel, _ := filepath.Rel(root, path) |
| + if strings.HasSuffix(rel, "guards_test.go") { |
| + return // this file names them in order to forbid them |
| + } |
| + for _, f := range forbidden { |
| + if strings.Contains(string(body), f) { |
| + t.Errorf("%s references %s — the stack is cgo-free", filepath.ToSlash(rel), f) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// Every var(--rst-*) this app's own CSS uses must actually be declared |
| +// by the vendored token or theme files. |
| +// |
| +// Written because an invented token name is invisible: the property |
| +// falls back to nothing, the element renders unstyled-ish, and nobody |
| +// notices until a screen looks slightly wrong on a canary. |
| +func TestEveryTokenWeUseIsDefined(t *testing.T) { |
| + root := repoRoot(t) |
| + static := filepath.Join(root, "internal", "calendar", "static") |
| + |
| + declared := map[string]bool{} |
| + for _, name := range []string{"tokens.css", "theme.css"} { |
| + b, err := os.ReadFile(filepath.Join(static, name)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", name, err) |
| + } |
| + for _, m := range regexp.MustCompile(`(--rst-[A-Za-z0-9-]+)\s*:`).FindAllStringSubmatch(string(b), -1) { |
| + declared[m[1]] = true |
| + } |
| + } |
| + if len(declared) < 20 { |
| + t.Fatalf("only %d tokens found in the vendored CSS; the parse is wrong, so this test proves nothing", len(declared)) |
| + } |
| + |
| + used := regexp.MustCompile(`var\((--rst-[A-Za-z0-9-]+)`) |
| + for _, name := range []string{"calendar.css"} { |
| + b, err := os.ReadFile(filepath.Join(static, name)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", name, err) |
| + } |
| + for _, m := range used.FindAllStringSubmatch(string(b), -1) { |
| + if !declared[m[1]] { |
| + t.Errorf("%s uses %s, which no vendored stylesheet declares", name, m[1]) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// This app's own components take cal-. rst- is the framework's |
| +// namespace and a future release may claim a name we took; estilo- is |
| +// the suite's, and a month grid is not a suite component until a second |
| +// app copies one. |
| +func TestOurCSSDoesNotClaimAnotherPrefix(t *testing.T) { |
| + root := repoRoot(t) |
| + b, err := os.ReadFile(filepath.Join(root, "internal", "calendar", "static", "calendar.css")) |
| + if err != nil { |
| + t.Fatalf("read calendar.css: %v", err) |
| + } |
| + // Selectors only. var(--rst-*) is a token reference and entirely |
| + // correct — this app is meant to use the framework's tokens. |
| + bad := regexp.MustCompile(`\[\s*(rst|estilo)-|\.(rst|estilo)-`) |
| + for _, m := range bad.FindAllString(string(b), -1) { |
| + t.Errorf("calendar.css defines a selector for another namespace: %q — this app's prefix is cal-", m) |
| + } |
| +} |
| + |
| +// Seven rastrillo utilities keep the class spelling and have no |
| +// attribute twin. Written in attribute position they silently do |
| +// nothing — the failure that put a visible "sr-only" label in sheets' |
| +// toolbar. |
| +func TestUtilitiesAreNeverWrittenAsAttributes(t *testing.T) { |
| + root := repoRoot(t) |
| + utilities := []string{"rst-sr-only", "rst-mono", "rst-grow", "rst-m-hide", "rst-danger", "rst-nm", "rst-cell-mut"} |
| + walk(t, filepath.Join(root, "internal", "calendar", "templates"), []string{".html"}, |
| + func(path string, body []byte) { |
| + rel, _ := filepath.Rel(root, path) |
| + for _, u := range utilities { |
| + // In attribute position it appears as a bare word |
| + // preceded by whitespace inside a tag, never as |
| + // class="...". |
| + bare := regexp.MustCompile(`<[^>]*\s` + regexp.QuoteMeta(u) + `[\s=>]`) |
| + for _, m := range bare.FindAllString(string(body), -1) { |
| + if strings.Contains(m, `class=`) { |
| + continue |
| + } |
| + t.Errorf("%s writes %s in attribute position (%q); it is a class-only utility", |
| + filepath.ToSlash(rel), u, strings.TrimSpace(m)) |
| + } |
| + } |
| + }) |
| +} |