ZDDC/zddc/internal/archive/resolver.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

95 lines
2.3 KiB
Go

package archive
import (
"strings"
)
// Resolve parses the .archive request filename and returns the server-relative
// redirect target URL (no leading slash) within the named project.
//
// Project is the top-level segment of the .archive contextPath
// (/<project>/.../.archive/<filename>). An empty project — i.e. a request
// against /.archive/ at the very root — returns ("", false): stable refs
// must be project-rooted to avoid cross-project tracking-number collisions.
//
// Supported URL filename patterns (after stripping .html suffix):
// - trackingNumber → highest base revision of trackingNumber
// - trackingNumber_rev → base revision file for rev
// - trackingNumber_rev+C1 → modifier file (C1, B1, N1, Q1)
//
// Returns ("", false) if project is empty, the filename cannot be parsed, or
// no match exists in the project.
func Resolve(idx *Index, project, filename string) (string, bool) {
if project == "" {
return "", false
}
// Strip .html suffix
stem := strings.TrimSuffix(filename, ".html")
if stem == filename {
// No .html suffix — not a valid archive request
return "", false
}
idx.mu.RLock()
defer idx.mu.RUnlock()
pe, ok := idx.ByProject[project]
if !ok {
return "", false
}
// Try to split off revision part (last _ segment)
lastUnderscore := strings.LastIndex(stem, "_")
if lastUnderscore < 0 {
// No underscore — treat entire stem as tracking number
tracking := stem
te, ok := pe.ByTracking[tracking]
if !ok || te.HighestBaseRev == "" {
return "", false
}
re, ok := te.ByRevision[te.HighestBaseRev]
if !ok || re.BasePath == "" {
return "", false
}
return re.BasePath, true
}
tracking := stem[:lastUnderscore]
revPart := stem[lastUnderscore+1:]
// Split revPart on "+" to separate baseRev from modifier
plusIdx := strings.Index(revPart, "+")
var baseRev, modifier string
if plusIdx < 0 {
baseRev = revPart
modifier = ""
} else {
baseRev = revPart[:plusIdx]
modifier = revPart[plusIdx+1:]
}
te, ok := pe.ByTracking[tracking]
if !ok {
return "", false
}
re, ok := te.ByRevision[baseRev]
if !ok {
return "", false
}
if modifier == "" {
if re.BasePath == "" {
return "", false
}
return re.BasePath, true
}
// Modifier lookup
path, ok := re.Modifiers[modifier]
if !ok {
return "", false
}
return path, true
}