package apps import ( "path/filepath" "strings" ) // 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). 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. return "" } // The project segment is parts[0]; canonical folder is parts[1]. canonical := strings.ToLower(parts[1]) switch canonical { case "archive": // Inside archive/. Check for the mdl sub-case at depth 4 // (parts: project, archive, party, mdl). if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") { return "tables" } return "archive" case "staging": return "transmittal" case "working": return "mdedit" case "reviewing": return "mdedit" } return "" }