diff --git a/landing/js/landing.js b/landing/js/landing.js index 9972e32..af99104 100644 --- a/landing/js/landing.js +++ b/landing/js/landing.js @@ -704,7 +704,11 @@ function openSelectedMdl() { var party = mdlSelect.value; if (!party) return; - var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl/'; + // No trailing slash: per the convention, the no-slash form + // serves the tables tool with the MDL view. The slash form + // would serve browse, which is not what the user wants when + // they click "Open MDL". + var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl'; window.location.assign(url); } mdlOpenBtn.addEventListener('click', openSelectedMdl); diff --git a/tests/data/test-archive.sh b/tests/data/test-archive.sh index 5b81690..088804a 100755 --- a/tests/data/test-archive.sh +++ b/tests/data/test-archive.sh @@ -447,10 +447,29 @@ cmd_build() { for party in $parties; do party_dir="$proj_dir/archive/$party" - mkdir -p "$party_dir/received" "$party_dir/issued" - chmod 0777 "$party_dir" "$party_dir/received" "$party_dir/issued" + mkdir -p "$party_dir/received" "$party_dir/issued" "$party_dir/incoming" + chmod 0777 "$party_dir" "$party_dir/received" "$party_dir/issued" "$party_dir/incoming" write_zddc_config "$party_dir/.zddc" party + # Seed incoming/ with a couple of unclassified files (no + # transmittal envelope yet) so the grid view has data to + # exercise. Stays small — incoming is the staging surface + # for files dropped by counterparties; classifier turns + # them into named transmittal folders. + incoming_seed_count=3 + j=0 + while [ "$j" -lt "$incoming_seed_count" ]; do + j=$((j + 1)) + seed_ext=$(pick_word "$EXTENSIONS_WEIGHTED") + seed_title="$(random_title)" + seed_name="incoming-${j}-${seed_title}.${seed_ext}" + seed_path="$party_dir/incoming/$seed_name" + # Use the per-transmittal renderer with dummy + # tracking/rev/status. classifier reads the bytes, + # not the envelope, when renaming. + render_file "$seed_ext" "drop-${j}" "_A" "(IFR)" "$seed_title" "$seed_path" + done + i=0 while [ "$i" -lt "$per_party" ]; do i=$((i + 1)) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index bb5829d..75ee696 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -884,15 +884,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // block → ServeDirectory → embedded browse.html. } } - // Default-MDL virtual directory at archive//mdl[/]. - // Shape rule mirrors the other canonical folders: - // - no slash → tables app (default tool for mdl/) - // - slash → browse (ServeDirectory → empty listing for - // the not-yet-materialised folder) - // The dispatcher works without the on-disk dir existing - // thanks to fs.ListDirectory's empty-listing fallback + - // RecognizeTableRequest's default-MDL fallback. + // Per-party canonical folders at archive//{mdl,incoming, + // received,issued}[/]. The on-disk folder may not exist yet — + // dispatch lands on a usable view regardless: + // - no-slash mdl → tables app (default-MDL fallback) + // - no-slash else → not yet a registered tool → falls + // through to 302 (slash form) + // - slash, all → browse (ServeDirectory → fs.ListDirectory + // empty-listing fallback) if r.Method == http.MethodGet || r.Method == http.MethodHead { + rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/") if !strings.HasSuffix(urlPath, "/") { synth := urlPath + "/table.html" if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { @@ -904,8 +905,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps handler.ServeTable(cfg, tr, w, r) return } - } else if zddc.IsArchivePartyMdlDir( - strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) { + // No-slash forms for non-mdl party folders 302 to + // the slash form so they consistently land on the + // browse listing. + if zddc.IsArchivePartyFolder(rel) { + http.Redirect(w, r, urlPath+"/", http.StatusFound) + return + } + } else if zddc.IsArchivePartyFolder(rel) { handler.ServeDirectory(cfg, appsSrv, w, r) return } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 6ab9929..61360b3 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -57,7 +57,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // virtualUserHomeEntry below still fires for working/ so the // user sees their own home placeholder. if os.IsNotExist(err) && - (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyMdlDir(dirPath)) { + (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) { entries = nil } else { return nil, err diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 6fdf179..5bc53c2 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 18:10:39 · e85d5fc-dirty + v0.0.17-alpha · 2026-05-11 18:30:39 · ab44d75-dirty
diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index 8e7a535..a44c1b1 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -49,6 +49,36 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} // MkdirAll for it. var VirtualOnlyCanonicalNames = []string{"reviewing"} +// IsArchivePartyFolder reports whether dirPath (relative, forward- +// slash-separated) names a canonical per-party folder at exactly +// depth 4: /archive//, where is one +// of mdl/incoming/received/issued (case-insensitive). The party +// segment is verbatim. +// +// Used by listing + dispatch fallbacks so a fresh party that hasn't +// yet had files written to each subfolder still lands on a usable +// empty browse view rather than 404. +func IsArchivePartyFolder(dirPath string) bool { + clean := strings.Trim(filepath.ToSlash(dirPath), "/") + if clean == "" { + return false + } + parts := strings.Split(clean, "/") + if len(parts) != 4 { + return false + } + if !strings.EqualFold(parts[1], "archive") { + return false + } + leaf := strings.ToLower(parts[3]) + for _, name := range PartyFolders { + if leaf == name { + return true + } + } + return false +} + // IsArchivePartyMdlDir reports whether dirPath (relative, forward- // slash-separated) names the default-MDL pattern at exactly depth 4: // /archive//mdl. Match is case-insensitive on the