ZDDC/zddc/internal/archive/index.go
ZDDC 9fce18cd45 feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:

* fix(archive): nested-party + folder-type cascade
  transmittalIsUnderVisibleParty short-circuited on the first matched
  party segment, only checking the immediately-next segment for a
  folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
  toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
  any-segment party match. Eight new Playwright cases pin the contract
  in tests/archive-cascade.spec.js.

* refactor(zddc-server): scope .archive index by project
  archive.Index now buckets by top-level segment
  (.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
  take a project parameter; handler extracts it from contextPath's first
  segment. /.archive/ at root returns 404 — stable refs must be
  project-rooted. Within-project (tracking, rev) collisions emit a WARN
  with both paths. Cross-project tracking-number duplicates no longer
  collide.

* perf(zddc-server): lazy-load expensive bits of the profile page
  serveProfilePage now ships a minimal shell: Email, EmailHeader,
  IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
  editable scaffolds populate client-side via /.profile/access. Subtree-
  admin scaffolds live in <template id="tmpl-subtree-admin">; pure
  non-admins receive no live admin form. ScanZddcFiles now memoized,
  invalidated on .zddc events by the watcher and writer helpers.

* feat: lockstep release + redesigned releases page
  sh build.sh --release [version|alpha|beta] is the canonical lockstep
  cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
  version. zddc-server binaries now committed under website/releases/
  with the same cascade chain as HTML tools (no more Codeberg release-
  asset publication). zddc/release.sh deprecated (kept as a guard);
  shared/publish-codeberg-release.sh removed.

  Releases page redesigned as an action-first install guide: hero +
  version dropdown that rewires every download link, channel chips for
  always-visible alpha/beta access (state-aware labels: "tracks stable"
  vs "active dev"), Path A (zddc-server with platform auto-detect from
  UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
  narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.

  Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
  resolves at the end of every build. Bootstrap-friendly: zddc-server
  artifact checks skip until the first lockstep cut anchors the chain.

Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:11:38 -05:00

389 lines
11 KiB
Go

package archive
import (
"log/slog"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
)
// RevisionEntry holds the resolved file paths for one base revision.
type RevisionEntry struct {
BasePath string // server-relative path for trackingNumber_rev.html
Modifiers map[string]string // modifier key (e.g. "C1") → server-relative path
Date string // transmittal date (YYYY-MM-DD) for first-seen logic
}
// TrackingEntry holds all revision data for one tracking number.
type TrackingEntry struct {
HighestBaseRev string // highest base revision (for trackingNumber.html)
ByRevision map[string]*RevisionEntry // base revision → entry
}
// ProjectEntry buckets all tracking numbers under one top-level segment of
// fsRoot (the "project"). Each project is its own namespace — the same
// tracking number issued under two different projects does NOT collide; each
// project's .archive/ surfaces only its own.
type ProjectEntry struct {
ByTracking map[string]*TrackingEntry
}
// Index is the in-memory archive index, bucketed by project. The project key
// is the first slash-separated segment of an indexed file's server-relative
// path. .archive virtual requests under /<project>/.../.archive/ resolve
// against the named project's bucket; /.archive/ at the very root has no
// project and returns 404.
type Index struct {
mu sync.RWMutex
ByProject map[string]*ProjectEntry
}
// NewIndex returns an empty Index.
func NewIndex() *Index {
return &Index{
ByProject: make(map[string]*ProjectEntry),
}
}
// transmittalFolderRE matches: YYYY-MM-DD_anything (anything) - anything
var transmittalFolderRE = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})_[^_\s]+\s*\([^)]+\)\s*-\s*.+$`)
// zddc filename: trackingNumber_revision (status) - title.ext
// trackingNumber: no spaces or underscores
// revision: ~?[A-Z0-9]+(+[CBNQ][0-9]+)?
var zddcFilenameRE = regexp.MustCompile(
`^([^_\s]+(?:-[^_\s]+)*)_(~?[A-Z0-9]+)(\+[CBNQ][0-9]+)?\s+\([^)]+\)\s*-\s*.+\.([^.]+)$`,
)
type parsedFile struct {
trackingNumber string
baseRev string
modifier string // empty or e.g. "C1"
date string // transmittal folder date
serverPath string // server-relative path (slash-separated, no leading slash)
}
// BuildIndex walks fsRoot, finds all transmittal folders, and builds the index.
func BuildIndex(fsRoot string) (*Index, error) {
idx := NewIndex()
if err := walkAndIndex(idx, fsRoot, fsRoot, ""); err != nil {
return nil, err
}
return idx, nil
}
// walkAndIndex recursively walks dirAbs looking for transmittal folders.
// serverDir is the server-relative path of dirAbs (slash-separated, no leading slash).
func walkAndIndex(idx *Index, fsRoot, dirAbs, serverDir string) error {
entries, err := os.ReadDir(dirAbs)
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
if !entry.IsDir() {
continue
}
var childServerDir string
if serverDir == "" {
childServerDir = name
} else {
childServerDir = serverDir + "/" + name
}
childAbs := filepath.Join(dirAbs, name)
if m := transmittalFolderRE.FindStringSubmatch(name); m != nil {
// This is a transmittal folder — index its files
date := m[1]
if err := indexTransmittalFolder(idx, fsRoot, childAbs, childServerDir, date); err != nil {
// Non-fatal: log and continue
continue
}
} else {
// Recurse into grouping/portfolio/project folders
if err := walkAndIndex(idx, fsRoot, childAbs, childServerDir); err != nil {
continue
}
}
}
return nil
}
// indexTransmittalFolder indexes all ZDDC files in a transmittal folder.
func indexTransmittalFolder(idx *Index, fsRoot, folderAbs, folderServerPath, date string) error {
return filepath.WalkDir(folderAbs, func(path string, d os.DirEntry, err error) error {
if err != nil {
// Log the error but continue indexing other files
_ = err // would log here: slog.Warn("walkdir error", "path", path, "err", err)
return nil
}
if d.IsDir() {
return nil
}
name := d.Name()
if strings.HasPrefix(name, ".") {
return nil
}
m := zddcFilenameRE.FindStringSubmatch(name)
if m == nil {
return nil
}
tracking := m[1]
baseRev := m[2]
modifierFull := m[3] // e.g. "+C1" or ""
modifier := ""
if modifierFull != "" {
modifier = modifierFull[1:] // strip leading "+"
}
// Build server-relative path
relPath, err := filepath.Rel(fsRoot, path)
if err != nil {
return nil
}
serverPath := filepath.ToSlash(relPath)
pf := parsedFile{
trackingNumber: tracking,
baseRev: baseRev,
modifier: modifier,
date: date,
serverPath: serverPath,
}
idx.recordFile(pf)
return nil
})
}
// projectOf returns the top-level slash-separated segment of a server-relative
// path. Files at the root (no slash) have no project and are not indexable.
func projectOf(serverPath string) string {
i := strings.IndexByte(serverPath, '/')
if i <= 0 {
return ""
}
return serverPath[:i]
}
// recordFile adds a parsed file to the index using first-seen (oldest date)
// logic, bucketed under the project (top-level segment) the file lives in.
func (idx *Index) recordFile(pf parsedFile) {
project := projectOf(pf.serverPath)
if project == "" {
// File sits directly at the served root with no project wrapper.
// Skipping it means /.archive/ at the root surfaces nothing — which
// is exactly the contract: stable references must include a project
// directory. Such files are still reachable as ordinary static URLs.
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
pe, ok := idx.ByProject[project]
if !ok {
pe = &ProjectEntry{ByTracking: make(map[string]*TrackingEntry)}
idx.ByProject[project] = pe
}
te, ok := pe.ByTracking[pf.trackingNumber]
if !ok {
te = &TrackingEntry{
ByRevision: make(map[string]*RevisionEntry),
}
pe.ByTracking[pf.trackingNumber] = te
}
re, ok := te.ByRevision[pf.baseRev]
if !ok {
re = &RevisionEntry{
Modifiers: make(map[string]string),
}
te.ByRevision[pf.baseRev] = re
}
if pf.modifier == "" {
switch {
case re.BasePath == "":
re.BasePath = pf.serverPath
re.Date = pf.date
case re.BasePath == pf.serverPath:
// same file, no-op (e.g. re-index from the watcher)
default:
// Two different files claim to be (project, tracking, rev) —
// that's a within-project authoring mistake. Log once with both
// paths so it's diagnosable; chronological winner still wins.
slog.Warn("archive: within-project revision collision",
"project", project,
"tracking", pf.trackingNumber,
"revision", pf.baseRev,
"existing", re.BasePath,
"existingDate", re.Date,
"new", pf.serverPath,
"newDate", pf.date,
)
if pf.date < re.Date {
re.BasePath = pf.serverPath
re.Date = pf.date
}
}
} else {
// Modifier file — record if no entry yet or this transmittal is older
if existing, exists := re.Modifiers[pf.modifier]; !exists || pf.date < re.Date {
_ = existing
re.Modifiers[pf.modifier] = pf.serverPath
}
}
// Update highest base revision
te.HighestBaseRev = highestRevision(te)
}
// highestRevision returns the highest base revision among all revisions in te.
// Revision ordering: numeric revisions (0,1,2…) are lower than alphabetic (A,B,C…).
// Draft prefix ~ means lower than base.
func highestRevision(te *TrackingEntry) string {
if len(te.ByRevision) == 0 {
return ""
}
revs := make([]string, 0, len(te.ByRevision))
for r := range te.ByRevision {
revs = append(revs, r)
}
sort.Slice(revs, func(i, j int) bool {
return compareRevisions(revs[i], revs[j]) < 0
})
return revs[len(revs)-1]
}
// compareRevisions returns negative if a < b, 0 if equal, positive if a > b.
// Order: ~rev < numeric < alpha (A < B < C ...)
func compareRevisions(a, b string) int {
isDraftA := strings.HasPrefix(a, "~")
isDraftB := strings.HasPrefix(b, "~")
baseA := strings.TrimPrefix(a, "~")
baseB := strings.TrimPrefix(b, "~")
// Draft < non-draft of same base
if baseA == baseB {
if isDraftA && !isDraftB {
return -1
}
if !isDraftA && isDraftB {
return 1
}
return 0
}
// Numeric vs alpha: numeric comes first
aIsNum := len(baseA) > 0 && baseA[0] >= '0' && baseA[0] <= '9'
bIsNum := len(baseB) > 0 && baseB[0] >= '0' && baseB[0] <= '9'
if aIsNum && !bIsNum {
return -1
}
if !aIsNum && bIsNum {
return 1
}
// Both numeric or both alpha: string comparison (works for single-char alpha)
if baseA < baseB {
return -1
}
if baseA > baseB {
return 1
}
return 0
}
// UpdateFromDir re-indexes a single transmittal folder (called by the watcher).
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
// Determine the date from the folder name
folderName := filepath.Base(transmittalDirPath)
m := transmittalFolderRE.FindStringSubmatch(folderName)
if m == nil {
return nil // not a transmittal folder
}
date := m[1]
// Compute server-relative path for this folder
rel, err := filepath.Rel(fsRoot, transmittalDirPath)
if err != nil {
return err
}
serverDir := filepath.ToSlash(rel)
return indexTransmittalFolder(idx, fsRoot, transmittalDirPath, serverDir, date)
}
// Entry is one virtual redirect file in the archive listing.
//
// URLName is the filename surfaced under .archive/ (e.g. "123.html",
// "123_~A.html"). TargetPath is the server-relative path the redirect
// resolves to — used both as the redirect target and as the input to the
// per-entry ACL check.
type Entry struct {
URLName string
TargetPath string
}
// AllEntries returns a sorted snapshot of every redirect entry for the named
// project. Two kinds per tracking number:
//
// - <tracking>.html → first-chronological copy of the highest base rev
// - <tracking>_<rev>.html → first-chronological copy of that specific base rev
//
// Modifier files (e.g. <tracking>_<rev>+C1.html) remain reachable via the
// resolver but are not surfaced in the listing — they're return traffic
// (comments / markups), not items the user browses to as primary documents.
//
// Sort order is by URLName; the "." in <tracking>.html sorts before the "_"
// in <tracking>_<rev>.html, so each tracking number's highest-rev shortcut
// comes first, followed by its individual revisions in revision order.
//
// An empty project (or one with no indexed tracking numbers) returns nil,
// keeping the caller branch-free.
func (idx *Index) AllEntries(project string) []Entry {
idx.mu.RLock()
defer idx.mu.RUnlock()
pe, ok := idx.ByProject[project]
if !ok {
return nil
}
var result []Entry
for tn, te := range pe.ByTracking {
if te.HighestBaseRev != "" {
if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" {
result = append(result, Entry{
URLName: tn + ".html",
TargetPath: re.BasePath,
})
}
}
for rev, re := range te.ByRevision {
if re.BasePath == "" {
continue
}
result = append(result, Entry{
URLName: tn + "_" + rev + ".html",
TargetPath: re.BasePath,
})
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].URLName < result[j].URLName
})
return result
}