feat(tables): new sortable/filterable grid tool for directories of YAML files

Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.

Schema (zddc/internal/zddc/file.go)
  - New `Tables map[string]string` on ZddcFile. Map key becomes the URL
    stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
    relative to the .zddc pointing at a `*.table.yaml` spec describing
    columns + the rows directory. No upward cascade in v1 — each
    directory hosting a table declares it directly.

Server handler (zddc/internal/handler/tablehandler.go)
  - `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
    against the cascade's `tables:` declarations. Dispatch routes
    table requests before the form-system intercept.
  - `ServeTable` ACL-gates with `policy.ActionRead` and serves the
    embedded `tables.html` template; client walks the directory itself
    via the listing JSON or FS Access API.
  - tables.html embedded via //go:embed — same pattern as form.html.

Frontend (tables/)
  - Vanilla JS: app/context/util/filters/sort/render/main modules.
  - Reads spec + row YAML files via window.zddc.source (HTTP polyfill
    or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
    client-side parsing.
  - Sample fixtures under tables/sample/ for local testing.

Build + CI
  - Lockstep build registers tables alongside the other 7 tools (HTML
    output, embed mirror, versions.txt, release-output, tags).
  - Playwright project added; `npx playwright test --project=tables`
    is part of `npm test`.

Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.

Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-05 20:32:01 -05:00
parent 2b17c9f030
commit 9ca36f25d8
34 changed files with 4415 additions and 26 deletions

6
.gitignore vendored
View file

@ -30,6 +30,12 @@ test-results/
# and reproducible from any <tool>-vX.Y.Z tag.
dist/
# Locally-compiled zddc-server binary. `(cd zddc && go build ./cmd/zddc-server)`
# drops the binary at zddc/zddc-server; the canonical released artifacts live
# under dist/release-output/zddc-server_* with platform suffixes and signing.
zddc/zddc-server
zddc/zddc-server.exe
# IDE and project files
.opencode/
opencode.json

View file

@ -27,7 +27,7 @@
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
# Single-tool dev build for testing (does NOT touch dist/release-output/):
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing|form|tables|browse
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
# don't drift between tools). Same flag form as before.
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
npm test
# Test single tool
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables
# Dev server (cache-busting HTTP, on port 8000)
./dev-server start
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
## Architecture
Six independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`). 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. The sixth tool, `form`, is the schema-driven renderer used by zddc-server's form-data system; see "Form-data 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 declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below).
```
tool/
@ -202,20 +202,20 @@ Format: `trackingNumber_revision (status) - title.extension`
- Feature-branch workflow; squash-merge feature branches to `main`
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
- Release tags: `<tool>-v<X.Y.Z>` per tool, all seven sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `zddc-server-v0.0.8`)
- Release tags: `<tool>-v<X.Y.Z>` per tool, all nine sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
### Releasing — lockstep, channels, layout
**Lockstep convention.** Every release cut bumps all seven artifacts (6 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all seven tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
**Lockstep convention.** Every release cut bumps all nine artifacts (8 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all nine tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build alpha|beta|release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
| Artifact | Type | Layout |
|---|---|---|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form |
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse |
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
@ -367,15 +367,58 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag).
```sh
# Compile a local binary for the host platform (requires Go 1.24+)
(cd zddc && go build -o zddc-server ./cmd/zddc-server)
# Compile a local binary for the host platform via the build image.
# Same flag pattern as Test below — see that subsection for why.
podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod:Z \
-w /src/zddc -e GOPROXY=https://proxy.golang.org -e GOSUMDB=off -e GOPRIVATE= \
localhost/zddc-go:1.24 go build -o zddc-server ./cmd/zddc-server
# Or run directly without producing a binary
(cd zddc && go run ./cmd/zddc-server)
# `go run` is normally a one-liner but in-container `go run` of a network
# server is awkward — for dev iteration, build with the line above and
# launch the binary on the host (`./zddc/zddc-server`).
```
The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build alpha|beta|release` it also promotes those binaries to `dist/release-output/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
### Test
Go is **not** installed on the dev shell host directly — run `go test` (and `go build`) through a golang-alpine image. `./build`'s containerized cross-compile already pulls `golang:1.24-alpine` as a build stage; tag it for reuse:
```sh
# One-time: locate the golang-alpine image (~810 MB, untagged after a build run)
# and give it a stable name. The size and `golang/.../go test` lines distinguish
# it from the small ~18 MB zddc-server runtime image.
podman images --filter dangling=true --format '{{.Size}}\t{{.ID}}' | sort -h | tail
podman tag <id> localhost/zddc-go:1.24
# If you have no <none> 810 MB image (fresh machine), pull directly:
podman pull docker.io/library/golang:1.24-alpine
podman tag docker.io/library/golang:1.24-alpine localhost/zddc-go:1.24
```
Canonical invocation:
```sh
podman run --rm --network=host \
-v "$PWD":/src:Z \
-v /tmp/gocache:/root/go/pkg/mod:Z \
-w /src/zddc \
-e GOPROXY=https://proxy.golang.org \
-e GOSUMDB=off \
-e GOPRIVATE= \
localhost/zddc-go:1.24 \
go test ./...
```
Why each flag:
- `--network=host` — the alpine image's TLS chain can't shake hands with `gopkg.in` directly (sandbox hits "Connection reset by peer"); host networking + the proxy below works around it.
- `GOPROXY=https://proxy.golang.org` — fetch via the public proxy. Without this, the build-image's baked `GOPRIVATE=*` forces direct VCS, which fails on `gopkg.in/natefinch/lumberjack.v2`.
- `GOSUMDB=off` — sum.golang.org isn't reachable from the sandbox either; we already trust the proxy.
- `GOPRIVATE=` (empty) — explicit override of the image's `GOPRIVATE=*`, which is a leftover from how `./build` does in-container compilation and would otherwise re-trigger direct fetch.
- `/tmp/gocache` mount — persistent module cache across runs.
Run-it-once-per-session pattern: alias it. Do **not** `apt install golang` on the host — the image is the source of truth for the version pin, so dev and CI compile against the same Go.
### Run (development)
```sh
@ -432,9 +475,9 @@ local path that fails loudly and visibly on the developer's terminal.
### Notes
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/`. The Go toolchain is not on the host; use the `localhost/zddc-go:1.24` image as documented in the **Test** subsection above.
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.

View file

@ -16,6 +16,8 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
| **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. |
| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `<name>.form.html`. |
| **Tables** | Read-only grid view of a directory of YAML files with sort + filter; click row → edit in the form renderer. Declared per-directory in `.zddc`. |
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.

19
build
View file

