Two pieces: 1. Lookup helpers walk chain.Levels from leaf back to root. The "parent applies to descendants unless overridden" cascade rule means a working/ default_tool=mdedit propagates to deep paths like working/alice@example.com/notes/sub/deep without anyone declaring it at every level. AutoOwnAt and VirtualAt follow the same walk; explicit false at a descendant can override an ancestor's true (*bool semantics). 2. apps.DefaultAppAt delegates to zddc.DefaultToolAt. The hardcoded switch on parts[1] (archive→archive, staging→transmittal, working→mdedit, reviewing→mdedit, mdl→tables) and its case- sensitivity quirks now live in defaults.zddc.yaml. Operators can override any of these per-directory with an on-disk .zddc; no code change required. Semantic improvement: archive/<party>/incoming previously defaulted to "archive" (because parts[1]=archive and the switch didn't look deeper). The new convention routes it to "classifier" — incoming/ is the bulk-rename surface, not a record browser. Updated availability_test.go to reflect. All other DefaultAppAt cases — including case-fold (Archive/MDL), mdl override, reviewing virtual, project root returning "", random non-canonical names returning "" — produce bit-identical output. Two new tests in lookups_test.go cover the propagation: - TestDefaultToolAt_PropagatesToDescendants - TestAutoOwnAt_DescendantCanDisable Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
4.9 KiB
Go
127 lines
4.9 KiB
Go
package apps
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// AppAvailableAt reports whether app's virtual HTML can be served at
|
|
// requestDir. Rules (case-insensitive on canonical folder names):
|
|
//
|
|
// - archive: every directory (multi-project, project, archive, party)
|
|
// - browse: every directory (generic file listing — also the default
|
|
// served at folder URLs without an index.html; see directory.go)
|
|
// - classifier: requestDir is, or descends from, a folder named
|
|
// "working", "staging", or "incoming" (the directories where
|
|
// in-flight files get classified)
|
|
// - mdedit: requestDir is, or descends from, a "working" or
|
|
// "reviewing" folder. working/ is the drafting workspace;
|
|
// reviewing/ is the virtual aggregation view of pending review
|
|
// responses (server-rendered listings; mdedit follows the
|
|
// listing's canonical URLs via the polyfill).
|
|
// - transmittal: requestDir is, or descends from, a "staging" folder
|
|
// (where outgoing transmittals are prepared)
|
|
// - landing: only at the deployment root (the project picker)
|
|
//
|
|
// Operators can always drop a real <name>.html file at any path to
|
|
// override — that path is served by the static handler regardless of
|
|
// this function's result. AppAvailableAt is consulted only when no
|
|
// real file exists.
|
|
//
|
|
// In the canonical layout, "incoming" only appears at
|
|
// archive/<party>/incoming/, so checking "any ancestor named incoming"
|
|
// is equivalent to checking "under a per-party incoming folder."
|
|
func AppAvailableAt(root, requestDir, app string) bool {
|
|
root = filepath.Clean(root)
|
|
requestDir = filepath.Clean(requestDir)
|
|
|
|
switch app {
|
|
case "archive", "browse":
|
|
return true
|
|
case "landing":
|
|
return requestDir == root
|
|
case "classifier":
|
|
return inAncestorWithName(root, requestDir, "working", "staging", "incoming")
|
|
case "mdedit":
|
|
return inAncestorWithName(root, requestDir, "working", "reviewing")
|
|
case "transmittal":
|
|
return inAncestorWithName(root, requestDir, "staging")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// inAncestorWithName reports whether requestDir is, or has an ancestor
|
|
// (not including root itself), whose last segment case-folds to one
|
|
// of names. Match is on segment names, case-insensitively.
|
|
func inAncestorWithName(root, requestDir string, names ...string) bool {
|
|
if requestDir == root {
|
|
return false
|
|
}
|
|
rel, err := filepath.Rel(root, requestDir)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
for _, n := range names {
|
|
if strings.EqualFold(part, n) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DefaultAppAt returns the canonical default tool name for requestDir,
|
|
// or "" if no specific tool fits. Used by the dispatcher to decide
|
|
// which app to serve at a directory URL with no trailing slash —
|
|
// trailing-slash URLs serve the browse app for any directory.
|
|
//
|
|
// Rules (case-insensitive on canonical folder names):
|
|
//
|
|
// - <project>/archive/<party>/mdl/... → "tables"
|
|
// - <project>/archive/ → "archive"
|
|
// - <project>/archive/<party>/... → "archive"
|
|
// - <project>/staging/... → "transmittal"
|
|
// - <project>/working/... → "mdedit"
|
|
// - <project>/reviewing/... → "mdedit" (operates on the
|
|
// virtual aggregator listing)
|
|
// - any other directory → "" (no default)
|
|
//
|
|
// The mdl rule wins over the broader archive rule because the table
|
|
// editor is a more specific surface for browsing planned deliverables
|
|
// than the archive index. Note: the dir at archive/<party>/mdl/
|
|
// itself IS the table — its table.yaml + form.yaml + row YAMLs all
|
|
// live there together (self-contained directory).
|
|
//
|
|
// requestDir and root are absolute filesystem paths; requestDir must
|
|
// be under root (otherwise "" is returned).
|
|
//
|
|
// Phase 3b: delegates to zddc.DefaultToolAt, which resolves the
|
|
// answer from the .zddc cascade (operator on-disk + embedded
|
|
// defaults). The convention previously hardcoded in the switch
|
|
// statement below now lives in zddc/internal/zddc/defaults.zddc.yaml
|
|
// and is overridable per-directory by operators.
|
|
//
|
|
// Project root itself (depth-1) still returns "" — the cascade
|
|
// doesn't declare a default tool for it (landing is handled by the
|
|
// dispatcher's own depth-1 check).
|
|
func DefaultAppAt(root, requestDir string) string {
|
|
root = filepath.Clean(root)
|
|
requestDir = filepath.Clean(requestDir)
|
|
if requestDir == root {
|
|
return ""
|
|
}
|
|
rel, err := filepath.Rel(root, requestDir)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
return ""
|
|
}
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) < 2 {
|
|
// Project root itself — no default tool from the cascade.
|
|
// (Landing is handled separately by the dispatcher.)
|
|
return ""
|
|
}
|
|
return zddc.DefaultToolAt(root, requestDir)
|
|
}
|