ZDDC/zddc/internal/apps/availability.go
ZDDC f7958d7b22 feat(dispatch): trailing slash → browse, no slash → canonical default tool
URL convention for directories under a project:

- <dir>/  (with trailing slash)  → browse (the directory view; same
                                     behaviour as today)
- <dir>   (without trailing slash) → the canonical default tool for
                                     that directory's context, served
                                     inline (no 301 hop)

Tool mapping via the new apps.DefaultAppAt(root, dir):

  - working/...               → mdedit
  - staging/...               → transmittal
  - archive/                  → archive
  - archive/<party>/          → archive
  - archive/<party>/incoming|received|issued/...  → archive
  - archive/<party>/mdl/...   → tables (the per-party MDL grid editor)

Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.

This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.

Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:26:32 -05:00

123 lines
4.2 KiB
Go

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" folder
// (where markdown drafts are written and edited, including review
// responses drafted in working/<rs-name>/)
// - 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")
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"
// - 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.
//
// 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"
}
return ""
}