@ -157,6 +157,7 @@ sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
echo ""
@ -177,8 +178,9 @@ cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,browse}.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html"
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
@ -203,6 +205,12 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/internal/handler/form.html"
echo "Populated zddc/internal/handler/form.html for //go:embed"
# Same pattern for the tables renderer — embedded directly into the
# 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
# written by shared/build-lib.sh's compute_build_label. The Go side reads
# this via //go:embed in internal/apps/versions.go and surfaces it in
@ -210,7 +218,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
VERSIONS_FILE="$EMBED_DIR/versions.txt"
{
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
for _tool in archive transmittal classifier mdedit landing form browse; do
for _tool in archive transmittal classifier mdedit landing form tables browse; do
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
if [ -f "$_label_file" ]; then
_label=$(cat "$_label_file")
@ -959,7 +967,8 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
# that the binary needs at //go:embed time + the embedded form
# template.
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
"$SCRIPT_DIR/zddc/internal/handler/form.html"
"$SCRIPT_DIR/zddc/internal/handler/form.html" \
"$SCRIPT_DIR/zddc/internal/handler/tables.html"
if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then
git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep"
@ -971,7 +980,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
# Tag the seven artifacts at HEAD. Pre-flight already validated that
# any pre-existing tag is in HEAD's history, so this is safe.
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
_tag="${_t}-v${RELEASE_VERSION}"
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
@ -1005,7 +1014,7 @@ else
echo "Version: v$RELEASE_VERSION"
echo ""
echo "Tags created locally on main (push when ready):"
for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
echo " ${_t}-v${RELEASE_VERSION}"
done
echo " git push origin main && git push origin --tags"

View file

@ -55,6 +55,10 @@ export default defineConfig({
name: 'form-safety',
testMatch: 'form-safety.spec.js',
},
{
name: 'tables',
testMatch: 'tables.spec.js',
},
{
name: 'zddc-filter',
testMatch: 'zddc-filter.spec.js',

View file

@ -222,7 +222,7 @@ _emit_build_label_sidecar() {
# Tools that participate in the lockstep release. Source of truth — used
# by helpers that enumerate "all release artifacts" (matrix render,
# coordinated next-stable, channel-link verifier).
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form browse zddc-server"
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form tables browse zddc-server"
# Compute the next-stable target for a single tool — patch-bump of its own
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
@ -663,7 +663,7 @@ verify_channel_links() {
_missing=0
_verified=0
for _t in archive transmittal classifier mdedit landing form browse; do
for _t in archive transmittal classifier mdedit landing form tables browse; do
for _ch in stable beta alpha; do
_f="$_rdir/${_t}_${_ch}.html"
if [ -e "$_f" ]; then

2
shared/vendor/js-yaml.min.js vendored Normal file

File diff suppressed because one or more lines are too long

94
tables/README.md Normal file
View file

@ -0,0 +1,94 @@
# ZDDC Tables
[← Back to ZDDC](../README.md)
Render a directory of YAML files as a sortable, filterable table — read-only, with click-row → edit-in-form integration. Backed by `zddc-server`'s form handler so the table view and the form editor are two sides of the same data.
**Anchor use case.** A Master Deliverables List (MDL) under `Archive/<Party>/MDL/`, where each `.yaml` file is one expected deliverable. Multiple parties keep their own MDLs side by side; the table aggregates within a single party's directory.
## How it works
- **Storage** is file-per-row YAML — one `*.yaml` file in a directory per table row. Concurrent edits don't collide on a shared blob, every row has independent git history, and per-row ACL inherits from the cascading `.zddc` chain.
- **Discovery** is `.zddc`-declarative. Drop a `tables:` entry in the directory's `.zddc` to register the table; no file-presence auto-mount, no phantom tables from rogue YAML drops.
- **Rendering** is server-side: `zddc-server` reads every `*.yaml` under the rows directory, normalizes them into a JSON list, and inlines the list into the page on render. The browser does sorting, filtering, and click-row navigation locally — no further server round-trips for those.
- **Editing** is delegated to the existing form tool. Each row's click target is the form's re-edit URL (`<dir>/<name>/<basename>.yaml.html`), which `zddc-server` already serves via the form handler. The table itself never writes.
## Setup (for an MDL at `Archive/Acme/MDL/`)
```
Archive/Acme/
├── .zddc # declares: tables: { MDL: ./MDL.table.yaml }
├── MDL.table.yaml # column spec + rows path + row schema reference
├── MDL.form.yaml # JSON Schema for one row (used by both the table and the form editor)
└── MDL/
├── D-001.yaml # one row
├── D-002.yaml # one row
└── ...
```
Visit `Archive/Acme/MDL.table.html` and the table renders. Visit `Archive/Acme/MDL.form.html` to add a new row (the form handler creates a YAML in `MDL/`).
### `.zddc` declaration
```yaml
tables:
MDL: ./MDL.table.yaml
```
The map key (`MDL`) becomes the URL stem and must match both the rows directory name and the form spec name. v1 enforces this with a load-time spec-validation error.
### Table spec (`MDL.table.yaml`)
```yaml
title: Master Deliverables List
description: Optional description shown above the table.
rowSchema: ./MDL.form.yaml # path to the row's JSON Schema (form-spec format)
rows: ./MDL # directory of *.yaml row files (non-recursive in v1)
columns:
- field: id # top-level key OR JSON Pointer (e.g. /nested/path)
title: ID
width: 7em
sort: asc # default sort key (overridden by defaults.sort below)
- field: title
title: Deliverable
- field: dueDate
title: Due
format: date # date | datetime | number | bool
- field: status
title: Status
enum: [pending, submitted, accepted, rejected] # constrains values + enables enum filter
defaults:
sort:
- { field: dueDate, dir: asc }
filter:
status: [pending, submitted] # initial filter state; clear with the toolbar button
```
Columns are explicit — the renderer does not auto-derive from the row schema. Pick the subset you want to display.
## ACL behavior
- The page-level read check uses the cascade at the spec directory; a caller without `r` gets a 403.
- Per-row "edit" affordance is recomputed against the row's own parent dir. If the user has `w` there, the row is clickable; otherwise it's plain text. Hard enforcement remains on the form-handler side (the form's POST will refuse a write the cascade denies).
- `Issued`/`Received` archive folders are server-enforced WORM. The decider strips `w/d/a` from non-admin grants under those subtrees, so an MDL placed inside `Issued/` shows every row as read-only with no special-casing in the table tool.
## v1 limits
- Read-only grid; click-row opens the form editor. Inline cell editing is a v2 candidate (would PUT each edit through the new file API in `zddc/internal/handler/fileapi.go`).
- One directory of `*.yaml` per table; cross-directory aggregation (`Archive/*/MDL/*.yaml` as one combined view) is not yet supported.
- No virtualization — large tables (>1000 rows) will be slow.
- No multi-row bulk operations, no add-row UI inside the table (use the form editor at `<name>.form.html`).
- `.zddc tables:` declarations are direct-lookup only; no upward cascade. Each directory hosting a table needs its own declaration.
## Build & develop
```bash
sh tables/build.sh # build (writes tables/dist/tables.html)
sh tables/build.sh --release alpha # cut alpha
sh ./build # full lockstep build (all tools + zddc-server)
(cd zddc && go test ./internal/handler/... ./internal/zddc/...)
npx playwright test --project=tables
```
Authoritative architecture and build docs are in [`../AGENTS.md`](../AGENTS.md) and [`../ARCHITECTURE.md`](../ARCHITECTURE.md).

78
tables/build.sh Executable file
View file

@ -0,0 +1,78 @@
#!/bin/sh
set -eu
root_dir=$(cd "$(dirname "$0")" && pwd)
. "$root_dir/../shared/build-lib.sh"
src_html="$root_dir/template.html"
output_dir="$root_dir/dist"
output_html="$output_dir/tables.html"
mkdir -p "$output_dir"
ensure_exists "$src_html"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
concat_files \
"../shared/base.css" \
"css/table.css" \
> "$css_temp"
concat_files \
"../shared/vendor/js-yaml.min.js" \
"../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/help.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \
"js/filters.js" \
"js/sort.js" \
"js/render.js" \
"js/main.js" \
> "$js_raw"
escape_js_close_tags "$js_raw" "$js_temp"
compute_build_label "tables" "${1:-}" "${2:-}"
awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" '
/\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line
close(css_file)
next
}
/\{\{JS_PLACEHOLDER\}\}/ {
while ((getline line < js_file) > 0) print line
close(js_file)
next
}
/\{\{BUILD_LABEL\}\}/ {
if (is_red == "1") {
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https?:\/\// { next }
/<link rel="stylesheet" href="https?:\/\// { next }
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html"
if [ "$is_release" = "1" ]; then
promote_release "tables"
fi

124
tables/css/table.css Normal file
View file

@ -0,0 +1,124 @@
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main {
padding: var(--spacing-md);
max-width: 100%;
}
.table-description {
margin: 0 0 var(--spacing-md);
color: var(--color-text-muted);
font-size: 0.95rem;
}
.table-status {
margin: 0 0 var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, #fff8e6);
border: 1px solid var(--color-border, #d6cfa3);
border-radius: var(--radius-sm, 4px);
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-md);
margin: 0 0 var(--spacing-sm);
}
.table-toolbar__left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.table-rowcount {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.table-scroll {
overflow: auto;
max-height: calc(100vh - 200px);
border: 1px solid var(--color-border, #d8d8d8);
border-radius: var(--radius-sm, 4px);
}
.zddc-table {
border-collapse: collapse;
width: 100%;
font-size: 0.95rem;
}
.zddc-table thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__title-row .zddc-table__th {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-weight: 600;
border-bottom: 1px solid var(--color-border, #d8d8d8);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.zddc-table__title-row .zddc-table__th:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.zddc-table__filter-row .zddc-table__filter-cell {
padding: 4px var(--spacing-sm);
border-bottom: 1px solid var(--color-border, #d8d8d8);
background: var(--color-bg-elevated, #f5f5f5);
}
.zddc-table__filter-text,
.zddc-table__filter-enum {
width: 100%;
box-sizing: border-box;
padding: 2px 4px;
font-size: 0.85rem;
border: 1px solid var(--color-border, #d0d0d0);
border-radius: 3px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
}
.zddc-table__filter-enum {
min-height: 1.8em;
}
.zddc-table__row:nth-child(even) {
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
.zddc-table__row--editable {
cursor: pointer;
}
.zddc-table__row--editable:hover {
background: var(--color-bg-hover, rgba(50, 100, 200, 0.08));
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
.zddc-table__cell {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
}
.table-empty {
padding: var(--spacing-lg) var(--spacing-md);
text-align: center;
color: var(--color-text-muted);
font-style: italic;
}

15
tables/js/app.js Normal file
View file

@ -0,0 +1,15 @@
(function (global) {
'use strict';
if (global.tablesApp) {
return;
}
global.tablesApp = {
context: null,
state: {
rows: [],
sort: [],
filter: {}
},
modules: {}
};
})(window);

180
tables/js/context.js Normal file
View file

@ -0,0 +1,180 @@
(function (app) {
'use strict';
// load() resolves to the table context the rest of the app renders:
// { title?, description?, columns, rows, defaults? }
//
// Two paths:
//
// 1. Inline JSON (test seam, and also any host that wants to
// pre-render a context server-side): if #table-context parses
// to a non-empty object, return it as-is.
//
// 2. File-backed walk (the real-world path served by zddc-server):
// fetch <dir>/.zddc, find tables[<name>], fetch the *.table.yaml
// spec, list <dir>/<name>/*.yaml row files, parse each, and
// assemble the same shape.
//
// file:// mode without a directory handle is unsupported in v1 — the
// walk only runs against http(s). file:// users must either inject an
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
if (inline && Object.keys(inline).length > 0) {
return inline;
}
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer();
if (walked) {
return walked;
}
} catch (err) {
console.error('[tables] failed to load table from server', err);
showStatus('Could not load table: ' + (err && err.message ? err.message : err));
}
}
return {};
}
function readInlineContext() {
const el = document.getElementById('table-context');
if (!el) {
return null;
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[tables] failed to parse #table-context', err);
return null;
}
}
function showStatus(msg) {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = msg;
el.hidden = false;
}
async function walkServer() {
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
}
const tableName = tableNameFromUrl(location.pathname);
if (!tableName) {
throw new Error('Unrecognized table URL: ' + location.pathname);
}
const probe = await source.detectServerRoot();
if (!probe.handle) {
throw new Error(probe.status === 403
? 'No permission to list this directory'
: 'Server unreachable');
}
const dir = probe.handle;
const zddcDoc = await readYaml(dir, '.zddc');
const tablesMap = (zddcDoc && zddcDoc.tables) || {};
const specRel = tablesMap[tableName];
if (!specRel) {
throw new Error('No tables.' + tableName + ' declared in .zddc');
}
const spec = await readYaml(dir, stripDotSlash(specRel));
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec ' + specRel + ' missing columns[]');
}
const rowsRel = stripDotSlash(spec.rows || ('./' + tableName));
const rowsDir = await resolveDirectory(dir, rowsRel);
const rows = await readRows(rowsDir, rowsRel, tableName);
return {
title: spec.title,
description: spec.description,
columns: spec.columns,
defaults: spec.defaults,
rows: rows
};
}
function tableNameFromUrl(pathname) {
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/);
return m ? m[1] : null;
}
function stripDotSlash(p) {
let out = String(p || '');
if (out.startsWith('./')) out = out.slice(2);
if (out.startsWith('/')) out = out.slice(1);
if (out.endsWith('/')) out = out.slice(0, -1);
return out;
}
async function readYaml(dir, relPath) {
const fileHandle = await resolveFile(dir, relPath);
const file = await fileHandle.getFile();
const text = await file.text();
if (!window.jsyaml) {
throw new Error('js-yaml not loaded');
}
return window.jsyaml.load(text);
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) {
throw new Error('Empty file path');
}
const fileName = parts.pop();
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur.getFileHandle(fileName);
}
async function resolveDirectory(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur;
}
async function readRows(rowsDir, rowsRel, tableName) {
const rows = [];
for await (const entry of rowsDir.values()) {
if (entry.kind !== 'file') continue;
if (!entry.name.endsWith('.yaml')) continue;
try {
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
const data = window.jsyaml.load(await file.text());
rows.push({
url: rowEditUrl(rowsRel, tableName, entry.name),
data: data || {},
editable: true
});
} catch (err) {
console.warn('[tables] skipping unparseable row', entry.name, err);
}
}
return rows;
}
// Build the form-handler URL for editing one row. The page is at
// <dir>/<tableName>.table.html; the row file lives at
// <dir>/<rowsRel>/<basename>.yaml; the form re-edit URL is
// <dir>/<rowsRel>/<basename>.yaml.html.
function rowEditUrl(rowsRel, tableName, rowFileName) {
const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/');
const rowsPath = pageDir + (rowsRel || tableName) + '/';
return rowsPath + rowFileName + '.html';
}
app.modules.context = { load: load };
})(window.tablesApp);

64
tables/js/filters.js Normal file
View file

@ -0,0 +1,64 @@
(function (app) {
'use strict';
// A filter is per-column and has one of two shapes:
// - free-text: { kind: 'contains', value: '<string>' }
// - enum: { kind: 'enum', value: ['<choice>', ...] }
// An empty value (empty string or empty array) matches everything.
function isEnumColumn(col) {
return Array.isArray(col.enum) && col.enum.length > 0;
}
function defaultFilterFor(col) {
return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' };
}
function rowMatches(filter, cellValue) {
if (filter.kind === 'enum') {
if (!Array.isArray(filter.value) || filter.value.length === 0) {
return true;
}
const s = cellValue == null ? '' : String(cellValue);
return filter.value.indexOf(s) !== -1;
}
// contains
if (!filter.value) {
return true;
}
const needle = String(filter.value).toLowerCase();
const hay = cellValue == null ? '' : String(cellValue).toLowerCase();
return hay.indexOf(needle) !== -1;
}
function isEmpty(filter) {
if (filter.kind === 'enum') {
return !Array.isArray(filter.value) || filter.value.length === 0;
}
return !filter.value;
}
function apply(rows, columns, filterMap, resolveField) {
return rows.filter(function (row) {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const filter = filterMap[col.field];
if (!filter || isEmpty(filter)) {
continue;
}
const cellValue = resolveField(row.data, col.field);
if (!rowMatches(filter, cellValue)) {
return false;
}
}
return true;
});
}
app.modules.filters = {
defaultFilterFor: defaultFilterFor,
isEnumColumn: isEnumColumn,
isEmpty: isEmpty,
apply: apply
};
})(window.tablesApp);

104
tables/js/main.js Normal file
View file

@ -0,0 +1,104 @@
(function (app) {
'use strict';
async function init() {
const ctx = await app.modules.context.load();
app.context = ctx;
const titleEl = document.getElementById('table-title');
if (ctx.title && titleEl) {
titleEl.textContent = ctx.title;
document.title = 'ZDDC — ' + ctx.title;
}
const descEl = document.getElementById('table-description');
if (descEl && ctx.description) {
descEl.textContent = ctx.description;
descEl.hidden = false;
}
const tableEl = document.getElementById('table-root');
const theadEl = tableEl.querySelector('thead');
const tbodyEl = tableEl.querySelector('tbody');
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
const state = app.state;
state.rows = allRows;
state.sort = app.modules.sort.defaultsFromContext(ctx);
state.filter = {};
// Seed default filters from context.defaults.filter (per-column).
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const seeded = ctx.defaults.filter[col.field];
if (seeded == null) {
continue;
}
if (app.modules.filters.isEnumColumn(col)) {
state.filter[col.field] = {
kind: 'enum',
value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)]
};
} else {
state.filter[col.field] = { kind: 'contains', value: String(seeded) };
}
}
}
function anyFilterActive() {
const filters = app.modules.filters;
const keys = Object.keys(state.filter);
for (let i = 0; i < keys.length; i++) {
if (!filters.isEmpty(state.filter[keys[i]])) {
return true;
}
}
return false;
}
function paint() {
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
app.modules.render.body(tbodyEl, sorted, columns);
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
if (emptyEl) {
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
}
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
}
function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
paint();
}
function onFilterChange(field, value) {
state.filter[field] = value;
paint();
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.filter = {};
paint();
});
}
paint();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window.tablesApp);

118
tables/js/render.js Normal file
View file

@ -0,0 +1,118 @@
(function (app) {
'use strict';
function renderHeader(theadEl, columns, sortState, filterMap, onHeaderClick, onFilterChange) {
const util = app.modules.util;
const filters = app.modules.filters;
const sort = app.modules.sort;
theadEl.innerHTML = '';
const titleRow = util.h('tr', { className: 'zddc-table__title-row' });
const filterRow = util.h('tr', { className: 'zddc-table__filter-row' });
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const indicator = sort.indicator(sortState, col.field);
const th = util.h('th', {
className: 'zddc-table__th',
'data-field': col.field,
style: col.width ? 'width:' + col.width : null,
onClick: function (ev) { onHeaderClick(col.field, ev.shiftKey); }
}, col.title || col.field, indicator);
titleRow.appendChild(th);
const td = util.h('td', { className: 'zddc-table__filter-cell' });
const f = filterMap[col.field] || filters.defaultFilterFor(col);
if (filters.isEnumColumn(col)) {
const select = util.h('select', {
multiple: true,
'aria-label': 'Filter ' + (col.title || col.field),
className: 'zddc-table__filter-enum',
onChange: function (ev) {
const opts = ev.target.options;
const picked = [];
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 {
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);
}
theadEl.appendChild(titleRow);
theadEl.appendChild(filterRow);
}
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
tbodyEl.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const tr = util.h('tr', {
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
'data-url': row.url,
'data-editable': row.editable ? '1' : '0',
onClick: function (ev) {
const target = ev.currentTarget;
const editable = target.getAttribute('data-editable') === '1';
const url = target.getAttribute('data-url');
if (editable && url) {
// Indirection so tests can intercept without
// fighting Chromium's location.assign property
// descriptor. Production calls window.location.assign.
const nav = (window.tablesApp && window.tablesApp.navigateTo) ||
function (u) { window.location.assign(u); };
nav(url);
}
}
});
for (let c = 0; c < columns.length; c++) {
const col = columns[c];
const raw = util.resolveField(row.data, col.field);
const text = util.formatCell(raw, col.format);
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
}
tbodyEl.appendChild(tr);
}
}
function renderRowCount(el, displayed, total) {
if (!el) return;
if (displayed === total) {
el.textContent = total + (total === 1 ? ' row' : ' rows');
} else {
el.textContent = displayed + ' of ' + total + ' rows';
}
}
app.modules.render = {
header: renderHeader,
body: renderBody,
rowCount: renderRowCount
};
})(window.tablesApp);

108
tables/js/sort.js Normal file
View file

@ -0,0 +1,108 @@
(function (app) {
'use strict';
// Sort state is an ordered list of {field, dir} keys; the first is
// primary, additional keys break ties.
function defaultsFromContext(ctx) {
const defaults = ctx.defaults || {};
if (Array.isArray(defaults.sort) && defaults.sort.length > 0) {
return defaults.sort.slice();
}
// Fall back to any column with `sort:` set.
const fromCols = (ctx.columns || []).filter(function (c) { return c.sort; });
if (fromCols.length > 0) {
return fromCols.map(function (c) {
const dir = c.sort === 'desc' ? 'desc' : 'asc';
return { field: c.field, dir: dir };
});
}
return [];
}
function findColumn(columns, field) {
for (let i = 0; i < columns.length; i++) {
if (columns[i].field === field) {
return columns[i];
}
}
return null;
}
// Click handler for a header: cycle the sort state for `field`.
// - Not currently a sort key → add as primary, asc
// - Currently primary asc → flip to desc
// - Currently primary desc → remove
// - Currently secondary → promote to primary, asc
// Shift-click is meant for additional accumulation but we keep the
// single-click semantics simple; advanced multi-sort can be a
// follow-up.
function cycle(state, field, multi) {
const idx = state.findIndex(function (s) { return s.field === field; });
if (multi) {
if (idx === -1) {
return state.concat([{ field: field, dir: 'asc' }]);
}
const cur = state[idx];
if (cur.dir === 'asc') {
const next = state.slice();
next[idx] = { field: field, dir: 'desc' };
return next;
}
return state.slice(0, idx).concat(state.slice(idx + 1));
}
if (idx === -1) {
return [{ field: field, dir: 'asc' }];
}
if (idx === 0) {
const cur = state[0];
if (cur.dir === 'asc') {
return [{ field: field, dir: 'desc' }];
}
return [];
}
return [{ field: field, dir: 'asc' }];
}
function apply(rows, sortState, columns, util) {
if (!sortState || sortState.length === 0) {
return rows;
}
const out = rows.slice();
out.sort(function (a, b) {
for (let i = 0; i < sortState.length; i++) {
const key = sortState[i];
const col = findColumn(columns, key.field);
const fmt = col ? col.format : '';
const av = util.resolveField(a.data, key.field);
const bv = util.resolveField(b.data, key.field);
const cmp = util.compareCells(av, bv, fmt);
if (cmp !== 0) {
return key.dir === 'desc' ? -cmp : cmp;
}
}
return 0;
});
return out;
}
function indicator(sortState, field) {
for (let i = 0; i < sortState.length; i++) {
if (sortState[i].field === field) {
const arrow = sortState[i].dir === 'desc' ? ' ▼' : ' ▲';
if (sortState.length > 1) {
return arrow + (i + 1);
}
return arrow;
}
}
return '';
}
app.modules.sort = {
defaultsFromContext: defaultsFromContext,
cycle: cycle,
apply: apply,
indicator: indicator
};
})(window.tablesApp);

151
tables/js/util.js Normal file
View file

@ -0,0 +1,151 @@
(function (app) {
'use strict';
const util = {};
util.h = function (tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const k of Object.keys(attrs)) {
const v = attrs[k];
if (v == null || v === false) {
continue;
}
if (k === 'className') {
el.className = v;
} else if (k.length > 2 && k.slice(0, 2) === 'on' && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) {
el.setAttribute(k, '');
} else {
el.setAttribute(k, v);
}
}
}
for (let i = 2; i < arguments.length; i++) {
const c = arguments[i];
if (c == null || c === false) {
continue;
}
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else {
el.appendChild(c);
}
}
return el;
};
// Resolve a column's `field` against a row data object.
// - "" or "/" → the whole object
// - "/foo/bar" → JSON Pointer (RFC 6901) lookup
// - "foo" → top-level key
util.resolveField = function (data, field) {
if (data == null) {
return undefined;
}
if (!field || field === '/') {
return data;
}
if (field.charAt(0) !== '/') {
return data[field];
}
const segments = field.split('/').slice(1).map(function (s) {
return s.replace(/~1/g, '/').replace(/~0/g, '~');
});
let cur = data;
for (let i = 0; i < segments.length; i++) {
if (cur == null) {
return undefined;
}
if (Array.isArray(cur)) {
const idx = parseInt(segments[i], 10);
if (Number.isNaN(idx)) {
return undefined;
}
cur = cur[idx];
} else if (typeof cur === 'object') {
cur = cur[segments[i]];
} else {
return undefined;
}
}
return cur;
};
// Format a raw cell value per column's `format` hint.
util.formatCell = function (value, format) {
if (value == null || value === '') {
return '';
}
if (format === 'date') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toISOString().slice(0, 10);
}
return String(value);
}
if (format === 'datetime') {
const d = new Date(value);
if (!isNaN(d.getTime())) {
return d.toLocaleString();
}
return String(value);
}
if (format === 'number') {
const n = Number(value);
if (Number.isFinite(n)) {
return n.toLocaleString();
}
return String(value);
}
if (format === 'bool' || typeof value === 'boolean') {
return value ? '✓' : '';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
}
return String(value);
};
// Compare two cell values for sorting. null/undefined sort last.
// Numbers compared numerically, dates compared as Date, otherwise string compare.
util.compareCells = function (a, b, format) {
const aMissing = a == null || a === '';
const bMissing = b == null || b === '';
if (aMissing && bMissing) {
return 0;
}
if (aMissing) {
return 1;
}
if (bMissing) {
return -1;
}
if (format === 'date' || format === 'datetime') {
const da = new Date(a).getTime();
const db = new Date(b).getTime();
if (!isNaN(da) && !isNaN(db)) {
return da - db;
}
}
if (format === 'number' || (typeof a === 'number' && typeof b === 'number')) {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
return na - nb;
}
}
const sa = String(a).toLowerCase();
const sb = String(b).toLowerCase();
if (sa < sb) return -1;
if (sa > sb) return 1;
return 0;
};
app.modules.util = util;
})(window.tablesApp);

