browse: the party picker reads the ssr/ registry (the authoritative party list) and creates at physical peer paths <project>/<peer>/<party>/…; "register new party" writes ssr/<party>.yaml first (party_source: ssr). stage.js + accept-transmittal.js repointed to the top-level workspace peers (working/staging/incoming) — received/issued + plan-review stay under the WORM archive. tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE level into the party subdirs CLIENT-side (works online AND offline), with $party from the server-injected row content (or derived from the subdir offline). Rows carry the <party>/ prefix so reads/edits hit the real per-party path. The server just lists the peer root normally (party subdirs + synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows are dropped in favour of this dual-mode client recursion. Full Go suite + all 256 Playwright tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
Go
124 lines
4.3 KiB
Go
package zddc
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Project-level helpers for the physical top-level peer layout.
|
|
//
|
|
// There is no virtual URL space: every record row is addressed at its
|
|
// real path (mdl/<party>/<file>.yaml, ssr/<party>.yaml). mdl/ and rsk/
|
|
// AGGREGATE across their party subdirs — the peer root renders one
|
|
// table of every party's rows (a $party column derived from the real
|
|
// subdir name), while <peer>/<party>/ shows that party's rows flat.
|
|
// ssr/ aggregates naturally (one flat ssr/<party>.yaml per party) and
|
|
// is the authoritative party registry. These helpers back the
|
|
// aggregation listing and party-name validation.
|
|
|
|
// partyNameRE matches a valid party folder / registry-row token —
|
|
// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-].
|
|
var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`)
|
|
|
|
// ValidPartyName reports whether s is a valid party name. Used by URL
|
|
// resolution AND by the SSR create handler to validate user input.
|
|
func ValidPartyName(s string) bool {
|
|
return partyNameRE.MatchString(s)
|
|
}
|
|
|
|
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
|
|
// — the only URL shape Plan Review accepts. Trailing slash optional.
|
|
var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`)
|
|
|
|
// IsPlanReviewURL reports whether urlPath is a directory URL eligible
|
|
// for the Plan Review composite endpoint — i.e. it points at the
|
|
// canonical received/<tracking>/ folder under archive/<party>/.
|
|
// Eligibility is purely structural; the handler-side authorisation
|
|
// check still gates the actual operation.
|
|
func IsPlanReviewURL(urlPath string) bool {
|
|
return planReviewURLRE.MatchString(urlPath)
|
|
}
|
|
|
|
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
|
|
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
|
|
// returns urlPath unchanged + false. The form recognizer calls this
|
|
// to map a form-edit URL onto the underlying data file.
|
|
func StripYAMLHTML(urlPath string) (string, bool) {
|
|
if strings.HasSuffix(urlPath, ".yaml.html") {
|
|
return strings.TrimSuffix(urlPath, ".html"), true
|
|
}
|
|
return urlPath, false
|
|
}
|
|
|
|
// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html —
|
|
// the SSR "+ Register party" target. Returns the project name when
|
|
// matched. The handler writes the new ssr/<party>.yaml registry row.
|
|
func IsSSRCreateURL(urlPath string) (string, bool) {
|
|
if urlPath == "" || urlPath[0] != '/' {
|
|
return "", false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
|
|
if len(parts) != 3 || parts[1] != "ssr" || parts[2] != "form.html" {
|
|
return "", false
|
|
}
|
|
project := parts[0]
|
|
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
|
return "", false
|
|
}
|
|
return project, true
|
|
}
|
|
|
|
// IsRollupCreateURL reports whether urlPath is
|
|
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
|
|
// project-level MDL or RSK aggregate view. Returns the project name +
|
|
// peer ("mdl" or "rsk") when matched. The handler reads a `party` field
|
|
// from the body and routes the new row into <project>/<peer>/<party>/.
|
|
func IsRollupCreateURL(urlPath string) (project, peer string, ok bool) {
|
|
if urlPath == "" || urlPath[0] != '/' {
|
|
return "", "", false
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
|
|
if len(parts) != 3 || parts[2] != "form.html" {
|
|
return "", "", false
|
|
}
|
|
if parts[1] != "mdl" && parts[1] != "rsk" {
|
|
return "", "", false
|
|
}
|
|
project = parts[0]
|
|
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
|
return "", "", false
|
|
}
|
|
return project, parts[1], true
|
|
}
|
|
|
|
// ListParties returns the registered party names under <project>/ssr/
|
|
// — one per ssr/<party>.yaml file (the authoritative party registry).
|
|
// Names are filtered through ValidPartyName. Returns nil + nil when
|
|
// ssr/ doesn't exist on disk.
|
|
func ListParties(projectAbs string) ([]string, error) {
|
|
reg := filepath.Join(projectAbs, "ssr")
|
|
entries, err := os.ReadDir(reg)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".yaml")
|
|
if !ValidPartyName(name) {
|
|
continue
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|