ZDDC/zddc/internal/apps/availability.go
ZDDC fb13ff4fd8
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
feat(browse): generic directory listing tool — default at folder URLs
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.

Modes (auto-detected at page load):
  - Online: when served by zddc-server at a folder URL, queries
    the same URL with Accept: application/json to load the listing
    and renders it. Auto-served as the default at any directory
    under ZDDC_ROOT without an index.html (replacing the previous
    minimal-HTML stub from directory.go).
  - Local: 'Select Directory' button uses FileSystemAccessAPI to
    pick any folder on disk; works in Chromium-based browsers.

Features (Phase 1 — what's in this commit):
  - Tree view with lazy-loaded folders (children fetched on first
    expand).
  - Sort by name / size / extension / date (column header click).
  - Filter by name substring (toolbar input).
  - File click opens in a new tab — for server-backed pages,
    routes through zddc-server's normal handler so .archive
    redirects + apps cascade overrides + ACL all apply.

Phase 2 deferred:
  - ZIP files inline expansion (treat archive entries as virtual
    children).
  - File preview popup (reuse shared/preview-lib.js).
  - Extension multi-select filter.

Wiring:
  - browse/ added to top-level ./build's per-tool list, embed
    block, versions.txt, and the lockstep release commit + tag set.
    All seven tools (archive, transmittal, classifier, mdedit,
    landing, form, browse) advance together on stable cuts.
  - shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
    verify_channel_links's per-tool loop.
  - zddc/internal/apps/embed.go: //go:embed browse.html +
    EmbeddedBytes("browse") case.
  - zddc/internal/apps/availability.go: browse available at every
    directory (same as archive).
  - zddc/internal/apps/handler.go: MatchAppHTML routes
    /<dir>/browse.html → 'browse'.
  - zddc/internal/handler/directory.go: when a directory request
    arrives with Accept: text/html and no index.html exists,
    serve the embedded browse.html bytes (with a JSON-fallback
    if the embedded slot is empty during bootstrap).
2026-05-03 19:56:51 -05:00

75 lines
2.5 KiB
Go

package apps
import (
"path/filepath"
"strings"
)
// Folder name conventions that gate which tools are virtually available
// at a given path. The names are case-sensitive; ZDDC convention uses
// the capitalized forms.
var (
folderNamesIncomingWorkingStaging = []string{"Incoming", "Working", "Staging"}
folderNamesWorking = []string{"Working"}
folderNamesStaging = []string{"Staging"}
)
// AppAvailableAt reports whether app's virtual HTML can be served at
// requestDir. Rules:
//
// - archive: every directory (multi-project, project, archive, vendor)
// - 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
// "Incoming", "Working", or "Staging" (the directories where
// incoming/outgoing files get classified)
// - mdedit: requestDir is, or descends from, a "Working" folder
// (where markdown drafts are written and edited)
// - 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.
func AppAvailableAt(root, requestDir, app string) bool {
root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir)
switch app {
case "archive":
return true
case "browse":
return true
case "landing":
return requestDir == root
case "classifier":
return inAncestorWithName(root, requestDir, folderNamesIncomingWorkingStaging)
case "mdedit":
return inAncestorWithName(root, requestDir, folderNamesWorking)
case "transmittal":
return inAncestorWithName(root, requestDir, folderNamesStaging)
}
return false
}
// inAncestorWithName reports whether requestDir is, or has an ancestor
// (not including root itself), named one of names. The match is on the
// last segment of each directory in the chain root → requestDir.
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 part == n {
return true
}
}
}
return false
}