18
tables/sample/.zddc Normal file
View file

@ -0,0 +1,18 @@
# Sample .zddc declaring a table view.
#
# Tables are declared explicitly per directory; there is no auto-mount on
# file presence. The map key becomes the URL stem: `MDL` here means the
# table view is served at <this-dir>/MDL.table.html. The value points at
# the *.table.yaml spec file (relative to this .zddc).
#
# Drop this directory under any project with a .zddc cascade granting
# read+write to the operator's email. The form re-edit URL the table
# links to (<dir>/MDL/<basename>.yaml.html) is the existing form
# handler's pattern; no separate routing is required.
title: Sample MDL — deliverables tracker
acl:
permissions:
"*@example.com": rwcd
tables:
MDL: ./MDL.table.yaml

View file

@ -0,0 +1,39 @@
# Row schema for one Master Deliverables List entry. The table tool
# uses this as both the column-type source (formats / enums) and the
# "click-row → edit" form spec. The form handler also serves it
# directly as MDL.form.html for adding new entries.
title: Deliverable
description: One expected deliverable, tracked across parties.
schema:
type: object
required: [id, title, party, dueDate, status]
additionalProperties: false
properties:
id:
type: string
title: ID
description: Tracking identifier (e.g. D-001).
minLength: 1
title:
type: string
title: Deliverable
description: Short description of what is to be delivered.
minLength: 1
party:
type: string
title: Party
enum: [Acme, Beta, Gamma]
dueDate:
type: string
format: date
title: Due date
status:
type: string
title: Status
enum: [pending, submitted, accepted, rejected]
notes:
type: string
title: Notes
description: Free-text notes (optional).

