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 }