Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.
Two depths, both trailing-slash:
GET <project>/reviewing/?json=1
→ array of virtual <tracking>/ entries, one per submittal in
archive/<party>/received/ that doesn't yet have a matching
archive/<party>/issued/ entry. Sorted by tracking. URLs stay
under reviewing/ so the user can drill into the per-submittal
view. ACL: per-party, filtered like fs.ListDirectory.
GET <project>/reviewing/<tracking>/?json=1
→ array of two virtual entries, received/ + staged/, with
canonical URLs pointing back to archive/<party>/received/...
and staging/... respectively. staged/ is omitted when no
response draft exists yet.
When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.
Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.
Dispatcher routing in zddc-server/main.go:
- GET <project>/reviewing/[<tracking>/] with Accept: json
→ ServeReviewing
- GET <project>/reviewing/[<tracking>/] with Accept: html
→ mdedit (rooted at the virtual path; polyfill fetches the
JSON listing on its own)
- GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
- GET <project>/reviewing/<tracking> (no slash) → 301 to slash form
Tests:
- handler/reviewinghandler_test.go (6 cases): IsReviewingPath
classification + ServeReviewing depth-0/depth-1 with and without
staged drafts + 404 on unknown tracking + empty when archive/ is
absent.
- apps/availability_test.go updated: reviewing/ now expects mdedit
rather than "" (no default).
- cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
extended to assert reviewing → mdedit at the no-slash form;
older "no-slash/reviewing → 301" test removed.
Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.7 KiB
Go
131 lines
4.7 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" 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).
|
|
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 ""
|
|
}
|