View file

@ -0,0 +1,41 @@
# Master Deliverables List — table spec.
#
# Columns are an explicit ordered subset of the row schema. The renderer
# does not auto-derive columns from the schema. `field` is either a
# top-level key on the row YAML or a JSON Pointer (RFC 6901) for nested
# values.
title: Master Deliverables List
description: Sample MDL spec used for development and tests.
# Path to the form spec that defines each row's JSON schema. Click-row
# in the table opens this form for the picked row. The form spec must be
# named <name>.form.yaml where <name> matches both the rows directory
# basename and the .zddc declaration key (here: MDL).
rowSchema: ./MDL.form.yaml
# Directory of *.yaml row files, relative to this spec. Non-recursive.
rows: ./MDL
columns:
- field: id
title: ID
width: 7em
sort: asc
- field: title
title: Deliverable
- field: party
title: Party
enum: [Acme, Beta, Gamma]
- field: dueDate
title: Due
format: date
- field: status
title: Status
enum: [pending, submitted, accepted, rejected]
defaults:
sort:
- { field: dueDate, dir: asc }
filter:
status: [pending, submitted]

View file

@ -0,0 +1,6 @@
id: D-001
title: Site survey report
party: Acme
dueDate: 2026-05-12
status: pending
notes: Initial walkthrough completed; report drafting underway.

