refactor(tables): in-dir convention + unified table+form HTML bundle

Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.

PART 1 — in-dir convention for table+form spec files

Old layout had the spec at the parent and rows in a child:

    archive/<party>/
      mdl.table.yaml         spec
      mdl.form.yaml          row-edit form
      mdl/                   rows-dir
        row-001.yaml ...

URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.

New layout collapses everything into the rows-dir:

    archive/<party>/mdl/      self-contained
      table.yaml              spec
      form.yaml               row-edit form
      row-001.yaml ...        rows

URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).

Server changes:

- internal/handler/tablehandler.go RecognizeTableRequest fires on
  /<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
  alias map is gone — pure presence-based discovery now matches
  the form system's existing convention. Default-MDL fallback at
  archive/<party>/mdl/ stays for the virgin-archive case (the
  rows-dir need not exist on disk; the URL renders fully virtually).

- internal/handler/formhandler.go RecognizeFormRequest fires on
  /<dir>/form.html and /<dir>/<id>.yaml.html with spec at
  <dir>/form.yaml. specEligible accepts on-disk files OR the
  default-MDL virtual path so an empty mdl/ dir still surfaces the
  add-row form.

- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
  serving archive/<party>/mdl/{table,form}.yaml (5 segments after
  ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
  isAtArchivePartyMdlDir for directory-based recognition. New
  IsDefaultMdlSpecAbs accessor for callers that hold an abs path
  rather than a URL (formhandler).

- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
  back to embedded default-MDL bytes when os.ReadFile returns
  NotExist AND the path matches the archive-party-mdl shape. Three
  call sites updated to pass cfg.Root.

- internal/handler/formhandler.go serveFormCreate writes
  submissions to filepath.Dir(req.SpecPath) — the spec, the form,
  and rows all live in one directory. The submissionsDir creation
  is idempotent (MkdirAll); cascade falls back one level for ACL
  evaluation when the dir hasn't been materialized yet.

- internal/handler/tablehandler.go tableRowsRedirect now points at
  /<dir>/table.html (was /<dir>.table.html) when the directory
  request maps to a recognized table.

- cmd/zddc-server/main.go dispatch synth flips from
  urlPath + ".table.html" to urlPath + "/table.html" for the
  no-trailing-slash → tables-app routing.

- internal/apps/availability.go DefaultAppAt comment clarified
  that the dir at archive/<party>/mdl/ IS the table (not a child).

Client changes:

- tables/js/context.js walkServer fetches <currentdir>/table.yaml
  directly — no .zddc walk for table declarations. Rows are every
  *.yaml in current dir EXCLUDING table.yaml and form.yaml. The
  .zddc fetch-for-aliases is gated on file:// (online mode 404s
  on .zddc reads via the dispatcher's reserve guard, so skipping
  the request avoids browser console noise).

- tables/js/main.js add-row button links to relative form.html
  (same dir).

- tables/js/render.js + filters.js: every column's autofilter is
  uniformly a text-contains input, even enum columns — keeps the
  filter row visually consistent and doesn't constrain users to
  the enum vocabulary.

PART 2 — unified table+form HTML bundle

The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.

- tables/template.html grows two top-level mode containers:
  #table-mode (toolbar + sortable table) and #form-mode (form +
  submit button). Both hidden at parse time; the dispatcher
  unhides one. The shared #form-context placeholder was added
  here so the server's existing injectFormContext target
  resolves.

- tables/js/mode.js (new) sets window.zddcMode synchronously
  based on URL pattern: /form.html or /<id>.yaml.html → form,
  /table.html → table, else inline-context fallback for
  file:// (whichever context blob is non-empty wins). Unhides
  the matching container at DOMContentLoaded.

- tables/js/main.js init() and form/js/main.js boot() each guard
  early when mode isn't theirs. Both apps live on different
  globals (window.tablesApp vs window.formApp) so module
  registration doesn't collide.

- form/js/main.js title write falls back from #form-title to
  #table-title (the unified bundle's shared header element)
  when the dedicated id isn't present.

- tables/build.sh concatenates form modules (widgets, render,
  object, array, errors, post, serialize, util) and form CSS.
  No new external deps. Bundle grows from ~95KB to ~120KB.

- internal/handler/formhandler.go drops the //go:embed form.html
  directive; serveFormRender now writes embeddedTablesHTML via
  a small formRenderHTML() accessor (var declared in
  tablehandler.go, same package). The embedded form.html file
  is removed.

- build script: cp form/dist/form.html → internal/handler/form.html
  step is gone (file no longer exists in the source tree). cp
  tables/dist/tables.html → internal/handler/tables.html now
  runs unconditionally rather than only on beta/stable cuts —
  the renderer is a fixed binary component and dev iteration
  needs the embedded copy refreshed every build. Channel-cascaded
  apps (internal/apps/embedded/) stay channel-gated as before.

- form/dist/form.html still builds for standalone offline-only
  use (downloadable from /releases/), but no longer goes into
  the binary.

Tests:

- internal/handler/tablehandler_test.go and formhandler_test.go
  rewritten for the in-dir layout. New test
  TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
  empty-form, create POST, re-edit row, and the negative cases
  (Working/, non-mdl name) where the fallback must NOT fire.

- internal/handler/directory_test.go updated for the new
  /<dir>/table.html redirect target.

- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
  expectation updated.

- tests/form-safety.spec.js loads tables/dist/tables.html
  (named form.html in the temp dir to trigger form-mode in the
  dispatcher) so it tests the same bytes the server returns.
  Title-element selector switches to #table-title.

- tests/tables.spec.js updates the status-filter test for the
  uniform text-input filter.

Docs:

