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).
111 lines
3.3 KiB
Go
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
|
|
}
|