View file

@ -0,0 +1,6 @@
id: D-002
title: Foundation drawings rev. A
party: Beta
dueDate: 2026-05-20
status: submitted
notes: Submitted via transmittal T-2026-014. Awaiting reviewer assignment.

View file

@ -0,0 +1,6 @@
id: D-003
title: Procurement schedule
party: Acme
dueDate: 2026-05-08
status: accepted
notes: Accepted on first review.

View file

@ -0,0 +1,6 @@
id: D-004
title: Safety plan
party: Gamma
dueDate: 2026-05-15
status: rejected
notes: Missing PPE inventory section; resubmit by EOM.

View file

@ -0,0 +1,5 @@
id: D-005
title: Geotechnical report
party: Beta
dueDate: 2026-05-30
status: pending

108
tables/template.html Normal file
View file

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Table</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
<main class="table-main">
<div id="table-description" class="table-description" hidden></div>
<div id="table-status" class="table-status" hidden></div>
<div class="table-toolbar" id="table-toolbar">
<div class="table-toolbar__left">
<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>
</div>
</div>
<div class="table-scroll">
<table id="table-root" class="zddc-table" aria-describedby="table-description">
<thead></thead>
<tbody></tbody>
</table>
</div>
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
</main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Table</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is this table?</h3>
<p>Each row in this table is one YAML file in the source directory.
Tables are declared in <code>.zddc</code> via a
<code>tables:</code> map. The columns and row schema come from
a <code>*.table.yaml</code> spec file.</p>
<h3>Sorting</h3>
<p>Click a column header to sort by that column. Click again to
toggle direction. Shift-click another header to add a secondary
sort key.</p>
<h3>Filtering</h3>
<p>Type in the box under a column header to filter rows whose
value contains your text (case-insensitive). For columns with a
fixed enum, the box becomes a multi-select — leave it empty to
show every value.</p>
<h3>Editing a row</h3>
<p>Click a row to open its YAML in the form editor. Whether the
row is editable depends on the cascading <code>.zddc</code>
permissions for the row's path. Rows in <code>Issued</code> or
<code>Received</code> archives are read-only by design (WORM).</p>
<h3>Header buttons</h3>
<dl>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
<dt>? Help</dt>
<dd>This panel. Press <kbd>Esc</kbd> to close.</dd>
</dl>
</div>
</aside>
<!--
Server injects the table context here on render. Shape:
{
"title": "Optional page title override",
"description": "Optional description shown above the table",
"columns": [{field, title, width?, format?, filter?, sort?, enum?}],
"rows": [{url, data, editable}],
"defaults": {sort?: [{field, dir}], filter?: {field: value}}
}
-->
<script id="table-context" type="application/json">{}</script>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