- AGENTS.md form-data system rewrites the URL conventions and
  storage layout for in-dir; gains a Tables system section
  parallel to forms describing the self-contained-directory
  property; subfolder rules ("one table per folder by
  construction; subfolders allowed and silently ignored as rows
  — legitimate uses: nested sub-tables, per-row attachments,
  drafts, future history sidecars") so we don't re-derive this.

Not included (deferred):

- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 09:15:26 -05:00
parent 2ce5336289
commit e6d9966593
23 changed files with 1798 additions and 2123 deletions

View file

@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
## Architecture ## Architecture
Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below). Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below).
``` ```
tool/ tool/
@ -333,13 +333,13 @@ A schema-driven form renderer used to collect structured data into YAML files in
**Form spec**: `<name>.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well. **Form spec**: `<name>.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well.
**URL conventions** (form posts back to its own URL; server strips `.html`): **URL conventions** (form posts back to its own URL; server strips `.html`). The spec lives **inside** the rows-dir alongside the row YAMLs, so the whole form (spec + every submission) is a single self-contained directory:
- `GET /<path>/<name>.form.html` — render empty form - `GET /<dir>/form.html` — render empty form
- `POST /<path>/<name>.form.html` — create new submission → 201 + Location capability URL - `POST /<dir>/form.html` — create new submission → 201 + Location capability URL pointing at the new `<dir>/<id>.yaml`
- `GET /<path>/<name>/<id>.yaml.html` — render form pre-filled from `<id>.yaml` - `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<path>/<name>/<id>.yaml.html` — overwrite that submission → 200 - `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200
**Storage**: spec at `<dir>/<name>.form.yaml`, submissions at `<dir>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml`. Submissions folder is created lazily; ACL applies via the existing `.zddc` cascade. **Storage**: spec at `<dir>/form.yaml`, submissions at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (siblings of the spec). Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself. **Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
@ -347,7 +347,36 @@ A schema-driven form renderer used to collect structured data into YAML files in
**Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1. **Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1.
**Adding a new form**: drop a `<name>.form.yaml` into any path users can write to (per `.zddc` ACL). No code change required. Visit `<that-path>/<name>.form.html`. **Adding a new form**: create a directory `<dir>/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `<dir>/form.html`.
## Tables system (`tables/` + zddc-server table handler)
Read/aggregate counterpart to the form system. Renders a directory of YAML row files as a sortable, filterable table; each row clicks through to its `<id>.yaml.html` form editor. The tables tool (`tables/`) is the renderer; the server-side recognizer is `zddc/internal/handler/tablehandler.go RecognizeTableRequest`.
**Discovery is presence-based**, the same convention as forms: a `<dir>/table.yaml` on disk auto-mounts at `<dir>/table.html`. The directory is the table.
**Storage** (self-contained directory):
```
<dir>/
table.yaml ← spec
form.yaml ← row-edit form (paired with table.yaml)
<id>.yaml ... ← rows
```
`table.yaml` and `form.yaml` are excluded from the rows list. Each row is also a form submission — the same files the form system reads — so the table view and the per-row form editor are two views of one folder of YAMLs. Copying `<dir>/` elsewhere copies the entire table (spec + form + every row) — that's the whole point of the in-dir layout.
**One table per directory** by construction (the spec is the singleton `table.yaml`). No `.zddc` reference needed; presence-based discovery is the entire rule. To make a directory a table, drop a `table.yaml` in it — that's it.
**Subfolders inside a table dir are allowed and silently ignored as rows.** The rows iterator filters non-`.yaml` entries, so directories don't show up in the table view. Legitimate subfolder use cases:
- **Nested sub-tables**`<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
- **Per-row attachments**`<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
- **Drafts / staging**`<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
- **Future per-row history**`<dir>/.history/<id>/<timestamp>.yaml` if/when version sidecars are added.
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
## Implementation-vs-dependency policy ## Implementation-vs-dependency policy

21
build
View file

@ -199,17 +199,20 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html" cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html" cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
echo "Populated $EMBED_DIR/ for //go:embed" echo "Populated $EMBED_DIR/ for //go:embed"
fi
# The form renderer lives next to its handler (no cascade needed — it's a # The unified tables renderer ships both table-mode and form-mode in
# fixed renderer, not a per-folder-override tool). # one HTML — see tables/template.html and tables/js/mode.js. The Go
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html" # server embeds a single tables.html (//go:embed in tablehandler.go);
echo "Populated zddc/internal/handler/form.html for //go:embed" # both ServeTable and ServeForm output these same bytes with their
# respective inline-context blob. Form-mode-only standalone use is
# served by form/dist/form.html (download-only, not embedded). Refresh
# on every build (including plain dev `./build`) so iteration on
# form/tables JS shows up in the binary without needing a beta cut.
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
echo "Populated zddc/internal/handler/tables.html for //go:embed"
# Same pattern for the tables renderer — embedded directly into the if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
# handler package (read-only directory-of-YAML view; not subject to
# per-folder version overrides).
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
echo "Populated zddc/internal/handler/tables.html for //go:embed"
# Assemble the embedded versions manifest from the per-tool .label sidecars # Assemble the embedded versions manifest from the per-tool .label sidecars
# written by shared/build-lib.sh's compute_build_label. The Go side reads # written by shared/build-lib.sh's compute_build_label. The Go side reads

View file

@ -2,10 +2,22 @@
'use strict'; 'use strict';
function boot() { function boot() {
// When this bundle is hosted by the unified tables.html, the
// mode dispatcher decides which app paints. Skip when mode is
// not "form" — table-mode requests are handled by tablesApp.
// (Standalone form/dist/form.html has no zddcMode global; treat
// undefined as form-mode for back-compat.)
if (window.zddcMode && window.zddcMode !== 'form') {
return;
}
app.context = app.modules.context.load(); app.context = app.modules.context.load();
if (app.context.title) { if (app.context.title) {
const t = document.getElementById('form-title'); // Standalone form.html has #form-title in its header; unified
// tables.html bundle has #table-title (shared across modes).
// Whichever exists, write to it.
const t = document.getElementById('form-title') ||
document.getElementById('table-title');
if (t) { if (t) {
t.textContent = app.context.title; t.textContent = app.context.title;
} }

View file

@ -20,14 +20,21 @@ trap cleanup EXIT
concat_files \ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"css/table.css" \ "css/table.css" \
"../form/css/form.css" \
> "$css_temp" > "$css_temp"
# Single bundle hosts both apps. mode.js runs first to set
# window.zddcMode based on the URL, then each app's main.js bails
# early when its mode isn't selected. Form modules live under
# window.formApp; table modules under window.tablesApp; no namespace
# collisions.
concat_files \ concat_files \
"../shared/vendor/js-yaml.min.js" \ "../shared/vendor/js-yaml.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/help.js" \ "../shared/help.js" \
"js/mode.js" \
"js/app.js" \ "js/app.js" \
"js/context.js" \ "js/context.js" \
"js/util.js" \ "js/util.js" \
@ -35,6 +42,17 @@ concat_files \
"js/sort.js" \ "js/sort.js" \
"js/render.js" \ "js/render.js" \
"js/main.js" \ "js/main.js" \
"../form/js/app.js" \
"../form/js/context.js" \
"../form/js/util.js" \
"../form/js/widgets.js" \
"../form/js/object.js" \
"../form/js/array.js" \
"../form/js/render.js" \
"../form/js/serialize.js" \
"../form/js/errors.js" \
"../form/js/post.js" \
"../form/js/main.js" \
> "$js_raw" > "$js_raw"
escape_js_close_tags "$js_raw" "$js_temp" escape_js_close_tags "$js_raw" "$js_temp"

View file

@ -27,12 +27,17 @@
margin: 0 0 var(--spacing-sm); margin: 0 0 var(--spacing-sm);
} }
.table-toolbar__left { .table-toolbar__left,
.table-toolbar__right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
#table-add-row {
text-decoration: none;
}
.table-rowcount { .table-rowcount {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -11,9 +11,11 @@
// to a non-empty object, return it as-is. // to a non-empty object, return it as-is.
// //
// 2. File-backed walk (the real-world path served by zddc-server): // 2. File-backed walk (the real-world path served by zddc-server):
// fetch <dir>/.zddc, find tables[<name>], fetch the *.table.yaml // page is at /<dir>/table.html — fetch <dir>/table.yaml,
// spec, list <dir>/<name>/*.yaml row files, parse each, and // list every other *.yaml in <dir> as a row file (filtering
// assemble the same shape. // out table.yaml and form.yaml so they don't appear as rows),
// parse each, and assemble the same shape. The whole table
// lives in one directory.
// //
// file:// mode without a directory handle is unsupported in v1 — the // file:// mode without a directory handle is unsupported in v1 — the
// walk only runs against http(s). file:// users must either inject an // walk only runs against http(s). file:// users must either inject an
@ -75,20 +77,18 @@
} }
const dir = probe.handle; const dir = probe.handle;
const zddcDoc = await readYaml(dir, '.zddc'); // Spec lives at <currentdir>/table.yaml — the page URL is
const tablesMap = (zddcDoc && zddcDoc.tables) || {}; // <currentdir>/table.html, so the spec is right next door.
const specRel = tablesMap[tableName]; const spec = await readYaml(dir, 'table.yaml');
if (!specRel) {
throw new Error('No tables.' + tableName + ' declared in .zddc');
}
const spec = await readYaml(dir, stripDotSlash(specRel));
if (!spec || !Array.isArray(spec.columns)) { if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec ' + specRel + ' missing columns[]'); throw new Error('Spec table.yaml missing columns[]');
} }
const rowsRel = stripDotSlash(spec.rows || ('./' + tableName)); // Rows are every *.yaml in <currentdir> EXCEPT the spec
const rowsDir = await resolveDirectory(dir, rowsRel); // (table.yaml) and the row-edit form (form.yaml). They live
const rows = await readRows(rowsDir, rowsRel, tableName); // in the same directory by design — copying the directory
// copies the whole table.
const rows = await readRows(dir, '', tableName);
return { return {
title: spec.title, title: spec.title,
@ -100,7 +100,9 @@
} }
function tableNameFromUrl(pathname) { function tableNameFromUrl(pathname) {
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/); // /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
return m ? m[1] : null; return m ? m[1] : null;
} }
@ -146,16 +148,19 @@
return cur; return cur;
} }
async function readRows(rowsDir, rowsRel, tableName) { async function readRows(rowsDir, _rowsRel, _tableName) {
const rows = []; const rows = [];
for await (const entry of rowsDir.values()) { for await (const entry of rowsDir.values()) {
if (entry.kind !== 'file') continue; if (entry.kind !== 'file') continue;
if (!entry.name.endsWith('.yaml')) continue; if (!entry.name.endsWith('.yaml')) continue;
// Skip the spec and the row-edit form — they live alongside
// the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try { try {
const file = await (await rowsDir.getFileHandle(entry.name)).getFile(); const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
const data = window.jsyaml.load(await file.text()); const data = window.jsyaml.load(await file.text());
rows.push({ rows.push({
url: rowEditUrl(rowsRel, tableName, entry.name), url: rowEditUrl(entry.name),
data: data || {}, data: data || {},
editable: true editable: true
}); });
@ -166,14 +171,12 @@
return rows; return rows;
} }
// Build the form-handler URL for editing one row. The page is at // Re-edit URL for one row. Page is at /<dir>/table.html; row file
// <dir>/<tableName>.table.html; the row file lives at // lives at /<dir>/<basename>.yaml; form re-edit URL is
// <dir>/<rowsRel>/<basename>.yaml; the form re-edit URL is // /<dir>/<basename>.yaml.html — same directory.
// <dir>/<rowsRel>/<basename>.yaml.html. function rowEditUrl(rowFileName) {
function rowEditUrl(rowsRel, tableName, rowFileName) { const pageDir = location.pathname.replace(/\/table\.html$/, '/');
const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/'); return pageDir + rowFileName + '.html';
const rowsPath = pageDir + (rowsRel || tableName) + '/';
return rowsPath + rowFileName + '.html';
} }
app.modules.context = { load: load }; app.modules.context = { load: load };

View file

@ -5,13 +5,17 @@
// - free-text: { kind: 'contains', value: '<string>' } // - free-text: { kind: 'contains', value: '<string>' }
// - enum: { kind: 'enum', value: ['<choice>', ...] } // - enum: { kind: 'enum', value: ['<choice>', ...] }
// An empty value (empty string or empty array) matches everything. // An empty value (empty string or empty array) matches everything.
//
// The render layer only emits the free-text shape; enum is kept here
// for back-compat with any inline-context test fixtures that seed
// filter state directly. defaultFilterFor always returns text.
function isEnumColumn(col) { function isEnumColumn(col) {
return Array.isArray(col.enum) && col.enum.length > 0; return Array.isArray(col.enum) && col.enum.length > 0;
} }
function defaultFilterFor(col) { function defaultFilterFor(_col) {
return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' }; return { kind: 'contains', value: '' };
} }
function rowMatches(filter, cellValue) { function rowMatches(filter, cellValue) {

View file

@ -2,6 +2,12 @@
'use strict'; 'use strict';
async function init() { async function init() {
// Both apps (table + form) ship in the same bundle. Skip if
// mode dispatcher said this isn't our mode — form-mode requests
// are handled by formApp.
if (window.zddcMode === 'form') {
return;
}
const ctx = await app.modules.context.load(); const ctx = await app.modules.context.load();
app.context = ctx; app.context = ctx;
@ -23,6 +29,22 @@
const emptyEl = document.getElementById('table-empty'); const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount'); const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters'); const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
// Add-row button: link to <name>.form.html, the form-system's
// empty-form URL for this table's row schema. POST creates a
// new submission and the server redirects to the row's edit
// URL. Hidden when we can't derive a table name from the
// pathname (e.g. inline-context test harness opening tables.html
// directly without a *.table.html URL).
if (addRowBtn) {
// Page is at <dir>/table.html; the row-creation form is at
// <dir>/form.html — same directory, just swap the basename.
if (/\/table\.html$/.test(location.pathname || '')) {
addRowBtn.href = 'form.html';
addRowBtn.hidden = false;
}
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : []; const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : []; const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
@ -40,14 +62,12 @@
if (seeded == null) { if (seeded == null) {
continue; continue;
} }
if (app.modules.filters.isEnumColumn(col)) { // Filter UI is uniformly text-contains. If the spec
state.filter[col.field] = { // seeds an array (legacy enum-style), coerce to a
kind: 'enum', // comma-joined contains string — partial match on any
value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)] // listed value still narrows the table sensibly.
}; const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
} else { state.filter[col.field] = { kind: 'contains', value: seedStr };
state.filter[col.field] = { kind: 'contains', value: String(seeded) };
}
} }
} }

76
tables/js/mode.js Normal file
View file

@ -0,0 +1,76 @@
// mode.js — picks table-mode vs form-mode at boot time and unhides the
// matching container. Both apps (tablesApp, formApp) ship in the same
// bundle but each only paints when its container is visible.
//
// Decision rule:
// /<dir>/table.html → table mode
// /<dir>/form.html → form mode (empty / create)
// /<dir>/<id>.yaml.html → form mode (re-edit)
// anything else / file:// → table mode (legacy default; tables tool
// was the original consumer of this bundle)
//
// In offline / file:// mode the inline-context placeholders decide:
// whichever blob is non-empty wins. Tests that inject only
// #form-context render in form mode; tests that inject only
// #table-context render in table mode.
(function () {
'use strict';
function modeFromUrl() {
const path = String((typeof location !== 'undefined' && location.pathname) || '');
if (/\/form\.html$/.test(path) || /\.yaml\.html$/.test(path)) {
return 'form';
}
if (/\/table\.html$/.test(path)) {
return 'table';
}
return null; // unknown — will be decided once DOM is parsed.
}
function readInline(id) {
const el = document.getElementById(id);
if (!el) return null;
try {
return JSON.parse(el.textContent || '{}');
} catch (_) {
return null;
}
}
function modeFromInline() {
// file:// or unrecognised URL — whichever inline-context blob is
// non-empty wins. Tests that inject only #form-context render in
// form mode; tests that inject only #table-context render in
// table mode. Default to table for legacy compatibility.
const formCtx = readInline('form-context');
if (formCtx && Object.keys(formCtx).length > 0) {
return 'form';
}
return 'table';
}
// Best-effort synchronous decision so per-app boot guards can read
// window.zddcMode without waiting for DOM. URL-based decision is
// always known up-front; inline-context fallback only matters for
// file:// and is finalized at DOMContentLoaded.
window.zddcMode = modeFromUrl() || 'table';
function activate() {
if (modeFromUrl() == null) {
window.zddcMode = modeFromInline();
}
const tableEl = document.getElementById('table-mode');
const formEl = document.getElementById('form-mode');
if (window.zddcMode === 'form' && formEl) {
formEl.hidden = false;
} else if (tableEl) {
tableEl.hidden = false;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', activate, { once: true });
} else {
activate();
}
})();

View file

@ -22,45 +22,23 @@
titleRow.appendChild(th); titleRow.appendChild(th);
const td = util.h('td', { className: 'zddc-table__filter-cell' }); const td = util.h('td', { className: 'zddc-table__filter-cell' });
// Every column gets the same text-contains filter input, even
// enum columns — keeps the filter row visually uniform and
// doesn't constrain users to picking from the enum (a
// case-insensitive substring match works for both free-text
// and enum data).
const f = filterMap[col.field] || filters.defaultFilterFor(col); const f = filterMap[col.field] || filters.defaultFilterFor(col);
if (filters.isEnumColumn(col)) { const input = util.h('input', {
const select = util.h('select', { type: 'text',
multiple: true, className: 'zddc-table__filter-text',
'aria-label': 'Filter ' + (col.title || col.field), placeholder: 'filter…',
className: 'zddc-table__filter-enum', 'aria-label': 'Filter ' + (col.title || col.field),
onChange: function (ev) { value: typeof f.value === 'string' ? f.value : '',
const opts = ev.target.options; onInput: function (ev) {
const picked = []; onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
for (let j = 0; j < opts.length; j++) {
if (opts[j].selected) {
picked.push(opts[j].value);
}
}
onFilterChange(col.field, { kind: 'enum', value: picked });
}
});
for (let j = 0; j < col.enum.length; j++) {
const v = col.enum[j];
const opt = util.h('option', { value: v }, v);
if (Array.isArray(f.value) && f.value.indexOf(v) !== -1) {
opt.selected = true;
}
select.appendChild(opt);
} }
td.appendChild(select); });
} else { td.appendChild(input);
const input = util.h('input', {
type: 'text',
className: 'zddc-table__filter-text',
placeholder: 'filter…',
'aria-label': 'Filter ' + (col.title || col.field),
value: typeof f.value === 'string' ? f.value : '',
onInput: function (ev) {
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
}
});
td.appendChild(input);
}
filterRow.appendChild(td); filterRow.appendChild(td);
} }

View file

@ -31,7 +31,8 @@
</div> </div>
</header> </header>
<main class="table-main"> <!-- Table mode: shown for /<dir>/table.html requests. -->
<main id="table-mode" class="table-main" hidden>
<div id="table-description" class="table-description" hidden></div> <div id="table-description" class="table-description" hidden></div>
<div id="table-status" class="table-status" hidden></div> <div id="table-status" class="table-status" hidden></div>
<div class="table-toolbar" id="table-toolbar"> <div class="table-toolbar" id="table-toolbar">
@ -39,6 +40,9 @@
<span id="table-rowcount" class="table-rowcount" aria-live="polite"></span> <span id="table-rowcount" class="table-rowcount" aria-live="polite"></span>
<button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button> <button type="button" id="table-clear-filters" class="btn btn-secondary btn-sm" hidden>Clear filters</button>
</div> </div>
<div class="table-toolbar__right">
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div> </div>
<div class="table-scroll"> <div class="table-scroll">
<table id="table-root" class="zddc-table" aria-describedby="table-description"> <table id="table-root" class="zddc-table" aria-describedby="table-description">
@ -49,6 +53,18 @@
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div> <div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
</main> </main>
<!-- Form mode: shown for /<dir>/form.html and /<dir>/<id>.yaml.html
requests. Same bundle ships both modes so a row's "+ Add row"
and click-to-edit reuse the table tool's spec, validator, and
file-IO instead of duplicating them in a separate form HTML. -->
<main id="form-mode" class="form-main" hidden>
<div id="form-status" class="form-status" hidden></div>
<form id="form-root" class="form-root" novalidate></form>
<div class="form-actions">
<button type="button" id="submit-btn" class="btn btn-primary">Submit</button>
</div>
</main>
<!-- Help Panel --> <!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title"> <aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header"> <div class="help-panel__header">
@ -101,6 +117,12 @@
--> -->
<script id="table-context" type="application/json">{}</script> <script id="table-context" type="application/json">{}</script>
<!--
Form mode context — server injects this for /<dir>/form.html and
/<dir>/<id>.yaml.html. Empty in table-mode renders.
-->
<script id="form-context" type="application/json">{}</script>
<script> <script>
{{JS_PLACEHOLDER}} {{JS_PLACEHOLDER}}
</script> </script>

View file

@ -3,7 +3,12 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
const HTML_PATH = path.resolve('form/dist/form.html'); // Form mode is hosted by the unified tables.html bundle — same bytes the
// server returns for /<dir>/form.html and /<dir>/<id>.yaml.html. Loading
// tables/dist/tables.html via file:// (named form.html in the temp dir
// so the URL pathname triggers form-mode in the dispatcher) is the
// closest offline mirror of what online callers actually receive.
const HTML_PATH = path.resolve('tables/dist/tables.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8'); const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
const SAFETY_SCHEMA = { const SAFETY_SCHEMA = {
@ -82,7 +87,8 @@ test.describe('form/ — safety check-in renderer', () => {
await expect(radios).toHaveCount(3); // Site A / B / C await expect(radios).toHaveCount(3); // Site A / B / C
await expect(page.locator('#form-root textarea')).toHaveCount(1); await expect(page.locator('#form-root textarea')).toHaveCount(1);
await expect(page.locator('#form-root .form-array__add')).toHaveCount(1); await expect(page.locator('#form-root .form-array__add')).toHaveCount(1);
await expect(page.locator('#form-title')).toContainText('Safety Check-In'); // Title element is shared across modes in the unified bundle.
await expect(page.locator('#table-title')).toContainText('Safety Check-In');
}); });
test('add/remove hazard rows works', async ({ page }) => { test('add/remove hazard rows works', async ({ page }) => {

View file

@ -128,16 +128,20 @@ test.describe('tables/ — directory-of-YAML table view', () => {
await expect(page.locator('#table-root tbody tr')).toHaveCount(2); await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
}); });
test('enum filter limits rows to selected values', async ({ page }) => { test('text filter on an enum column does substring match', async ({ page }) => {
// Filter row is uniformly text-contains across all columns —
// even for columns declared with `enum:` in the spec. Enum
// metadata still informs validation/sort but not filter UI.
await loadTableWithContext(page, { await loadTableWithContext(page, {
columns: MDL_COLUMNS, columns: MDL_COLUMNS,
rows: ROWS, rows: ROWS,
}); });
await page.waitForSelector('#table-root tbody tr'); await page.waitForSelector('#table-root tbody tr');
// Status column is enum; pick "pending" only. const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
const statusSelect = page.locator('.zddc-table__filter-row select').nth(1); // 0=party, 1=status // Spec columns: 0=id, 1=title, 2=party, 3=dueDate, 4=status.
await statusSelect.selectOption({ value: 'pending' }); const statusInput = filterInputs.nth(4);
await statusInput.fill('pending');
await expect(page.locator('#table-root tbody tr')).toHaveCount(2); await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
}); });

View file

@ -874,11 +874,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
switch apps.DefaultAppAt(cfg.Root, absPath) { switch apps.DefaultAppAt(cfg.Root, absPath) {
case "tables": case "tables":
// Tables aren't an apps-subsystem app — the table // Tables aren't an apps-subsystem app — the table
// handler responds to <dir>/<name>.table.html. Serve // handler responds to /<dir>/table.html. Serve the
// the equivalent table view inline at the bare-mdl // equivalent table view inline at the bare URL by
// URL by routing through the handler with the // synthesizing the canonical /table.html suffix.
// canonical .table.html name appended. synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+".table.html"); tr != nil { if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
handler.ServeTable(cfg, tr, w, r) handler.ServeTable(cfg, tr, w, r)
return return
} }

View file

@ -436,7 +436,7 @@ func TestDispatchSlashRouting(t *testing.T) {
// Trailing-slash form on a tables rows-dir bounces to the canonical // Trailing-slash form on a tables rows-dir bounces to the canonical
// .table.html URL so users land on the table view rather than a // .table.html URL so users land on the table view rather than a
// browse listing of the row-yaml files. // browse listing of the row-yaml files.
{"archive/<party>/mdl slash → 302 .table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl.table.html"}, {"archive/<party>/mdl slash → 302 in-dir table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl/table.html"},
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""}, {"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""}, {"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""}, {"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},

View file

@ -85,7 +85,9 @@ func inAncestorWithName(root, requestDir string, names ...string) bool {
// //
// The mdl rule wins over the broader archive rule because the table // The mdl rule wins over the broader archive rule because the table
// editor is a more specific surface for browsing planned deliverables // editor is a more specific surface for browsing planned deliverables
// than the archive index. // 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 // requestDir and root are absolute filesystem paths; requestDir must
// be under root (otherwise "" is returned). // be under root (otherwise "" is returned).

View file

@ -129,29 +129,27 @@ func TestServeDirectoryRootIsPublic(t *testing.T) {
} }
// TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a // TestServeDirectoryRedirectsTableRowsDir asserts that an HTML GET on a
// directory that is the rows-dir of a registered table bounces to the // directory containing a table.yaml bounces to that dir's table.html URL
// canonical <parent>/<name>.table.html URL. JSON GETs on the same URL // (in-dir convention: /<dir>/table.html serves the table view, the row
// fall through to the listing so the table client can still enumerate // YAMLs are siblings of table.yaml). JSON GETs fall through to the
// row files. // listing so the table client can still enumerate row files.
func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) {
root := t.TempDir() root := t.TempDir()
working := filepath.Join(root, "Working") mdlDir := filepath.Join(root, "Working", "MDL")
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"),
[]byte(sampleTableSpec), 0o644); err != nil { []byte(sampleTableSpec), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"),
[]byte(sampleRowFormSpec), 0o644); err != nil { []byte(sampleRowFormSpec), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`acl: if err := os.WriteFile(filepath.Join(root, "Working", ".zddc"), []byte(`acl:
permissions: permissions:
"*@example.com": rwcda "*@example.com": rwcda
tables:
MDL: ./MDL.table.yaml
`), 0o644); err != nil { `), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -159,7 +157,7 @@ tables:
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
t.Run("HTML GET on rows-dir redirects to .table.html", func(t *testing.T) { t.Run("HTML GET on dir with table.yaml redirects to table.html", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil) req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
req.Header.Set("Accept", "text/html") req.Header.Set("Accept", "text/html")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
@ -169,12 +167,12 @@ tables:
if rec.Code != http.StatusFound { if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
} }
if got, want := rec.Header().Get("Location"), "/Working/MDL.table.html"; got != want { if got, want := rec.Header().Get("Location"), "/Working/MDL/table.html"; got != want {
t.Errorf("Location = %q, want %q", got, want) t.Errorf("Location = %q, want %q", got, want)
} }
}) })
t.Run("JSON GET on rows-dir falls through to listing", func(t *testing.T) { t.Run("JSON GET on dir with table.yaml falls through to listing", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil) req := httptest.NewRequest(http.MethodGet, "/Working/MDL/", nil)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "casey@example.com"))
@ -190,9 +188,8 @@ tables:
}) })
t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) { t.Run("HTML GET on plain dir is not redirected", func(t *testing.T) {
// Sibling of the rows dir — same parent .zddc, but the dir name // Sibling dir without a table.yaml — no redirect.
// "Other" isn't declared as a table key. if err := os.MkdirAll(filepath.Join(root, "Working", "Other"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(working, "Other"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil) req := httptest.NewRequest(http.MethodGet, "/Working/Other/", nil)
@ -208,8 +205,8 @@ tables:
} }
// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback: // TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback:
// archive/<party>/mdl/ with no operator .zddc declaration still redirects // archive/<party>/mdl/ with no on-disk table.yaml still redirects
// to <party>/mdl.table.html (the table handler serves embedded defaults). // to mdl/table.html (the table handler serves embedded defaults).
func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) { func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), if err := os.WriteFile(filepath.Join(root, ".zddc"),
@ -233,7 +230,7 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) {
if rec.Code != http.StatusFound { if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String())
} }
if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl.table.html"; got != want { if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl/table.html"; got != want {
t.Errorf("Location = %q, want %q", got, want) t.Errorf("Location = %q, want %q", got, want)
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,6 @@
package handler package handler
import ( import (
_ "embed"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -39,8 +38,17 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
//go:embed form.html // Form-mode rendering shares the unified `tables.html` bundle: one HTML
var embeddedFormHTML []byte // hosts both apps (tablesApp, formApp) so the table view's "+ Add row"
// link, the empty-form / create URL, and the row-edit URL all return
// the same bytes. A small mode dispatcher in the bundle picks which
// app paints based on the URL pattern. This eliminates a second
// embedded HTML and lets future editable-cell mode reuse the form
// validator + write path without IPC across two SPAs.
//
// formRenderHTML is the source of bytes for serveFormRender; it's the
// same package var as embeddedTablesHTML (declared in tablehandler.go).
func formRenderHTML() []byte { return embeddedTablesHTML }
// FormSpec is the YAML envelope of a <name>.form.yaml file. // FormSpec is the YAML envelope of a <name>.form.yaml file.
// //
@ -97,17 +105,40 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
} }
underlying := strings.TrimSuffix(urlPath, ".html") underlying := strings.TrimSuffix(urlPath, ".html")
// Form-spec URL: <name>.form.html → spec at <name>.form.yaml. // specEligible accepts a spec path that exists on disk OR matches
// Data URL: <name>/<id>.yaml.html → underlying ends in .yaml → spec at // the default-MDL virtual-fallback shape at archive/<party>/mdl/.
// <name>.form.yaml at the parent's parent. // Without this, the default-MDL row form would 404 on a fresh
if strings.HasSuffix(underlying, ".form") { // archive even though the table view renders.
// <name>.form.html — empty form / create. specEligible := func(specAbs string) bool {
specRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) + ".yaml" if fileExists(specAbs) {
specAbs := filepath.Join(fsRoot, specRel) return true
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { }
if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
return true
}
return false
}
// In-dir convention: spec, form, and rows all live in one
// directory. URLs:
// /<dir>/form.html — empty form / create
// /<dir>/<id>.yaml.html — re-edit / update one row
// Spec is always <dir>/form.yaml relative to the URL.
if strings.HasSuffix(underlying, "/form") || underlying == "/form" {
// /<dir>/form.html — empty form / create.
dirRel := strings.TrimSuffix(strings.TrimPrefix(underlying, "/"), "/form")
dirRel = strings.TrimSuffix(dirRel, "form") // root case "/form" → ""
dirRel = strings.Trim(dirRel, "/")
if dirRel == "" {
// /form.html at root has no rows-dir to bind a spec to.
return nil return nil
} }
if !fileExists(specAbs) { dirAbs := filepath.Join(fsRoot, filepath.FromSlash(dirRel))
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
return nil
}
specAbs := filepath.Join(dirAbs, "form.yaml")
if !specEligible(specAbs) {
return nil return nil
} }
kind := "render-empty" kind := "render-empty"
@ -122,19 +153,15 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
} }
if strings.HasSuffix(underlying, ".yaml") { if strings.HasSuffix(underlying, ".yaml") {
// <name>/<id>.yaml.html — re-edit / update. // /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
// SAME directory as the row file (<dir>/form.yaml).
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel) dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
return nil return nil
} }
// Spec lives at the parent's parent: <dir>/<name>/<id>.yaml → specPath := filepath.Join(filepath.Dir(dataAbs), "form.yaml")
// <dir>/<name>.form.yaml. if !specEligible(specPath) {
parentDir := filepath.Dir(dataAbs)
formName := filepath.Base(parentDir)
grandparent := filepath.Dir(parentDir)
specPath := filepath.Join(grandparent, formName+".form.yaml")
if !fileExists(specPath) {
return nil return nil
} }
kind := "render-edit" kind := "render-edit"
@ -193,12 +220,12 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
return return
} }
if len(embeddedFormHTML) == 0 { if len(formRenderHTML()) == 0 {
http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable) http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
return return
} }
spec, err := loadFormSpec(req.SpecPath) spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil { if err != nil {
slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err) slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
@ -232,7 +259,7 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
Errors: validationErrs, Errors: validationErrs,
} }
html, err := injectFormContext(embeddedFormHTML, ctx) html, err := injectFormContext(formRenderHTML(), ctx)
if err != nil { if err != nil {
http.Error(w, "render: "+err.Error(), http.StatusInternalServerError) http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
return return
@ -250,15 +277,18 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
return return
} }
formName := strings.TrimSuffix(filepath.Base(req.SpecPath), ".form.yaml") // In-dir convention: spec, form, and rows live in one directory.
specDir := filepath.Dir(req.SpecPath) // New submissions land alongside the spec; submissionsDir IS the
submissionsDir := filepath.Join(specDir, formName) // directory holding form.yaml.
submissionsDir := filepath.Dir(req.SpecPath)
// ACL: write-rights at submissions dir. The dir may not exist yet; the // ACL: write-rights at the directory where the row YAML will land.
// cascade chain falls back to the parent. // In the default-MDL fallback case the directory may not exist
// yet; cascade up to the closest existing ancestor for the policy
// chain.
gateDir := submissionsDir gateDir := submissionsDir
if !fileExists(submissionsDir) { if !fileExists(submissionsDir) {
gateDir = specDir gateDir = filepath.Dir(submissionsDir)
} }
chain, err := zddc.EffectivePolicy(cfg.Root, gateDir) chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
if err != nil { if err != nil {
@ -275,7 +305,7 @@ func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
return return
} }
spec, err := loadFormSpec(req.SpecPath) spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil { if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return return
@ -358,7 +388,7 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
return return
} }
spec, err := loadFormSpec(req.SpecPath) spec, err := loadFormSpec(cfg.Root, req.SpecPath)
if err != nil { if err != nil {
http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
return return
@ -386,10 +416,23 @@ func serveFormUpdate(cfg config.Config, req *FormRequest, w http.ResponseWriter,
// --- Helpers ----------------------------------------------------------------- // --- Helpers -----------------------------------------------------------------
func loadFormSpec(path string) (*FormSpec, error) { func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err // Default-MDL virtual fallback: when the operator hasn't placed
// an mdl.form.yaml under archive/<party>/, serve the embedded
// default. Mirrors the static-handler fallback for direct YAML
// fetches so the form recognizer and the loader agree on what
// "this spec exists" means.
if os.IsNotExist(err) {
if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
data = bytes
} else {
return nil, err
}
} else {
return nil, err
}
} }
var spec FormSpec var spec FormSpec
if err := yaml.Unmarshal(data, &spec); err != nil { if err := yaml.Unmarshal(data, &spec); err != nil {

View file

@ -46,12 +46,13 @@ func formTestSetup(t *testing.T, zddcFiles map[string]string) (config.Config, fu
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
// Always seed the form spec at /Working/safety.form.yaml. // Always seed the form spec at /Working/safety/form.yaml — in-dir
working := filepath.Join(root, "Working") // convention puts the spec inside the rows-dir alongside row YAMLs.
if err := os.MkdirAll(working, 0o755); err != nil { safetyDir := filepath.Join(root, "Working", "safety")
if err := os.MkdirAll(safetyDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
specPath := filepath.Join(working, "safety.form.yaml") specPath := filepath.Join(safetyDir, "form.yaml")
if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil { if err := os.WriteFile(specPath, []byte(sampleFormSpec), 0o644); err != nil {
t.Fatalf("write spec: %v", err) t.Fatalf("write spec: %v", err)
} }
@ -99,7 +100,7 @@ func TestRecognizeFormRequest(t *testing.T) {
if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Working", "safety"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(root, "Working", "safety.form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "Working", "safety", "form.yaml"), []byte("schema:\n type: object\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "Working", "safety", "2026-05-01-casey.yaml"), []byte("date: 2026-05-01\n"), 0o644); err != nil {
@ -107,25 +108,25 @@ func TestRecognizeFormRequest(t *testing.T) {
} }
cases := []struct { cases := []struct {
method, url string method, url string
wantKind string // "" means expect nil wantKind string // "" means expect nil
wantSpec string wantSpec string
wantData string wantData string
}{ }{
{"GET", "/Working/safety.form.html", "render-empty", "Working/safety.form.yaml", ""}, {"GET", "/Working/safety/form.html", "render-empty", "Working/safety/form.yaml", ""},
{"POST", "/Working/safety.form.html", "create", "Working/safety.form.yaml", ""}, {"POST", "/Working/safety/form.html", "create", "Working/safety/form.yaml", ""},
{"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, {"GET", "/Working/safety/2026-05-01-casey.yaml.html", "render-edit", "Working/safety/form.yaml", "Working/safety/2026-05-01-casey.yaml"},
{"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety.form.yaml", "Working/safety/2026-05-01-casey.yaml"}, {"POST", "/Working/safety/2026-05-01-casey.yaml.html", "update", "Working/safety/form.yaml", "Working/safety/2026-05-01-casey.yaml"},
// No spec → not a form request. // No spec → not a form request.
{"GET", "/Working/missing.form.html", "", "", ""}, {"GET", "/Working/missing/form.html", "", "", ""},
// Bare .yaml (not .yaml.html) → not a form request, falls through to static. // Bare .yaml (not .yaml.html) → not a form request, falls through to static.
{"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""}, {"GET", "/Working/safety/2026-05-01-casey.yaml", "", "", ""},
// Random .html → falls through. // Random .html → falls through.
{"GET", "/index.html", "", "", ""}, {"GET", "/index.html", "", "", ""},
// Wrong method. // Wrong method.
{"DELETE", "/Working/safety.form.html", "", "", ""}, {"DELETE", "/Working/safety/form.html", "", "", ""},
// Path traversal attempt. // Path traversal attempt.
{"GET", "/../etc/passwd.form.html", "", "", ""}, {"GET", "/../etc/passwd/form.html", "", "", ""},
} }
for _, tc := range cases { for _, tc := range cases {
@ -159,13 +160,78 @@ func TestRecognizeFormRequest(t *testing.T) {
} }
} }
// TestRecognizeFormRequest_DefaultMdlAtArchiveParty: archive/<party>/mdl/
// is the one place a form can be served fully virtually — the embedded
// default form.yaml fills in for a missing on-disk spec so the
// "+ Add row" link from the default-MDL table view resolves on a fresh
// archive. Outside archive/<party>/mdl/ the recognizer still requires
// a real spec on disk.
func TestRecognizeFormRequest_DefaultMdlAtArchiveParty(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA"))
// Empty form / create at archive/<party>/mdl/form.html — no spec
// on disk, no mdl/ dir on disk, default-MDL fallback applies.
got := RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/form.html")
if got == nil || got.Kind != "render-empty" {
t.Fatalf("GET mdl/form.html: got %+v want render-empty via default-MDL fallback", got)
}
if got.SpecPath != filepath.Join(root, "Project", "archive", "PartyA", "mdl", "form.yaml") {
t.Errorf("SpecPath = %q", got.SpecPath)
}
// POST → create.
got = RecognizeFormRequest(root, "POST", "/Project/archive/PartyA/mdl/form.html")
if got == nil || got.Kind != "create" {
t.Fatalf("POST mdl/form.html: got %+v want create", got)
}
// Re-edit (<id>.yaml.html) at archive/<party>/mdl/ — same default
// spec applies. The data file itself must exist on disk; the spec
// is the embedded default in the same directory.
mustMkdir(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl"))
mustWrite(t, filepath.Join(root, "Project", "archive", "PartyA", "mdl", "row-001.yaml"), "trackingNumber: TR-001\n")
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/mdl/row-001.yaml.html")
if got == nil || got.Kind != "render-edit" {
t.Fatalf("GET row-001.yaml.html: got %+v want render-edit", got)
}
// NOT at archive/<party>/mdl/ — default doesn't apply, still 404.
got = RecognizeFormRequest(root, "GET", "/Project/Working/mdl/form.html")
if got != nil {
t.Errorf("Working/mdl/form.html: got %+v want nil (no default outside archive-party-mdl)", got)
}
// Non-mdl directory at archive/<party>/ — no default for arbitrary names.
got = RecognizeFormRequest(root, "GET", "/Project/archive/PartyA/safety/form.html")
if got != nil {
t.Errorf("safety/form.html: got %+v want nil (only mdl has a default)", got)
}
}
// mustMkdir / mustWrite — local copies (the cmd/zddc-server package
// has them but they're test-internal there).
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
}
func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestRenderEmptyForm(t *testing.T) { func TestRenderEmptyForm(t *testing.T) {
_, do := formTestSetup(t, map[string]string{ _, do := formTestSetup(t, map[string]string{
"": `acl: "": `acl:
allow: ["*@example.com"] allow: ["*@example.com"]
`, `,
}) })
rec := do(http.MethodGet, "/Working/safety.form.html", "casey@example.com", "") rec := do(http.MethodGet, "/Working/safety/form.html", "casey@example.com", "")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
} }
@ -189,7 +255,7 @@ func TestRenderEmptyForm_ACLDeny(t *testing.T) {
allow: ["root@example.com"] allow: ["root@example.com"]
`, `,
}) })
rec := do(http.MethodGet, "/Working/safety.form.html", "stranger@example.com", "") rec := do(http.MethodGet, "/Working/safety/form.html", "stranger@example.com", "")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
} }
@ -203,7 +269,7 @@ func TestCreateSubmission_Valid(t *testing.T) {
}) })
body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}` body := `{"date":"2026-05-01","location":"Site A","severity":3,"notes":"all clear"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) rec := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d want 201; body = %s", rec.Code, rec.Body.String())
} }
@ -244,7 +310,7 @@ func TestCreateSubmission_Invalid_Returns422(t *testing.T) {
// Missing required `location`, severity out of range. // Missing required `location`, severity out of range.
body := `{"date":"2026-05-01","severity":99}` body := `{"date":"2026-05-01","severity":99}`
rec := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) rec := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
if rec.Code != http.StatusUnprocessableEntity { if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d want 422; body = %s", rec.Code, rec.Body.String())
} }
@ -280,7 +346,7 @@ func TestCreateSubmission_ACLDeny(t *testing.T) {
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` body := `{"date":"2026-05-01","location":"Site A"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "stranger@example.com", body) rec := do(http.MethodPost, "/Working/safety/form.html", "stranger@example.com", body)
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
} }
@ -293,7 +359,7 @@ func TestCreateSubmission_NoAuth_Returns401(t *testing.T) {
`, `,
}) })
body := `{"date":"2026-05-01","location":"Site A"}` body := `{"date":"2026-05-01","location":"Site A"}`
rec := do(http.MethodPost, "/Working/safety.form.html", "", body) rec := do(http.MethodPost, "/Working/safety/form.html", "", body)
if rec.Code != http.StatusUnauthorized { if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String()) t.Errorf("status = %d want 401; body = %s", rec.Code, rec.Body.String())
} }
@ -307,11 +373,11 @@ func TestCreateSubmission_FilenameCollision(t *testing.T) {
}) })
body := `{"date":"2026-05-01","location":"Site A"}` body := `{"date":"2026-05-01","location":"Site A"}`
first := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) first := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
if first.Code != http.StatusCreated { if first.Code != http.StatusCreated {
t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String()) t.Fatalf("first submit: status = %d; body = %s", first.Code, first.Body.String())
} }
second := do(http.MethodPost, "/Working/safety.form.html", "casey@example.com", body) second := do(http.MethodPost, "/Working/safety/form.html", "casey@example.com", body)
if second.Code != http.StatusCreated { if second.Code != http.StatusCreated {
t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String()) t.Fatalf("second submit: status = %d; body = %s", second.Code, second.Body.String())
} }

View file

@ -55,25 +55,29 @@ func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes. // DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm } func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request // IsDefaultMdlSpec reports whether urlPath is one of the default-MDL
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/ // virtual files served when no operator file exists on disk:
// where no operator file exists. Caller is the static-file handler.
// //
// Returns the embedded bytes + true when the fallback should fire. // <project>/archive/<party>/mdl/table.yaml
// Returns nil + false when an operator-supplied file exists or the path // <project>/archive/<party>/mdl/form.yaml
// is not eligible for the fallback. //
// The MDL files live INSIDE the rows-dir along with row YAMLs so the
// whole directory is self-contained — copying mdl/ moves the spec,
// the form, and all rows together. Returns embedded bytes + true when
// the fallback should fire; nil + false when an operator-supplied
// file exists or the path is not eligible.
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) { func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
base := strings.ToLower(filepath.Base(urlPath)) base := strings.ToLower(filepath.Base(urlPath))
var bytes []byte var bytes []byte
switch base { switch base {
case "mdl.table.yaml": case "table.yaml":
bytes = embeddedDefaultMdlTable bytes = embeddedDefaultMdlTable
case "mdl.form.yaml": case "form.yaml":
bytes = embeddedDefaultMdlForm bytes = embeddedDefaultMdlForm
default: default:
return nil, false return nil, false
} }
if !isAtArchivePartyLevel(fsRoot, urlPath) { if !isAtArchivePartyMdlLevel(fsRoot, urlPath) {
return nil, false return nil, false
} }
// Operator file wins if it exists on disk. // Operator file wins if it exists on disk.
@ -88,6 +92,22 @@ func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
return bytes, true return bytes, true
} }
// IsDefaultMdlSpecAbs is the abs-path-keyed variant of IsDefaultMdlSpec.
// Used by handlers that hold a filesystem path rather than a URL.
// Returns the embedded default bytes + true when absPath is the
// virtual archive/<party>/{mdl.table.yaml, mdl.form.yaml} fallback.
func IsDefaultMdlSpecAbs(fsRoot, absPath string) ([]byte, bool) {
if !strings.HasPrefix(absPath, fsRoot+string(filepath.Separator)) && absPath != fsRoot {
return nil, false
}
rel, err := filepath.Rel(fsRoot, absPath)
if err != nil {
return nil, false
}
urlPath := "/" + filepath.ToSlash(rel)
return IsDefaultMdlSpec(fsRoot, urlPath)
}
// isAtArchivePartyLevel reports whether urlPath refers to a file // isAtArchivePartyLevel reports whether urlPath refers to a file
// directly under <project>/archive/<party>/ (depth-3 directory). The // directly under <project>/archive/<party>/ (depth-3 directory). The
// canonical-folder names are case-folded. // canonical-folder names are case-folded.
@ -101,6 +121,20 @@ func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
return strings.EqualFold(parts[1], "archive") return strings.EqualFold(parts[1], "archive")
} }
// isAtArchivePartyMdlLevel reports whether urlPath refers to a file
// directly under <project>/archive/<party>/mdl/ (depth-4 directory).
// Used by the default-MDL fallback after the spec/form moved INSIDE
// the rows-dir for self-containment.
func isAtArchivePartyMdlLevel(fsRoot, urlPath string) bool {
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
parts := strings.Split(rel, "/")
// <project>/archive/<party>/mdl/<file> = 5 segments
if len(parts) != 5 {
return false
}
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
}
// TableRequest describes a recognized table-system request. // TableRequest describes a recognized table-system request.
type TableRequest struct { type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables). // Name is the table's URL stem (the key declared in .zddc tables).
@ -113,31 +147,24 @@ type TableRequest struct {
Dir string Dir string
} }
// tableRowsRedirect reports the canonical .table.html URL to redirect // tableRowsRedirect reports the canonical /<dir>/table.html URL to
// to when (urlPath) names a directory that is the rows-dir of a // redirect to when (urlPath) names a directory that contains a
// registered table. Returns "" when no redirect should fire. // table.yaml (or matches the default-MDL fallback). Returns "" when
// no redirect should fire.
// //
// Recognition reuses RecognizeTableRequest by synthesizing the // Recognition reuses RecognizeTableRequest by synthesizing the
// equivalent <parent>/<name>.table.html URL from urlPath and asking // equivalent <urlPath>table.html and asking the recognizer whether
// the table-recognizer whether it's a real, declared (or default-MDL) // it's a real (or default-MDL) table. Single source of truth for
// table. This keeps validation in one place — operator-declared tables // validation.
// require both a `tables:` entry AND an existing spec file.
func tableRowsRedirect(fsRoot, urlPath string) string { func tableRowsRedirect(fsRoot, urlPath string) string {
// urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/". // urlPath is the directory request — e.g. "/proj/archive/Acme/mdl/".
trimmed := strings.TrimSuffix(urlPath, "/") if urlPath == "" || urlPath == "/" {
if trimmed == "" || trimmed == "/" {
return "" return ""
} }
slash := strings.LastIndex(trimmed, "/") if !strings.HasSuffix(urlPath, "/") {
if slash < 0 { urlPath += "/"
return ""
} }
parent := trimmed[:slash+1] // includes trailing slash synthesized := urlPath + "table.html"
name := trimmed[slash+1:]
if name == "" {
return ""
}
synthesized := parent + name + ".table.html"
if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil { if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil {
return "" return ""
} }
@ -146,9 +173,16 @@ func tableRowsRedirect(fsRoot, urlPath string) string {
// RecognizeTableRequest classifies r as a table-system request, or // RecognizeTableRequest classifies r as a table-system request, or
// returns nil if it falls through to other handlers. Discovery is // returns nil if it falls through to other handlers. Discovery is
// strictly .zddc-declarative — a *.table.html URL with no matching // presence-based and self-contained: a /<dir>/table.html URL fires
// `tables:` entry in <dir>/.zddc returns nil so it falls through to // when <dir>/table.yaml exists on disk, or when the default-MDL
// the static-file path (404 unless an operator dropped a real file). // fallback at archive/<party>/mdl/ applies.
//
// The spec, the row-edit form, and all rows live together in <dir>.
// Copying <dir> elsewhere copies everything needed to re-host the
// table — that's the whole point of the in-dir layout.
//
// The table's "name" is the directory's basename (so the URL
// /<parent>/mdl/table.html names the "mdl" table, with rows in mdl/).
// //
// Methods other than GET return nil — the table is read-only at the // Methods other than GET return nil — the table is read-only at the
// URL level. Writes go through the file API directly. // URL level. Writes go through the file API directly.
@ -156,64 +190,61 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
if method != http.MethodGet { if method != http.MethodGet {
return nil return nil
} }
if !strings.HasSuffix(urlPath, ".table.html") { if !strings.HasSuffix(urlPath, "/table.html") && urlPath != "/table.html" {
return nil return nil
} }
// Split <dir>/<name>.table.html into dir + name. // Strip /table.html — what remains is the rows-dir.
stem := strings.TrimSuffix(urlPath, ".table.html") rel := strings.TrimSuffix(strings.TrimPrefix(urlPath, "/"), "/table.html")
if stem == "" || stem == "/" { rel = strings.TrimSuffix(rel, "table.html") // handles "/table.html" at root → ""
rel = strings.Trim(rel, "/")
if rel == "" {
// /table.html at root has no rows-dir to name.
return nil return nil
} }
dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/"))) dirAbs := filepath.Join(fsRoot, filepath.FromSlash(rel))
name := filepath.Base(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
if name == "" || name == "." || name == "/" {
return nil
}
dirAbs := filepath.Join(fsRoot, dirRel)
if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot { if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
return nil return nil
} }
zddcPath := filepath.Join(dirAbs, ".zddc") name := filepath.Base(dirAbs)
zf, err := zddc.ParseFile(zddcPath)
if err != nil && !isNotExistError(err) { specAbs := filepath.Join(dirAbs, "table.yaml")
// Malformed .zddc — log and pass through; static handler will 500
// if it cares. Recognition just says "not a declared table here." // Presence-based discovery: <dir>/table.yaml on disk.
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err) if fileExists(specAbs) {
return nil return &TableRequest{Name: name, SpecPath: specAbs, Dir: dirAbs}
} }
if specRel, ok := zf.Tables[name]; ok {
// Operator explicitly declared this table — honour it strictly. // Default-MDL virtual-spec fallback at archive/<party>/mdl/. The
// If the declared spec file is missing, return nil so the URL // spec bytes come from IsDefaultMdlSpec via the static-file
// 404s rather than silently falling back to the default. This // dispatcher when no on-disk file exists at that path; the rows-dir
// keeps a typo in the operator's .zddc visible. // itself doesn't need to exist either (fully virtual archive).
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel)) if isAtArchivePartyMdlDir(fsRoot, dirAbs) {
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot { return &TableRequest{Name: "mdl", SpecPath: specAbs, Dir: dirAbs}
return nil
}
if !fileExists(specAbs) {
return nil
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
}
}
// No operator declaration — apply the default MDL spec fallback at
// archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
// the static-file dispatcher.
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
return &TableRequest{
Name: "mdl",
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
Dir: dirAbs,
}
} }
return nil return nil
} }
// isAtArchivePartyMdlDir reports whether dirAbs is exactly
// <fsRoot>/<project>/archive/<party>/mdl. Used by the default-MDL
// fallback to recognize the virtual rows-dir whether or not it
// exists on disk.
func isAtArchivePartyMdlDir(fsRoot, dirAbs string) bool {
rel, err := filepath.Rel(fsRoot, dirAbs)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
return false
}
parts := strings.Split(rel, "/")
if len(parts) != 4 {
return false
}
return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "mdl")
}
// isNotExistError reports whether err indicates a missing file. Local // isNotExistError reports whether err indicates a missing file. Local
// helper to avoid pulling errors.Is into the handler package. // helper to avoid pulling errors.Is into the handler package.
func isNotExistError(err error) bool { func isNotExistError(err error) bool {

View file

@ -16,8 +16,6 @@ import (
const sampleTableSpec = `title: Master Deliverables List const sampleTableSpec = `title: Master Deliverables List
description: Sample MDL. description: Sample MDL.
rowSchema: ./MDL.form.yaml
rows: ./MDL
columns: columns:
- field: id - field: id
title: ID title: ID
@ -47,12 +45,12 @@ schema:
enum: [pending, submitted, accepted] enum: [pending, submitted, accepted]
` `
// tableTestSetup writes a directory tree under a temp root with: // tableTestSetup writes a directory tree under a temp root with the
// in-dir layout:
// //
// <root>/Working/.zddc → declares tables: { MDL: ./MDL.table.yaml } // <root>/Working/MDL/table.yaml → spec
// <root>/Working/MDL.table.yaml → spec // <root>/Working/MDL/form.yaml → row schema
// <root>/Working/MDL.form.yaml → row schema // <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
// <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
// //
// Optional extra .zddc files at relative paths can be supplied via zddcFiles. // Optional extra .zddc files at relative paths can be supplied via zddcFiles.
// Returns (config, do) where do dispatches a request through ServeTable via // Returns (config, do) where do dispatches a request through ServeTable via
@ -65,18 +63,18 @@ func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]s
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
working := filepath.Join(root, "Working") mdlDir := filepath.Join(root, "Working", "MDL")
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
t.Fatalf("write spec: %v", err) t.Fatalf("write spec: %v", err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
t.Fatalf("write form spec: %v", err) t.Fatalf("write form spec: %v", err)
} }
for name, body := range rows { for name, body := range rows {
if err := os.WriteFile(filepath.Join(working, "MDL", name), []byte(body), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, name), []byte(body), 0o644); err != nil {
t.Fatalf("write row %s: %v", name, err) t.Fatalf("write row %s: %v", name, err)
} }
} }
@ -87,8 +85,6 @@ func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]s
zddcFiles["Working"] = `acl: zddcFiles["Working"] = `acl:
permissions: permissions:
"*@example.com": rwcd "*@example.com": rwcd
tables:
MDL: ./MDL.table.yaml
` `
} }
for rel, body := range zddcFiles { for rel, body := range zddcFiles {
@ -125,22 +121,17 @@ tables:
func TestRecognizeTableRequest(t *testing.T) { func TestRecognizeTableRequest(t *testing.T) {
root := t.TempDir() root := t.TempDir()
working := filepath.Join(root, "Working") mdlDir := filepath.Join(root, "Working", "MDL")
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil { if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`tables: zddc.InvalidateCache(mdlDir)
MDL: ./MDL.table.yaml
`), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(working)
cases := []struct { cases := []struct {
method, url string method, url string
@ -148,21 +139,21 @@ func TestRecognizeTableRequest(t *testing.T) {
wantSpec string wantSpec string
wantName string wantName string
}{ }{
{"GET", "/Working/MDL.table.html", false, "Working/MDL.table.yaml", "MDL"}, {"GET", "/Working/MDL/table.html", false, "Working/MDL/table.yaml", "MDL"},
// Same URL but POST → tables are read-only at the URL level. // Same URL but POST → tables are read-only at the URL level.
{"POST", "/Working/MDL.table.html", true, "", ""}, {"POST", "/Working/MDL/table.html", true, "", ""},
{"PUT", "/Working/MDL.table.html", true, "", ""}, {"PUT", "/Working/MDL/table.html", true, "", ""},
{"DELETE", "/Working/MDL.table.html", true, "", ""}, {"DELETE", "/Working/MDL/table.html", true, "", ""},
// Not declared in .zddc → not a table request. // No table.yaml in this dir → not a table request.
{"GET", "/Working/Other.table.html", true, "", ""}, {"GET", "/Working/Other/table.html", true, "", ""},
// No .zddc at the dir → not a table request. // No table.yaml anywhere → not a table request.
{"GET", "/Other/MDL.table.html", true, "", ""}, {"GET", "/Other/MDL/table.html", true, "", ""},
// Random .html → falls through. // Random .html → falls through.
{"GET", "/index.html", true, "", ""}, {"GET", "/index.html", true, "", ""},
// .form.html (form territory) → falls through to form handler. // /form.html in the same dir is form territory, not a table.
{"GET", "/Working/MDL.form.html", true, "", ""}, {"GET", "/Working/MDL/form.html", true, "", ""},
// Path traversal attempt. // Path traversal attempt.
{"GET", "/../etc/passwd.table.html", true, "", ""}, {"GET", "/../etc/passwd/table.html", true, "", ""},
} }
for _, tc := range cases { for _, tc := range cases {
@ -196,7 +187,7 @@ func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n", "D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
} }
_, do := tableTestSetup(t, rows, nil) _, do := tableTestSetup(t, rows, nil)
rec := do(http.MethodGet, "/Working/MDL.table.html", "casey@example.com") rec := do(http.MethodGet, "/Working/MDL/table.html", "casey@example.com")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
} }
@ -222,7 +213,7 @@ tables:
`, `,
} }
_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs) _, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
rec := do(http.MethodGet, "/Working/MDL.table.html", "stranger@example.com") rec := do(http.MethodGet, "/Working/MDL/table.html", "stranger@example.com")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String()) t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
} }
@ -276,7 +267,7 @@ func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(m
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) { func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
_, do := archivePartyTestSetup(t, "") _, do := archivePartyTestSetup(t, "")
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com") rec := do(http.MethodGet, "/Project/archive/Acme/mdl/table.html", "alice@example.com")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
} }
@ -286,31 +277,17 @@ func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
} }
} }
func TestRecognizeTableRequest_OperatorOverrideWins(t *testing.T) {
// Operator declared a custom mdl spec that points at a non-existent
// file. The fallback should NOT fire, because the operator
// explicitly took control. RecognizeTableRequest returns nil.
root, do := archivePartyTestSetup(t, `tables:
mdl: ./custom-mdl.table.yaml
`)
_ = root
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
if rec.Code != http.StatusNotFound {
t.Errorf("operator declaration with missing spec should fall through to 404, got %d", rec.Code)
}
}
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) { func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
// Default fallback is scoped to <project>/archive/<party>/. A // Default fallback is scoped to <project>/archive/<party>/. A
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a // request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
// non-archive path should return nil (no recognition). // non-archive path should return nil (no recognition).
_, do := archivePartyTestSetup(t, "") _, do := archivePartyTestSetup(t, "")
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com") rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl/table.html", "alice@example.com")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code) t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
} }
rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com") rec = do(http.MethodGet, "/Project/working/mdl/table.html", "alice@example.com")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code) t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
} }
@ -318,12 +295,12 @@ func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) { func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
root := t.TempDir() root := t.TempDir()
// archive/Acme/ exists but no mdl.table.yaml on disk. // archive/Acme/ exists but no mdl/table.yaml on disk.
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml") bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml")
if !ok { if !ok {
t.Fatalf("expected fallback to fire") t.Fatalf("expected fallback to fire")
} }
@ -331,7 +308,7 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))]) t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
} }
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml") bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/form.yaml")
if !ok { if !ok {
t.Fatalf("expected form fallback to fire") t.Fatalf("expected form fallback to fire")
} }
@ -342,14 +319,14 @@ func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) { func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
root := t.TempDir() root := t.TempDir()
dir := filepath.Join(root, "Project", "archive", "Acme") mdlDir := filepath.Join(root, "Project", "archive", "Acme", "mdl")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(mdlDir, "table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok { if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl/table.yaml"); ok {
t.Errorf("operator file should win over embedded fallback") t.Errorf("operator file should win over embedded fallback")
} }
} }
@ -357,9 +334,9 @@ func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) { func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
root := t.TempDir() root := t.TempDir()
cases := []string{ cases := []string{
"/Project/working/mdl.table.yaml", "/Project/working/mdl/table.yaml",
"/Project/archive/mdl.table.yaml", // depth 3 — no party segment "/Project/archive/mdl/table.yaml", // depth 3 — no party segment
"/Project/archive/Acme/sub/mdl.table.yaml", "/Project/archive/Acme/sub/mdl/table.yaml",
} }
for _, p := range cases { for _, p := range cases {
if _, ok := IsDefaultMdlSpec(root, p); ok { if _, ok := IsDefaultMdlSpec(root, p); ok {

File diff suppressed because one or more lines are too long