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 .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//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): // // - /archive//mdl/... → "tables" // - /archive/ → "archive" // - /archive//... → "archive" // - /staging/... → "transmittal" // - /working/... → "mdedit" // - /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//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) }