Files
projax/web/per.go
mAi e055e4607e feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.

store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
  upserts with COALESCE(new, old) for note + event_date so partial
  callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.

web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
  Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
  DatedLinks output and assigns render-time collision tags inside each
  date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
  resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
  date-sorted rows with computed PER, ref-type badge, remove × with
  anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
  fragment, redirect for non-HTMX callers.

mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
  fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.

docs:
- docs/standards/per.md gains an Implementation section pointing at
  item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
  schema delta, conflict policy, and URL routing rules.

tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
  length); collisionTag (1..53); computePERs (bare-then-.a, skips
  undated, multi-date grouping).
2026-05-15 18:35:21 +02:00

111 lines
3.3 KiB
Go

package web
import (
"strconv"
"strings"
"time"
"github.com/m/projax/store"
)
// parsePER strips a trailing YYMMDD segment off the path and returns the
// shorter path + the parsed date. If the last segment doesn't match the
// 6-digit form, the input is returned unchanged with a nil date. We
// deliberately ignore collision-tag suffixes (`.a`/`.b`) at v0.1 — collision
// is a display concern only per the PER standard, so a URL ending in `.a`
// just won't strip and will 404 on its own.
func parsePER(path string) (basePath string, eventDate *time.Time) {
idx := strings.LastIndex(path, ".")
if idx < 0 || idx == len(path)-1 {
return path, nil
}
last := path[idx+1:]
if len(last) != 6 {
return path, nil
}
for _, c := range last {
if c < '0' || c > '9' {
return path, nil
}
}
yy, _ := strconv.Atoi(last[0:2])
mm, _ := strconv.Atoi(last[2:4])
dd, _ := strconv.Atoi(last[4:6])
// YY → 20YY (PER spec applies to m's century).
t := time.Date(2000+yy, time.Month(mm), dd, 0, 0, 0, 0, time.UTC)
// Reject impossible dates: time.Date normalises (e.g. Feb 30 → Mar 2),
// so a round-trip mismatch signals "not a real date".
if t.Year() != 2000+yy || int(t.Month()) != mm || t.Day() != dd {
return path, nil
}
return path[:idx], &t
}
// formatPERDate is the inverse of the YYMMDD slice of parsePER. Used for
// rendering computed PERs in the Documents section.
func formatPERDate(t time.Time) string {
return t.UTC().Format("060102")
}
// computePERs annotates each dated link with the canonical PER under which
// it should display, including a collision tag (`.a`/`.b`/…) when multiple
// links share the same `event_date`. Inputs must already be ordered by
// (event_date DESC, created_at ASC) — matches store.DatedLinks output. The
// tag is render-time only per the PER v0.1 spec; we never store it.
type DocumentRow struct {
Link *store.ItemLink
PER string // basePath + . + YYMMDD + optional .a/.b
Tag string // "" | "a" | "b" | …
}
func computePERs(basePath string, links []*store.ItemLink) []DocumentRow {
// Group by date so we can assign collision tags inside each group.
type group struct {
date time.Time
links []*store.ItemLink
}
groups := []group{}
for _, l := range links {
if l.EventDate == nil {
continue
}
d := *l.EventDate
// New group when date changes (input is already sorted by event_date DESC).
if len(groups) == 0 || !groups[len(groups)-1].date.Equal(d) {
groups = append(groups, group{date: d})
}
groups[len(groups)-1].links = append(groups[len(groups)-1].links, l)
}
out := make([]DocumentRow, 0, len(links))
for _, g := range groups {
// Within a date, the first link is bare; the rest get .a, .b, …
// (input is sorted by created_at ASC within the same date).
for i, l := range g.links {
row := DocumentRow{Link: l, PER: basePath + "." + formatPERDate(g.date)}
if i > 0 {
tag := collisionTag(i)
row.Tag = tag
row.PER = row.PER + "." + tag
}
out = append(out, row)
}
}
return out
}
// collisionTag returns the alpha-only suffix for the n-th colliding link
// (1-indexed: 1→"a", 2→"b", …, 26→"z", 27→"aa", 28→"ab", …). Matches the
// rule documented in docs/standards/per.md §"Collision handling".
func collisionTag(n int) string {
if n <= 0 {
return ""
}
out := ""
for n > 0 {
n--
out = string(rune('a'+(n%26))) + out
n /= 26
}
return out
}