May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
7.8 KiB
Go
222 lines
7.8 KiB
Go
package zddc
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Virtual `received/` window — the doc-controller's Plan Review composite
|
|
// endpoint scaffolds physical folders under archive/<party>/reviewing/ and
|
|
// archive/<party>/staging/, each carrying a .zddc whose `received_path:`
|
|
// points back at the canonical archive/<party>/received/<tracking>/. When
|
|
// a workflow folder is listed, the server injects a synthetic `received/`
|
|
// child that shows the canonical submittal's contents in context.
|
|
//
|
|
// Three behaviours rely on this:
|
|
//
|
|
// GET <workflow>/received/ → list the canonical received/<tracking>/
|
|
// GET <workflow>/received/<file> → serve canonical bytes (read passthrough)
|
|
// PUT <workflow>/received/<file> → rewrite to <workflow>/<base>+C<n><suffix>
|
|
// (the canonical record is WORM)
|
|
//
|
|
// Helpers below give the file API and listing handlers a single point of
|
|
// detection so the routing stays declarative.
|
|
|
|
// IsWorkflowFolder reports whether dirPath has a .zddc with a non-empty
|
|
// ReceivedPath — i.e. it's a Plan-Review-scaffolded reviewing/ or staging/
|
|
// folder.
|
|
func IsWorkflowFolder(dirPath string) bool {
|
|
rp := WorkflowReceivedPath(dirPath)
|
|
return rp != ""
|
|
}
|
|
|
|
// WorkflowReceivedPath returns the .zddc.received_path for dirPath, or
|
|
// empty if the file doesn't exist or doesn't declare one. The path is
|
|
// as-stored (typically relative to the master root).
|
|
func WorkflowReceivedPath(dirPath string) string {
|
|
zf, err := ParseFile(filepath.Join(dirPath, ".zddc"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return zf.ReceivedPath
|
|
}
|
|
|
|
// VirtualReceivedResolution captures the result of mapping a request URL
|
|
// onto either a workflow folder's synthetic `received/` child or a
|
|
// canonical path under it. All fields are populated only when Resolved
|
|
// is true.
|
|
type VirtualReceivedResolution struct {
|
|
Resolved bool
|
|
WorkflowAbs string // absolute path of the workflow folder
|
|
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/archive/Acme/reviewing/2026-05-30_X (TBD) - …/")
|
|
ReceivedAbs string // absolute path of the canonical received target (or canonical+suffix when the URL drills into a file)
|
|
ReceivedURL string // server-relative URL of the canonical received target
|
|
SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf")
|
|
IsRoot bool // true iff the URL targets `<workflow>/received/` itself (no suffix)
|
|
}
|
|
|
|
// virtualReceivedRE matches any URL that traverses a `received` segment
|
|
// not at the canonical archive/<party>/received/<tracking>/ position.
|
|
// The match is loose; the resolver verifies the parent .zddc carries a
|
|
// ReceivedPath before returning Resolved=true.
|
|
//
|
|
// Captures:
|
|
// 1: workflow URL prefix (including trailing slash before "received")
|
|
// 2: suffix after "received/" (may be empty)
|
|
var virtualReceivedRE = regexp.MustCompile(`^(/.+/)received(?:/(.*))?$`)
|
|
|
|
// ResolveVirtualReceived inspects urlPath and returns a populated
|
|
// resolution iff:
|
|
//
|
|
// - the URL contains a `received/` segment whose parent on disk is a
|
|
// workflow folder (.zddc.received_path is set), AND
|
|
// - the URL is NOT the canonical archive/<party>/received/<tracking>/[...]
|
|
// form (handlers there go through normal routing).
|
|
//
|
|
// The canonical form is detected by checking the .zddc.received_path of
|
|
// the parent — if the parent's path matches what received_path points at,
|
|
// that's the canonical record, not a synthetic mapping.
|
|
//
|
|
// On a non-match, Resolved=false and other fields are zero.
|
|
//
|
|
// urlPath is the server-relative URL with one leading slash. trailingSlash
|
|
// indicates whether the original URL ended with a slash (meaning a directory
|
|
// listing was requested vs a file).
|
|
func ResolveVirtualReceived(fsRoot, urlPath string) VirtualReceivedResolution {
|
|
var out VirtualReceivedResolution
|
|
if urlPath == "" || urlPath[0] != '/' {
|
|
return out
|
|
}
|
|
trimmed := strings.TrimSuffix(urlPath, "/")
|
|
m := virtualReceivedRE.FindStringSubmatch(trimmed)
|
|
if m == nil {
|
|
return out
|
|
}
|
|
workflowURL := m[1]
|
|
suffix := m[2]
|
|
|
|
// Translate workflow URL → workflow absolute path.
|
|
workflowRel := strings.TrimPrefix(strings.TrimSuffix(workflowURL, "/"), "/")
|
|
workflowAbs := filepath.Join(fsRoot, filepath.FromSlash(workflowRel))
|
|
if !strings.HasPrefix(workflowAbs, fsRoot+string(filepath.Separator)) && workflowAbs != fsRoot {
|
|
return out
|
|
}
|
|
|
|
// Workflow folder must carry a .zddc.received_path.
|
|
rp := WorkflowReceivedPath(workflowAbs)
|
|
if rp == "" {
|
|
return out
|
|
}
|
|
|
|
// Guard: if workflowAbs itself happens to be the canonical received
|
|
// folder for some weird cascade, don't loop on it. The canonical
|
|
// record never has a .zddc declaring its own received_path, so this
|
|
// can only happen with operator misconfiguration; bail out.
|
|
receivedRel := filepath.ToSlash(filepath.Clean(rp))
|
|
if filepath.ToSlash(strings.TrimPrefix(workflowAbs, fsRoot+string(filepath.Separator))) == receivedRel {
|
|
return out
|
|
}
|
|
|
|
receivedAbs := filepath.Join(fsRoot, filepath.FromSlash(receivedRel))
|
|
receivedURL := "/" + receivedRel + "/"
|
|
|
|
if suffix != "" {
|
|
// File or sub-path drill-in. Append to both abs and URL.
|
|
receivedAbs = filepath.Join(receivedAbs, filepath.FromSlash(suffix))
|
|
receivedURL = "/" + receivedRel + "/" + suffix
|
|
if strings.HasSuffix(urlPath, "/") {
|
|
receivedURL += "/"
|
|
}
|
|
}
|
|
|
|
out.Resolved = true
|
|
out.WorkflowAbs = workflowAbs
|
|
out.WorkflowURL = workflowURL
|
|
out.ReceivedAbs = receivedAbs
|
|
out.ReceivedURL = receivedURL
|
|
out.SuffixURL = suffix
|
|
out.IsRoot = suffix == ""
|
|
return out
|
|
}
|
|
|
|
// commentFilenameRE captures the canonical filename shape with an
|
|
// optional +Cn modifier already on the revision, so we can compute the
|
|
// next n for a target.
|
|
//
|
|
// Captures:
|
|
//
|
|
// 1: tracking, e.g. "Acme-0042"
|
|
// 2: revision base (without +C<n>), e.g. "A"
|
|
// 3: existing +C<n> number (may be empty if no modifier), e.g. "1"
|
|
// 4: rest of the filename, e.g. " (RFI) - Foundation.pdf"
|
|
var commentFilenameRE = regexp.MustCompile(`^([^_]+)_([^+\s()]+)(?:\+C(\d+))?(\s*\([^)]+\)\s*-\s*.+)$`)
|
|
|
|
// CommentResolvedName computes the next +Cn comment filename for the
|
|
// given target name inside workflowAbs. The target name is the file the
|
|
// user dropped onto (e.g. "Acme-0042_A (RFI) - Foundation.pdf"); the
|
|
// returned name has a `+C<n>` modifier on the revision token. n starts
|
|
// at 1 and increments past any existing comments for the same target.
|
|
//
|
|
// If targetName doesn't match the canonical ZDDC filename pattern, an
|
|
// error is returned — comment uploads are only meaningful against
|
|
// parseable submittals.
|
|
func CommentResolvedName(workflowAbs, targetName string) (string, error) {
|
|
m := commentFilenameRE.FindStringSubmatch(targetName)
|
|
if m == nil {
|
|
return "", errors.New("target filename does not match the ZDDC pattern")
|
|
}
|
|
tracking := m[1]
|
|
baseRev := m[2]
|
|
rest := m[4]
|
|
|
|
// Scan workflowAbs for siblings matching <tracking>_<baseRev>+C<n><rest>.
|
|
entries, err := os.ReadDir(workflowAbs)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
maxN := 0
|
|
prefix := tracking + "_" + baseRev + "+C"
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
mm := commentFilenameRE.FindStringSubmatch(name)
|
|
if mm == nil || mm[1] != tracking || mm[2] != baseRev || mm[3] == "" {
|
|
continue
|
|
}
|
|
var n int
|
|
for _, ch := range mm[3] {
|
|
if ch < '0' || ch > '9' {
|
|
n = 0
|
|
break
|
|
}
|
|
n = n*10 + int(ch-'0')
|
|
}
|
|
if n > maxN {
|
|
maxN = n
|
|
}
|
|
}
|
|
return tracking + "_" + baseRev + "+C" + itoa(maxN+1) + rest, nil
|
|
}
|
|
|
|
// itoa is a tiny base-10 stringifier — small enough not to pull strconv.
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var buf [10]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|