View file

@ -19,15 +19,15 @@ test.describe('Markdown Editor', () => {
await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible();
await expect(page.locator('#content-container')).toBeVisible();
// Select Directory button is present and enabled
const selectDirBtn = page.locator('#select-directory');
await expect(selectDirBtn).toBeVisible();
await expect(selectDirBtn).not.toBeDisabled();
// Add Local Directory button is present and enabled
const addDirBtn = page.locator('#addDirectoryBtn');
await expect(addDirBtn).toBeVisible();
await expect(addDirBtn).not.toBeDisabled();
});
test('renders a file tree from a mock directory', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#select-directory', { timeout: 15000 });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Set up mock directory before triggering the picker
await page.evaluate(() => {
@ -37,7 +37,7 @@ test.describe('Markdown Editor', () => {
]);
});
await page.locator('#select-directory').click();
await page.locator('#addDirectoryBtn').click();
// File tree should populate with the two files
await page.waitForFunction(
@ -54,7 +54,7 @@ test.describe('Markdown Editor', () => {
page.on('console', msg => msg.type() === 'log' && logs.push(msg.text()));
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#select-directory', { timeout: 15000 });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
const probe = await page.evaluate(() => ({
debugDefined: typeof DEBUG !== 'undefined',

207
tests/tables.spec.js Normal file
View file

@ -0,0 +1,207 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const HTML_PATH = path.resolve('tables/dist/tables.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
const MDL_COLUMNS = [
{ field: 'id', title: 'ID', width: '6em' },
{ field: 'title', title: 'Deliverable' },
{ field: 'party', title: 'Party', enum: ['Acme', 'Beta', 'Gamma'] },
{ field: 'dueDate', title: 'Due', format: 'date' },
{ field: 'status', title: 'Status', enum: ['pending', 'submitted', 'accepted'] },
];
function makeRow(id, title, party, dueDate, status, editable = true) {
return {
url: `/Working/MDL/${id}.yaml.html`,
data: { id, title, party, dueDate, status },
editable,
};
}
const ROWS = [
makeRow('D-001', 'Site survey report', 'Acme', '2026-05-12', 'pending'),
makeRow('D-002', 'Foundation drawings A', 'Beta', '2026-05-20', 'submitted'),
makeRow('D-003', 'Procurement schedule', 'Acme', '2026-05-08', 'accepted'),
makeRow('D-004', 'Safety plan', 'Gamma', '2026-05-15', 'pending'),
makeRow('D-005', 'Geotechnical report', 'Beta', '2026-05-30', 'submitted'),
];
// Inject a complete table context into the page. Same pattern as
// form-safety.spec.js: write a patched copy of tables.html to a temp
// file and navigate via file://.
async function loadTableWithContext(page, context) {
const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/');
const replacement = `<script id="table-context" type="application/json">${ctxJson}</script>`;
const patched = HTML_RAW.replace(
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
replacement,
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-spec-'));
const tmpPath = path.join(tmpDir, 'tables.html');
fs.writeFileSync(tmpPath, patched);
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
}
test.describe('tables/ — directory-of-YAML table view', () => {
test('renders header with column titles and rows from context', async ({ page }) => {
page.on('pageerror', e => console.log('[pageerror]', e.message));
await loadTableWithContext(page, {
title: 'Master Deliverables List',
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForFunction(
() => document.querySelector('#table-root tbody').children.length > 0,
null,
{ timeout: 5000 },
);
// Header cells.
const headers = page.locator('.zddc-table__title-row .zddc-table__th');
await expect(headers).toHaveCount(MDL_COLUMNS.length);
await expect(headers.nth(0)).toContainText('ID');
await expect(headers.nth(1)).toContainText('Deliverable');
// Title in the page header.
await expect(page.locator('#table-title')).toContainText('Master Deliverables List');
// Row count.
await expect(page.locator('#table-root tbody tr')).toHaveCount(ROWS.length);
await expect(page.locator('#table-rowcount')).toContainText(`${ROWS.length} rows`);
});
test('default sort puts dueDate ascending when configured', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
defaults: { sort: [{ field: 'dueDate', dir: 'asc' }] },
});
await page.waitForSelector('#table-root tbody tr');
const ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
// Sorted ascending by dueDate: D-003 (5/8), D-001 (5/12), D-004 (5/15), D-002 (5/20), D-005 (5/30).
expect(ids).toEqual(['D-003', 'D-001', 'D-004', 'D-002', 'D-005']);
});
test('clicking a column header sorts by that column and toggles direction', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Click the ID header → sort ascending.
await page.locator('.zddc-table__th[data-field="id"]').click();
let ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
expect(ids).toEqual(['D-001', 'D-002', 'D-003', 'D-004', 'D-005']);
// Click again → descending.
await page.locator('.zddc-table__th[data-field="id"]').click();
ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
expect(ids).toEqual(['D-005', 'D-004', 'D-003', 'D-002', 'D-001']);
});
test('free-text filter narrows visible rows', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Type "report" in the title column's filter — should match the
// two rows whose title contains "report" (Site survey report,
// Geotechnical report).
const titleFilter = page.locator('.zddc-table__th[data-field="title"]')
.locator('..')
.locator('xpath=following-sibling::tr[1]')
.locator('input[type="text"]')
.nth(0);
// Simpler selector: nth filter input under the filter row.
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
await filterInputs.nth(1).fill('report'); // index 1 = title column
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('enum filter limits rows to selected values', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Status column is enum; pick "pending" only.
const statusSelect = page.locator('.zddc-table__filter-row select').nth(1); // 0=party, 1=status
await statusSelect.selectOption({ value: 'pending' });
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('click on editable row navigates to the row URL', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Stub the navigate seam render.js consults before falling back
// to window.location.assign (which Chromium won't let us override
// directly via a plain property assignment).
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
await page.locator('#table-root tbody tr').first().click();
const target = await page.evaluate(() => window.__navTarget);
expect(target).toBeTruthy();
expect(target).toContain('.yaml.html');
});
test('non-editable rows do not navigate on click', async ({ page }) => {
const readOnlyRows = ROWS.map(r => ({ ...r, editable: false }));
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: readOnlyRows,
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
await page.locator('#table-root tbody tr').first().click();
const target = await page.evaluate(() => window.__navTarget);
expect(target).toBeNull();
// Read-only rows should also lack the editable visual class.
await expect(page.locator('#table-root tbody tr.zddc-table__row--editable')).toHaveCount(0);
await expect(page.locator('#table-root tbody tr.zddc-table__row--readonly')).toHaveCount(ROWS.length);
});
test('default filters seed the visible row count from defaults.filter', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
defaults: { filter: { status: ['pending'] } },
});
await page.waitForSelector('#table-root tbody tr');
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('empty rows list shows the empty-state notice', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: [],
});
// Wait briefly for init.
await page.waitForTimeout(50);
await expect(page.locator('#table-root tbody tr')).toHaveCount(0);
await expect(page.locator('#table-empty')).toBeHidden();
});
});

View file

@ -479,6 +479,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
}
// Tables-system intercept: *.table.html is a virtual URL that the
// table handler renders inline, reading rows from a directory of
// *.yaml files declared in the directory's .zddc tables: map.
// Discovery is .zddc-declarative — no auto-mount on file presence —
// so RecognizeTableRequest returns nil whenever there's no matching
// declaration and the URL falls through to the static-file path
// (or to the form intercept below for *.form.html / *.yaml.html).
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
handler.ServeTable(cfg, tableReq, w, r)
return
}
// Form-system intercept: *.form.html and *.yaml.html under a sibling form
// folder are virtual URLs that the form handler renders inline, reading
// the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from

View file

@ -0,0 +1,138 @@
// Package handler — tablehandler.go: directory-of-YAML table view.
//
// URL convention:
//
// GET /<dir>/<name>.table.html → tables.html, only when <dir>/.zddc
// declares tables: { <name>: <spec-path> }
// AND the spec file exists.
//
// Discovery is .zddc-declarative (no auto-mount on file presence). The
// handler's only jobs are:
//
// 1. Recognize the URL — does <dir>/.zddc declare a table named <name>,
// and does the referenced *.table.yaml spec actually exist? If not,
// fall through to the static-file pipeline.
// 2. Gate on the cascading ACL at the request directory (read action).
// 3. Serve the static tables.html bytes.
//
// All rendering is client-side. The page (built from tables/) detects
// HTTP vs file:// mode, then walks the directory via shared/zddc-source.js
// (HttpDirectoryHandle in HTTP mode, real FileSystemDirectoryHandle in
// file mode), reads .zddc, parses the spec, and renders rows in the
// browser. The server does not pre-parse the spec, list rows, or compute
// per-row "editable" — the client does, and ACL is enforced naturally:
// HTTP-mode reads/writes go through the cascade-aware file API, and
// local-mode reads/writes are bounded by whatever the OS gave the
// FS-Access handle.
package handler
import (
_ "embed"
"log/slog"
"net/http"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
//go:embed tables.html
var embeddedTablesHTML []byte
// TableRequest describes a recognized table-system request.
type TableRequest struct {
// Name is the table's URL stem (the key declared in .zddc tables).
Name string
// SpecPath is the absolute filesystem path to the *.table.yaml.
// Validated to exist at recognition time.
SpecPath string
// Dir is the absolute path to the request directory (where the
// .zddc declared the table).
Dir string
}
// RecognizeTableRequest classifies r as a table-system request, or
// returns nil if it falls through to other handlers. Discovery is
// strictly .zddc-declarative — a *.table.html URL with no matching
// `tables:` entry in <dir>/.zddc returns nil so it falls through to
// the static-file path (404 unless an operator dropped a real file).
//
// Methods other than GET return nil — the table is read-only at the
// URL level. Writes go through the file API directly.
func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
if method != http.MethodGet {
return nil
}
if !strings.HasSuffix(urlPath, ".table.html") {
return nil
}
// Split <dir>/<name>.table.html into dir + name.
stem := strings.TrimSuffix(urlPath, ".table.html")
if stem == "" || stem == "/" {
return nil
}
dirRel := filepath.Dir(filepath.FromSlash(strings.TrimPrefix(stem, "/")))
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 {
return nil
}
zddcPath := filepath.Join(dirAbs, ".zddc")
zf, err := zddc.ParseFile(zddcPath)
if err != nil {
// Malformed .zddc — log and pass through; static handler will 500
// if it cares. Recognition just says "not a declared table here."
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
return nil
}
specRel, ok := zf.Tables[name]
if !ok {
return nil
}
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
return nil
}
if !fileExists(specAbs) {
return nil
}
return &TableRequest{
Name: name,
SpecPath: specAbs,
Dir: dirAbs,
}
}
// ServeTable serves the static tables.html bytes for a recognized
// request. ACL gate is the read action at the request directory; on
// allow, the embedded HTML is written verbatim. The client takes over
// from there — see tables/js/main.js.
func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
decider := DeciderFromContext(r)
chain, err := zddc.EffectivePolicy(cfg.Root, req.Dir)
if err != nil {
slog.Warn("table: policy error", "path", req.Dir, "err", err)
}
if allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, r.URL.Path, policy.ActionRead); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if len(embeddedTablesHTML) == 0 {
http.Error(w, "table renderer not built into this binary", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(embeddedTablesHTML)
}

View file

@ -0,0 +1,229 @@
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
const sampleTableSpec = `title: Master Deliverables List
description: Sample MDL.
rowSchema: ./MDL.form.yaml
rows: ./MDL
columns:
- field: id
title: ID
width: 6em
- field: title
title: Deliverable
- field: status
title: Status
enum: [pending, submitted, accepted]
defaults:
sort:
- { field: id, dir: asc }
`
const sampleRowFormSpec = `title: Deliverable
schema:
type: object
required: [id, title]
additionalProperties: false
properties:
id:
type: string
title:
type: string
status:
type: string
enum: [pending, submitted, accepted]
`
// tableTestSetup writes a directory tree under a temp root with:
//
// <root>/Working/.zddc → declares tables: { MDL: ./MDL.table.yaml }
// <root>/Working/MDL.table.yaml → spec
// <root>/Working/MDL.form.yaml → row schema
// <root>/Working/MDL/<file>.yaml → row data (one per entry in rows)
//
// Optional extra .zddc files at relative paths can be supplied via zddcFiles.
// Returns (config, do) where do dispatches a request through ServeTable via
// the same recognize → serve path the production catch-all uses.
//
// Note: under the client-side rendering architecture the handler does not
// parse the spec or list row files — the rows/spec on disk are written
// only because the ACL cascade may evaluate paths under them.
func tableTestSetup(t *testing.T, rows map[string]string, zddcFiles map[string]string) (config.Config, func(method, target, email string) *httptest.ResponseRecorder) {
t.Helper()
root := t.TempDir()
working := filepath.Join(root, "Working")
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
t.Fatalf("write spec: %v", err)
}
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
t.Fatalf("write form spec: %v", err)
}
for name, body := range rows {
if err := os.WriteFile(filepath.Join(working, "MDL", name), []byte(body), 0o644); err != nil {
t.Fatalf("write row %s: %v", name, err)
}
}
if _, ok := zddcFiles["Working"]; !ok {
if zddcFiles == nil {
zddcFiles = make(map[string]string)
}
zddcFiles["Working"] = `acl:
permissions:
"*@example.com": rwcd
tables:
MDL: ./MDL.table.yaml
`
}
for rel, body := range zddcFiles {
dir := filepath.Join(root, rel)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
zddc.InvalidateCache(dir)
if body == "" {
continue
}
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
do := func(method, target, email string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
ctx := context.WithValue(req.Context(), EmailKey, email)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
tableReq := RecognizeTableRequest(cfg.Root, method, target)
if tableReq == nil {
rec.WriteHeader(http.StatusNotFound)
return rec
}
ServeTable(cfg, tableReq, rec, req)
return rec
}
return cfg, do
}
func TestRecognizeTableRequest(t *testing.T) {
root := t.TempDir()
working := filepath.Join(root, "Working")
if err := os.MkdirAll(filepath.Join(working, "MDL"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(working, "MDL.table.yaml"), []byte(sampleTableSpec), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(working, "MDL.form.yaml"), []byte(sampleRowFormSpec), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(working, ".zddc"), []byte(`tables:
MDL: ./MDL.table.yaml
`), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(working)
cases := []struct {
method, url string
wantNil bool
wantSpec string
wantName string
}{
{"GET", "/Working/MDL.table.html", false, "Working/MDL.table.yaml", "MDL"},
// Same URL but POST → tables are read-only at the URL level.
{"POST", "/Working/MDL.table.html", true, "", ""},
{"PUT", "/Working/MDL.table.html", true, "", ""},
{"DELETE", "/Working/MDL.table.html", true, "", ""},
// Not declared in .zddc → not a table request.
{"GET", "/Working/Other.table.html", true, "", ""},
// No .zddc at the dir → not a table request.
{"GET", "/Other/MDL.table.html", true, "", ""},
// Random .html → falls through.
{"GET", "/index.html", true, "", ""},
// .form.html (form territory) → falls through to form handler.
{"GET", "/Working/MDL.form.html", true, "", ""},
// Path traversal attempt.
{"GET", "/../etc/passwd.table.html", true, "", ""},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.url, func(t *testing.T) {
got := RecognizeTableRequest(root, tc.method, tc.url)
if tc.wantNil {
if got != nil {
t.Errorf("got %+v, want nil", got)
}
return
}
if got == nil {
t.Fatalf("got nil, want a TableRequest")
}
if got.Name != tc.wantName {
t.Errorf("Name = %q want %q", got.Name, tc.wantName)
}
wantSpec := filepath.Join(root, tc.wantSpec)
if got.SpecPath != wantSpec {
t.Errorf("SpecPath = %q want %q", got.SpecPath, wantSpec)
}
})
}
}
// TestServeTable_ServesEmbeddedHTML — an ACL-passing GET returns the
// embedded tables.html bytes verbatim, with the empty inline context
// placeholder intact (so the client knows to walk the directory).
func TestServeTable_ServesEmbeddedHTML(t *testing.T) {
rows := map[string]string{
"D-001.yaml": "id: D-001\ntitle: One\nstatus: pending\n",
}
_, do := tableTestSetup(t, rows, nil)
rec := do(http.MethodGet, "/Working/MDL.table.html", "casey@example.com")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q want text/html…", ct)
}
body := rec.Body.String()
if !strings.Contains(body, `<table id="table-root"`) {
t.Error("body missing #table-root markup; embedded HTML may be stale or empty")
}
if !strings.Contains(body, `<script id="table-context" type="application/json">{}</script>`) {
t.Error("inline context placeholder not preserved verbatim — client expects {} so it knows to walk")
}
}
func TestServeTable_ACLForbidden(t *testing.T) {
zddcs := map[string]string{
"Working": `acl:
permissions:
"root@example.com": rwcd
tables:
MDL: ./MDL.table.yaml
`,
}
_, do := tableTestSetup(t, map[string]string{"D.yaml": "id: D\n"}, zddcs)
rec := do(http.MethodGet, "/Working/MDL.table.html", "stranger@example.com")
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
}
}

