feat: virtual fallback for archive/<party>/* folders + incoming fixture data

Three coupled fixes:

1. landing MDL card: Open button now navigates to /<project>/archive/
   <party>/mdl (no trailing slash) so the tables tool loads. The
   slash form would route to browse instead, which is not what users
   want when they click "Open MDL".

2. zddc-server canonical-folder fallback extended to
   archive/<party>/{mdl,incoming,received,issued}. New
   zddc.IsArchivePartyFolder() recognises any of the four party
   folders at depth 4. fs.ListDirectory returns [] for missing
   on-disk variants (mirroring the project-root behavior added in
   commit 3fc3717); the dispatcher routes slash forms to
   ServeDirectory and the no-slash mdl form to ServeTable, with
   non-mdl no-slash forms 302'ing to the slash form.

   So /Project-N/archive/<party>/incoming/ now lands on an empty
   browse listing rather than 404 when nobody has dropped files yet.

3. Fixture seeded with 3 files per party under incoming/ — naming
   intentionally NOT in transmittal-envelope form, so classifier
   (loaded automatically by browse's grid mode at /incoming/
   per the URL-driven view convention) has something to rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 13:36:03 -05:00
parent ab44d75d03
commit 5debd552ae
6 changed files with 75 additions and 15 deletions

View file

@ -704,7 +704,11 @@
function openSelectedMdl() { function openSelectedMdl() {
var party = mdlSelect.value; var party = mdlSelect.value;
if (!party) return; 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); window.location.assign(url);
} }
mdlOpenBtn.addEventListener('click', openSelectedMdl); mdlOpenBtn.addEventListener('click', openSelectedMdl);

View file

@ -447,10 +447,29 @@ cmd_build() {
for party in $parties; do for party in $parties; do
party_dir="$proj_dir/archive/$party" party_dir="$proj_dir/archive/$party"
mkdir -p "$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" chmod 0777 "$party_dir" "$party_dir/received" "$party_dir/issued" "$party_dir/incoming"
write_zddc_config "$party_dir/.zddc" party 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 i=0
while [ "$i" -lt "$per_party" ]; do while [ "$i" -lt "$per_party" ]; do
i=$((i + 1)) i=$((i + 1))

View file

@ -884,15 +884,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// block → ServeDirectory → embedded browse.html. // block → ServeDirectory → embedded browse.html.
} }
} }
// Default-MDL virtual directory at archive/<party>/mdl[/]. // Per-party canonical folders at archive/<party>/{mdl,incoming,
// Shape rule mirrors the other canonical folders: // received,issued}[/]. The on-disk folder may not exist yet —
// - no slash → tables app (default tool for mdl/) // dispatch lands on a usable view regardless:
// - slash → browse (ServeDirectory → empty listing for // - no-slash mdl → tables app (default-MDL fallback)
// the not-yet-materialised folder) // - no-slash else → not yet a registered tool → falls
// The dispatcher works without the on-disk dir existing // through to 302 (slash form)
// thanks to fs.ListDirectory's empty-listing fallback + // - slash, all → browse (ServeDirectory → fs.ListDirectory
// RecognizeTableRequest's default-MDL fallback. // empty-listing fallback)
if r.Method == http.MethodGet || r.Method == http.MethodHead { if r.Method == http.MethodGet || r.Method == http.MethodHead {
rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
synth := urlPath + "/table.html" synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { 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) handler.ServeTable(cfg, tr, w, r)
return return
} }
} else if zddc.IsArchivePartyMdlDir( // No-slash forms for non-mdl party folders 302 to
strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) { // 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) handler.ServeDirectory(cfg, appsSrv, w, r)
return return
} }

View file

@ -57,7 +57,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// virtualUserHomeEntry below still fires for working/ so the // virtualUserHomeEntry below still fires for working/ so the
// user sees their own home placeholder. // user sees their own home placeholder.
if os.IsNotExist(err) && if os.IsNotExist(err) &&
(zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyMdlDir(dirPath)) { (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) {
entries = nil entries = nil
} else { } else {
return nil, err return nil, err

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 18:10:39 · e85d5fc-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 18:30:39 · ab44d75-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -49,6 +49,36 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}
// MkdirAll for it. // MkdirAll for it.
var VirtualOnlyCanonicalNames = []string{"reviewing"} var VirtualOnlyCanonicalNames = []string{"reviewing"}
// IsArchivePartyFolder reports whether dirPath (relative, forward-
// slash-separated) names a canonical per-party folder at exactly
// depth 4: <project>/archive/<party>/<folder>, where <folder> 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- // IsArchivePartyMdlDir reports whether dirPath (relative, forward-
// slash-separated) names the default-MDL pattern at exactly depth 4: // slash-separated) names the default-MDL pattern at exactly depth 4:
// <project>/archive/<party>/mdl. Match is case-insensitive on the // <project>/archive/<party>/mdl. Match is case-insensitive on the