File diff suppressed because one or more lines are too long

View file

@ -84,6 +84,14 @@ type ZddcFile struct {
Apps map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
AppsPubKey string `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`
// Tables declares directory-of-YAML table views available at this
// directory. The map key becomes the URL stem: tables[MDL] is served
// at <dir>/MDL.table.html. The value is a path (relative to this
// .zddc) to a *.table.yaml spec describing columns and the rows
// directory. There is no upward cascade for tables in v1 — each
// directory that hosts a table declares it directly.
Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"`
// Roles are named principal groups available at this level and below.
// See Role for member syntax.
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`

View file

@ -0,0 +1,76 @@
package zddc
import (
"os"
"path/filepath"
"testing"
)
// TestParseFile_TablesRoundTrip exercises the Tables field added to
// support the table tool. A .zddc with a tables: map should round-trip
// through ParseFile cleanly without disturbing existing fields.
func TestParseFile_TablesRoundTrip(t *testing.T) {
root := t.TempDir()
body := `acl:
permissions:
"*@example.com": rwcd
title: Demo
apps:
archive: stable
tables:
MDL: ./MDL.table.yaml
Subcontracts: ./contracts/subs.table.yaml
roles:
reviewers:
members: ["bob@example.com"]
`
path := filepath.Join(root, ".zddc")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zf, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if got := zf.Tables["MDL"]; got != "./MDL.table.yaml" {
t.Errorf("Tables[MDL] = %q want %q", got, "./MDL.table.yaml")
}
if got := zf.Tables["Subcontracts"]; got != "./contracts/subs.table.yaml" {
t.Errorf("Tables[Subcontracts] = %q want %q", got, "./contracts/subs.table.yaml")
}
// Sibling fields should still parse.
if zf.Title != "Demo" {
t.Errorf("Title = %q want %q", zf.Title, "Demo")
}
if got := zf.Apps["archive"]; got != "stable" {
t.Errorf("Apps[archive] = %q want %q", got, "stable")
}
if r, ok := zf.Roles["reviewers"]; !ok || len(r.Members) != 1 {
t.Errorf("Roles[reviewers] = %+v want one member", r)
}
if got := zf.ACL.Permissions["*@example.com"]; got != "rwcd" {
t.Errorf("ACL.Permissions[*@example.com] = %q want rwcd", got)
}
}
// TestParseFile_TablesEmptyOmitted confirms that a .zddc without a
// tables: key parses with a nil Tables map (omitempty round-trip).
func TestParseFile_TablesEmptyOmitted(t *testing.T) {
root := t.TempDir()
body := `title: NoTables
acl:
permissions:
"*@example.com": r
`
path := filepath.Join(root, ".zddc")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zf, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if zf.Tables != nil {
t.Errorf("Tables = %+v want nil for absent tables: key", zf.Tables)
}
}