Compare commits

..

No commits in common. "adcf5dedd6dd4f9f201ed3881403f0fbb8a597f5" and "ba20e3e5bac1e41a0b2340a78e8d9cee6650bd57" have entirely different histories.

194 changed files with 5357 additions and 41297 deletions

8
.gitignore vendored
View file

@ -43,11 +43,3 @@ package-lock.json
zddc-knowledge*.json
zddc-knowledge*.md
zddc-knowledge*.html
# tests/data/test-archive.sh fixture output. Default is ~/zddc-test-data
# (outside the repo); these patterns catch in-repo redirects via
# TEST_ARCHIVE_DIR. Defense in depth — the real-archive CSV reference
# at ~/archive-export*.csv must NEVER end up in the repo.
/zddc-test-data/
/tests/data/output/
/archive-export*.csv

153
AGENTS.md
View file

@ -14,7 +14,7 @@
./build alpha # cut alpha (cascades nothing)
./build beta # cut beta (cascades alpha → beta)
./build release # cut stable, coordinated next version
# (cascades alpha + beta → new stable; tags all nine)
# (cascades alpha + beta → new stable; tags all seven)
./build release 1.2.0 # cut stable at explicit version
./build help
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
## 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 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).
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/
@ -144,7 +144,7 @@ Included as the **first** positional arg to every tool's `concat_files` CSS call
- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash.
- `concat_files` accepts **positional args only** (not array names).
- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `<script>`/`<link>` tags (pattern: `https?://`)
- `{{BUILD_LABEL}}` is substituted in all eight HTML tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
- `{{BUILD_LABEL}}` is substituted in all six tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `Built: <timestamp> BETA` for dev builds, `v<version>` for stable releases, and `<channel> · <date> · <sha>` for alpha/beta channel builds; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/alpha/beta, false for stable).
- Cleans up temp files via `trap cleanup EXIT`
**`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
@ -223,11 +223,11 @@ Format: `trackingNumber_revision (status) - title.extension`
| `zddc-server_<X>.html` | generated stub page | per-version / per-channel; lists the four platform downloads. This is what the matrix-cell link points at — one stub fans out to four binaries |
| `index.html` | regenerated by `build.sh` | matrix table, one column per tool, one row per release |
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (so cascades and the verifier see a complete world), forwards each HTML tool's build with the agreed version, then `promote_zddc_server` (in `shared/build-lib.sh`) copies the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the index, then `verify_channel_links` asserts nothing dangles. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all nine artifacts at that commit. `./deploy --releases` then publishes the bundle.
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (so cascades and the verifier see a complete world), forwards each HTML tool's build with the agreed version, then `promote_zddc_server` (in `shared/build-lib.sh`) copies the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the index, then `verify_channel_links` asserts nothing dangles. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all seven artifacts at that commit. `./deploy --releases` then publishes the bundle.
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the eight HTML tools + per-version binaries for zddc-server (real bytes, immutable). Refreshes 5 symlinks per HTML tool + 5 symlinks per zddc-server platform → the new version. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all nine (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes. Cascade: stable cut means beta and alpha both reset to stable for every tool.
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the six HTML tools + per-version binaries for zddc-server (real bytes, immutable). Refreshes 5 symlinks per HTML tool + 5 symlinks per zddc-server platform → the new version. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all seven (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes. Cascade: stable cut means beta and alpha both reset to stable for every tool.
- **Beta** (`./build beta`): Overwrites `<tool>_beta.html` with dist bytes for each HTML tool, and `zddc-server_beta_<platform>` with each platform's binary. Updates `zddc/internal/apps/embedded/*` to beta-labeled bytes (the dev image picks them up via `ZDDC_REF=main`). Cascade: `<tool>_alpha.html``<tool>_beta.html` and `zddc-server_alpha_<platform>``zddc-server_beta_<platform>` (symlinks). No tag.
- **Alpha** (`./build alpha`): Overwrites only the alpha mirrors in `dist/release-output/`, all nine artifacts. **Does NOT update `zddc/internal/apps/embedded/`** — the project invariant is that alpha is never baked into the binary. No tag, no other side-effects.
- **Alpha** (`./build alpha`): Overwrites only the alpha mirrors in `dist/release-output/`, all seven tools. **Does NOT update `zddc/internal/apps/embedded/`** — the project invariant is that alpha is never baked into the binary. No tag, no other side-effects.
- **Plain dev builds** (`./build` with no arg): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `dist/release-output/`, the live site, or `embedded/`. Use it to iterate without affecting deployable state.
**Bake-in invariant** — what zddc-server's binary embeds via `//go:embed` from `zddc/internal/apps/embedded/`:
@ -251,7 +251,7 @@ After cutting a stable release, `git push origin main && git push origin --tags`
### Channel discipline (MUST rules)
The build enforces lockstep mechanically (one command bumps all nine). The rules below are still on you.
The build enforces lockstep mechanically (one command bumps all seven). The rules below are still on you.
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Stable per-version files are immutable.
2. **Lockstep is the contract.** Don't cut a single tool's release without bumping the rest. The HTML tool's standalone `--release` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
@ -284,11 +284,18 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent
No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). The server virtually serves them at folder-name-driven paths:
- `archive.html` at every directory (multi-project, project, archive, vendor levels)
- `classifier.html` in any `Incoming`/`Working`/`Staging` directory and its subtree
- `mdedit.html` in any `Working` directory and its subtree
- `transmittal.html` in any `Staging` directory and its subtree
- `index.html` (landing) only at the deployment root
See `internal/apps/availability.go`. Outside these locations, requesting `<app>.html` returns 404 (just like any other missing file).
To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win.
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.
@ -311,7 +318,7 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi
- Two-phase hydration: `populateStatic()` before publish, `hydrate()` on load of published file
- Reactive state via Proxy — `app.state.mode = 'view'` auto-notifies subscribers
- No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record."
- Runtime CDN loads (jszip, docx-preview, xlsx) are allowed only for the optional DOCX/XLSX preview; core features work offline
- Published payload stored in `<script id="transmittal-data" type="application/json">`
## mdedit-specific
@ -326,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.
**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 /<dir>/form.html` — render empty form
- `POST /<dir>/form.html` — create new submission → 201 + Location capability URL pointing at the new `<dir>/<id>.yaml`
- `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200
**URL conventions** (form posts back to its own URL; server strips `.html`):
- `GET /<path>/<name>.form.html` — render empty form
- `POST /<path>/<name>.form.html` — create new submission → 201 + Location capability URL
- `GET /<path>/<name>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<path>/<name>/<id>.yaml.html` — overwrite that submission → 200
**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.
**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.
**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.
@ -340,38 +347,7 @@ 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.
**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.
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`: `originator`, `phase`, `project`, `area`, `discipline`, `type`, `sequence`, `suffix` — each one a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The form schema accepts free-text on every component (no enums or regex constraints) so projects pick their own conventions. Operators customize by dropping their own `table.yaml` + `form.yaml` into `archive/<party>/mdl/`; both files override the embedded defaults atomically (no merge — operator-supplied wins entirely). Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
**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`.
**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`.
## Implementation-vs-dependency policy
@ -470,92 +446,15 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). |
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `<ZDDC_ROOT>/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. |
| `ZDDC_NO_AUTH` | *(empty)* | `1` skips ACL enforcement entirely on this instance. On a master: anyone reads everything (dev / trusted-LAN read-only deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. **Distinct from `ZDDC_INSECURE`** (which gates a startup safety check). |
| `ZDDC_UPSTREAM` | *(empty)* | Master URL (`https://master.example.com`). When set, the binary runs as a **client** (downstream proxy/cache/mirror) instead of a master — the master-side machinery (archive index, apps server, watcher, OPA, ACL middleware, token store) is replaced by the cache layer in `zddc/internal/cache/`. `--root` becomes the cache directory. **Setting this also downgrades the `--addr` default to `127.0.0.1:8443` (loopback)** — the cache forwards a bearer to upstream without authenticating the local caller, so non-loopback binds with `ZDDC_BEARER_FILE` set are refused unless `ZDDC_INSECURE_DIRECT=1` is also set. |
| `ZDDC_MODE` | `cache` | Client mode: `proxy` (forward live, no persistence), `cache` (default; persist responses on access), `mirror` (phase 3 — currently behaves like `cache`). Ignored when `ZDDC_UPSTREAM` is empty. |
| `ZDDC_BEARER_FILE` | *(empty)* | Path to a 0600 file containing the master-issued token (see `/.tokens` on the master). Forwarded as `Authorization: Bearer …` to upstream on every request. Ignored when `ZDDC_UPSTREAM` is empty. |
| `ZDDC_SKIP_TLS_VERIFY` | *(empty)* | `1` accepts self-signed / untrusted upstream certs. Distinct from `ZDDC_NO_AUTH`. Dev / internal-CA scenarios only. |
| `ZDDC_MIRROR_SUBTREE` | *(empty)* | Comma-separated URL subtrees the access-triggered mirror walker keeps current (e.g. `/Vendors/Acme,/Public`). Empty + `ZDDC_MODE=mirror` = full mirror (`/`). Ignored when `ZDDC_MODE != mirror`. |
| `ZDDC_MIRROR_MIN_INTERVAL` | `1h` | Minimum gap between walks of the same mirror subtree. Idle subtrees generate zero upstream traffic until next access. Format is Go `time.ParseDuration`. |
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
| `ZDDC_APPS_PUBKEY` | *(empty)* | Path to PEM Ed25519 pubkey for verifying signatures on URL-fetched `apps:` artifacts. Empty = URL apps refused. Download from `zddc.varasys.io/pubkey.pem` (canonical channels) or supply your own. No baked-in default — same posture as TLS certs. Alternative inline form: `apps_pubkey:` in root `.zddc` (root-only, env/flag wins). |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### URL handling
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (`working/`, `staging/`, `archive/<party>/incoming/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
**`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip.
**`GET /dir/?zip=1` — subtree download.** Streams an `application/zip` of every readable file under `/dir/` (recursively), `Content-Disposition: attachment; filename="<dir>.zip"`, `X-ZDDC-Source: subtree-zip`. ACL-filtered per file's containing-dir `.zddc` chain (per-dir decision cache, same as `serveArchiveListing`); skips `.`/`_`-prefixed entries (`.zddc`, `_template`, `_app`); adds a `.zip` *file* it meets as opaque bytes (does not recurse). Streamed, so an empty/fully-denied subtree is a valid empty zip, not a 403. The query check is in `dispatch`'s `info.IsDir()` branch right after the directory ACL gate (so it works on `/dir` and `/dir/`); code: `handler.ServeSubtreeZip`. The browse tool's toolbar "Download (zip)" button uses it in server mode; offline it bundles the picked folder with JSZip (`confirm()` above ~2000 files / ~500 MB).
### Client mode (proxy / cache / mirror)
When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store.
Three modes via `--mode <proxy|cache|mirror>` (default `cache`). Cache directory layout is intentionally a normal ZDDC root: `<master>/foo/bar.txt``<root>/foo/bar.txt`. Unset `--upstream` and the same root serves as a plain master, useful for portable offline snapshots.
Pipeline:
- Cache hit → serve immediately + background `If-Modified-Since` revalidate (304 no-op, 200 overwrite, 403/404 purge).
- Cache miss → forward to upstream; stream response simultaneously to client and a tmp-file atomically renamed into the cache.
- Network error + cached version → serve stale + `X-ZDDC-Cache: offline`.
- Network error + no cache → 503 + `X-ZDDC-Cache: offline`.
- Directory listings cached as `<dir>/.zddc-listing.<html|json>` sidecars (Accept-varied).
- `Cache-Control: no-store` / `private` responses pass through but are not persisted.
- **Writes** (PUT / POST / DELETE) forward to upstream when online; on transport error, queue in `<root>/.zddc-outbox/<id>/` (meta + body) and return `202 Accepted` + `X-ZDDC-Cache: queued`. Background loop replays in order — 2xx deletes the entry, 412 → `<id>.conflict-<ts>/`, 4xx-other drops, 5xx defers. PUT/DELETE include `If-Unmodified-Since` from the cached mtime so the master can reject conflicting writes.
- **Mirror mode** (`--mode mirror`): adds an access-triggered subtree walker (rate-limited via `--mirror-min-interval`, default 1h) that recursively pre-fetches under `--mirror-subtree`s; idle mirrors generate zero upstream traffic.
Two-instance smoke test recipe:
```sh
# Master.
mkdir -p /tmp/m && echo 'admins: [you@example.com]' > /tmp/m/.zddc
echo "hello" > /tmp/m/hello.txt
zddc-server --root /tmp/m --addr 127.0.0.1:18443 --tls-cert=none --no-auth &
# Client (cache mode).
mkdir -p /tmp/c
zddc-server --root /tmp/c --addr 127.0.0.1:18444 --tls-cert=none \
--upstream http://127.0.0.1:18443 --mode cache --no-auth &
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → miss
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit
ls /tmp/c # → hello.txt + .zddc-upstream marker
kill %1; sleep 1
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit (still served from disk)
curl -si http://127.0.0.1:18444/never.txt | head -1 # → 503
```
`X-ZDDC-Cache` response header values: `miss`, `hit`, `proxy` (no-persist or directory), `offline` (network unreachable). Useful for browser-side freshness UI.
Implementation: `zddc/internal/cache/cache.go` (a single file). Tests in `zddc/internal/cache/cache_test.go` use `httptest.NewServer` as a fake upstream and cover hit/miss/offline/range/bearer-forwarding/no-store paths.
### Bearer tokens (CLI auth)
zddc-server self-issues bearer tokens for CLI / non-browser callers. No external IDP, no JWKS rotation. Source of truth: `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` — a YAML file per token with `email`, `created`, `expires`, `description`. Filename is the **hash** of the token; the plaintext is never persisted.
User flow: sign in to the master in a browser, visit `/.tokens`, click "Create token," copy the value (shown once). Store in a 0600 file and pass `--bearer-file <path>` to a CLI that calls back into zddc-server, or send `Authorization: Bearer <token>` directly from scripts.
Endpoints:
- `GET /.tokens` — HTML self-service page (gated by browser auth).
- `GET/POST /.api/tokens` — list / create. Plaintext returned **only** on POST response.
- `DELETE /.api/tokens/<id>` — revoke. `<id>` is the 8-char short ID or full hash.
Validation flow inside the request path: `ACLMiddleware` checks for `Authorization: Bearer …` first; on success, sets the request email from the token file. On any failure (unknown / expired / store unavailable), returns 401 — there is no fallback to header-based auth, so a misconfigured client can't silently masquerade as anonymous. If no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segments 404 from direct GETs, and `fs.ListDirectory` filters them from listings. **Verify on any new deployment by attempting `GET /.zddc.d/tokens/anything` and confirming 404.**
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Release tagging
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags.
zddc-server has no separate release script. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags.
```sh
./build release # lockstep stable, coordinated next version
@ -567,7 +466,7 @@ zddc-server has no separate release script. The top-level `./build alpha|beta|re
The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags` (and run `./deploy` to put the artifacts on the live site).
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all nine sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — channel URLs are stable URLs by design. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all seven sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — channel URLs are stable URLs by design. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.

View file

@ -62,11 +62,11 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
```
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place.
**zddc-server binaries are reproducible from a tag, not in git** — `./build alpha|beta|release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev,cache}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
**zddc-server binaries are reproducible from a tag, not in git** — `./build alpha|beta|release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build alpha|beta|release` and then `./deploy`.
@ -89,7 +89,7 @@ Each topic has exactly one authoritative home; everything else links to it.
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all eight HTML tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all six tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
@ -112,7 +112,7 @@ The top-level `./build` at the repository root is the canonical lockstep entry p
1. On a channel/release cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
2. Forwards `--release [version|alpha|beta]` to every HTML tool's build, computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the eight HTML-tool tags (stable cuts only).
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the six HTML-tool tags (stable cuts only).
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `dist/release-output/`.
6. Regenerates `dist/release-output/index.html` as the action-first download page.
7. Calls `verify_channel_links` — fails the build if any channel link is dangling.
@ -121,11 +121,11 @@ Then `./deploy --releases` rsyncs `dist/release-output/` → `/srv/zddc/releases
### Channels
Three release channels, applied in lockstep across all nine artifacts (8 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
Three release channels, applied in lockstep across all seven tools (6 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the eight HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the six HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags `<tool>-v<X.Y.Z>` for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
- **Beta**`./build beta` overwrites `<tool>_beta.html` for each HTML tool and `zddc-server_beta_<platform>` for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
- **Alpha**`./build alpha` overwrites only the alpha mirrors, all nine artifacts. No tag, no other side-effects.
- **Alpha**`./build` overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.
A plain `./build` (no arg) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/` or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.
@ -210,32 +210,20 @@ Some tools bundle third-party libraries. These live in `tool/vendor/` and are co
|------|---------|------|-------|
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
| shared | jszip | `shared/vendor/jszip.min.js` | ZIP read for previews + classifier hash-export |
| shared | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
| transmittal | jszip, docx-preview, xlsx | CDN at runtime | Optional preview features; tool works without them |
**No runtime CDN loads.** Every external dependency is vendored into
`shared/vendor/` (or, for mdedit's editor, `mdedit/vendor/`) and
concatenated into each tool's bundle at build time. Tools that need a
given library include the vendor path in their `build.sh`'s
`concat_files` JS list. The "ship the record player with the record"
philosophy: a downloaded `.html` file works offline against any file
the user can open, with no network dependency at runtime.
**Runtime CDN loading exception**: The transmittal tool loads jszip, docx-preview, and xlsx from CDN at runtime via `loadLibrary()` forDOCX/XLSX preview functionality. These are **optional enhancements**—core transmittal functionality (JSON payload communication) works without them. This exception is documented here because:
Trade-off accepted: bundle sizes are larger. archive, classifier,
transmittal land around 1.5 MB after gzip; mdedit lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF.
Justified by the offline-first guarantee: any tool downloaded from
`/releases/` works without network, against air-gapped archives,
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
for the longer rationale.
1. The core transmittal features (creating, signing, verifying SHA-256 digests) do not depend on these libraries
2. Preview functionality gracefully degrades if libraries fail to load
3. Bundling would significantly increase file size for rarely-used features
`template.html` for tools with vendor deps loads those deps from CDN
purely for **dev convenience** — opening a template.html directly in
Chromium gives you a working tool without running a build. The build
script strips/replaces those CDN tags so the dist HTML has every
dependency inlined. No CDN URLs survive into the dist.
**Rule**: Runtime CDN loading is allowed only when:
- Features are strictly optional (graceful degradation)
- Core functionality works without the external library
- Library is clearly documented as non-essential
`template.html` for tools with vendor deps loads those deps from CDN for convenient local development. The build script replaces CDN tags with the bundled vendor files in the output.
### Development vs Production
@ -290,29 +278,17 @@ main.js ← Initialization (depends on all modules)
### State Management
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, mdedit, browse, form, tables), and it doesn't hide control flow.
Tools manage state in one of two patterns:
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
**1. Direct state on `window.app`** (archive, classifier, mdedit)
```javascript
window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };
// Mutate then re-render:
window.app.files.push(newFile);
window.app.modules.table.render();
```
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
State is read directly; mutations trigger explicit re-render calls. Classifier additionally layers a small pub-sub on top via `store.js` (`store.on('files', render)`).
**2. Pub-sub store on top of #1** (classifier)
```javascript
store.set('files', newFiles);
store.on('files', render);
```
Adds a tiny `store.on(key, fn)` / `store.notify(key)` layer in `classifier/js/store.js`. Justification: classifier has multiple independent panels (file list, spreadsheet, validation pane) that all need to react to the same state changes; calling three separate `render*()` functions from every mutation site would invite forgetting one.
**3. Proxy-based reactive state** (transmittal)
**2. Proxy-based reactive state** (transmittal)
```javascript
const state = createReactiveState({ mode: 'edit', published: false });
@ -320,26 +296,7 @@ state.subscribe((prop, newVal) => { /* auto-update UI */ });
state.mode = 'view'; // Proxy notifies all subscribers automatically
```
Used by transmittal because a single state change (e.g. `mode`) drives ≥3 independent UI regions (header chrome, body editability, action toolbar). Reactive shines when the cross-cutting wiring would otherwise be tedious. **Don't reach for this pattern unless you have at least three subscribers per state property.**
### `zddcMode` dispatcher (form / tables unified bundle)
The form and tables tools share a single compiled bundle (`tables/dist/tables.html`, also `//go:embed`d into `zddc-server` at `zddc/internal/handler/tables.html`). One window, two views. The bundle holds both `window.tablesApp` and `window.formApp`; whichever app paints is decided by a single global:
```javascript
// Set by the server-injected context (or absent for standalone form.html):
window.zddcMode = 'form' // → form renderer paints; tables app no-ops
window.zddcMode = 'table' // → tables app paints; form app no-ops
window.zddcMode = undefined // → standalone form.html, treated as 'form'
```
Each app's `main.js` checks `window.zddcMode` first and returns early when it's not their mode (see `form/js/main.js:10`, `tables/js/mode.js`). Rules for adding a third mode:
1. Set `window.zddcMode = '<new>'` in `tables/js/context.js` based on server context shape.
2. Add the new app's main module with the same early-return guard.
3. Keep the standalone-fallback rule consistent: undefined `zddcMode` should still mean "the lightest, most common mode for this bundle's standalone HTML."
Standalone `form/dist/form.html` uses this contract too — it has no `zddcMode` set, so form's main runs unconditionally and renders either the schema (when injected) or a friendly empty-state welcome (`form/js/main.js renderStandaloneWelcome`).
Use reactive state when the same property drives multiple independent UI elements. Use direct state when the data flow is simple and unidirectional.
---
@ -502,151 +459,15 @@ none of them is load-bearing alone.
| Layer | Job | Implementation |
|---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records |
| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically |
### Master + proxy / cache / mirror
The same `zddc-server` binary runs in two distinct topologies:
- **Master mode** (default): the binary owns a file tree under `--root`, applies `.zddc` ACL cascades to incoming requests, serves files / virtual app HTML / archive listings / form submissions / table views. The "normal" zddc-server. All of `cmd/zddc-server/main.go` lives here.
- **Client mode** (`--upstream <url>` set): the binary becomes a downstream proxy/cache/mirror against another zddc-server. The master-side machinery (archive index, apps server, watcher, OPA decider, ACL middleware, token store) is **bypassed entirely**. `zddc/internal/cache/` is the entire request handler.
Three sub-modes within client mode, controlled by `--mode <proxy|cache|mirror>`:
| Mode | Persists responses? | Subtree warmer? | Use case |
|---|---|---|---|
| `proxy` | no | no | thin pass-through; nothing on local disk |
| `cache` (default) | yes | no | field engineer — what you've viewed is available offline |
| `mirror` | yes | yes (access-triggered, subtree-scoped) | vendor mirrors of their subtree; admin backups; complete offline working set |
Internally the modes collapse to two switches on a single request-handling pipeline (`persist`, `warm`). Proxy is cache without disk writes; mirror is cache plus an access-triggered walker. Implementation factor: `cache.New` reads `cfg.Mode` once and sets `c.persist = mode != "proxy"`; the warmer is the only path that doesn't yet exist (phase 3).
**Mirror scope falls out of auth.** Whatever the client's bearer can see at upstream is what the cache can populate. Admin's bearer → mirror gets everything (full backup). Vendor's bearer → mirror is exactly that vendor's permitted subtree. No code distinguishes admin-vs-user — master-side ACL filtering does it.
#### Cache directory IS a normal ZDDC root
The cache directory layout is intentionally a regular ZDDC root: `<master>/foo/bar.txt` is stored at `<root>/foo/bar.txt`. No sidecar metadata files. The local file's `mtime` is set to the upstream's `Last-Modified` header (so revalidation via `If-Modified-Since` reflects the master's notion of file age, not local fetch time). A small `.zddc-upstream` marker file at the root records the upstream URL and first-cached-at timestamp, written once by `sync.Once` on first persist.
Two consequences:
- `zddc-server --root <cache-dir>` (without `--upstream`) serves whatever's been cached as a plain master. Useful for portable offline snapshots — tar the directory, hand it to a colleague, they have a working ZDDC.
- The master/client boundary is one flag: setting/unsetting `--upstream` switches behavior on the same on-disk root.
#### Pipeline
Phase 2 ships GET/HEAD only; writes are deferred to a later phase. For each incoming request:
1. **Directory request** (URL ends in `/`): always proxied live. Listing-cache support belongs with the mirror walker (phase 3) — the bare cache directory's contents only reflect visited files, so a local-walk listing would be misleading.
2. **File request, cache hit** (`persist` mode): serve cached bytes via `http.ServeContent` (which handles `Range` natively + 304 conditional GETs). Header `X-ZDDC-Cache: hit`. Background goroutine fires an `If-Modified-Since` revalidate; on `304` no-op, on `200` overwrite the cache atomically, on `403`/`404` purge.
3. **File request, cache miss**: build an upstream request preserving `Range`, `If-Range`, `Accept`, `Accept-Encoding`; attach the configured bearer. Stream the response simultaneously to the client AND to a tmp file in the cache directory; rename atomically only on success. Header `X-ZDDC-Cache: miss`.
4. **Proxy mode** (no persist): same as miss but skip the tmp-file teeing. Header `X-ZDDC-Cache: proxy`.
5. **Network error + cached version exists**: serve the cached bytes with `X-ZDDC-Cache: offline`. (When the cache hits before any network attempt, the header is `hit` — there's no way to distinguish "hit while online" from "hit while offline" without an extra round-trip; the header tells the user "this is from disk," and the user infers freshness from context or a future explicit freshness probe.)
6. **Network error + no cached version**: `503 Service Unavailable` + `X-ZDDC-Cache: offline`.
Responses with `Cache-Control: no-store` or `Cache-Control: private` pass through but are not persisted. Non-200 responses (including 206 partial content) are forwarded but not persisted — caching a partial body would corrupt subsequent full-body reads.
Hop-by-hop headers per RFC 7230 §6.1 (`Connection`, `Keep-Alive`, `Transfer-Encoding`, etc.) are dropped from forwarded responses; Go's transport drops most automatically, but the cache layer adds a guard for the cases that slip through.
#### Mirror walker (access-triggered)
`--mode mirror` adds an access-triggered subtree warmer (`zddc/internal/cache/walker.go`) on top of the cache pipeline. Naive design ("walk on a fixed timer") would scale poorly: many vendor mirrors against one master would generate thundering-herd polls of subtrees no human has looked at in months. Instead, walks are demand-triggered, rate-limited per-subtree.
Trigger policy (`MirrorScheduler.Trigger(urlPath)` is installed as the cache layer's `onAccess` hook, called in a goroutine on every authenticated request):
1. Match `urlPath` against the configured `--mirror-subtree`s. Longest prefix wins; `/` is a catch-all (full mirror).
2. If a walk is already in flight for that subtree, no-op.
3. If `now - last_walk_at < --mirror-min-interval` (default 1h), no-op.
4. Otherwise, mark in-flight and kick a walk goroutine.
Walk:
1. Recursively fetch JSON listings under the subtree, persisting each as `<dir>/.zddc-listing.json` (so directory browsing works offline for walked subtrees).
2. For each file, fire a conditional `If-Modified-Since` GET (bounded parallelism — default 4 concurrent, configurable). 304 = no-op; 200 = overwrite; 403/404 = purge.
3. Per-directory orphan purge: any local file present locally but absent from the upstream listing is removed (handles upstream deletes + ACL revocations).
State persists at `<root>/.zddc-mirror-state.json` as `{subtrees: {<path>: {last_walk_at}}}`. In-flight tracking is in-memory only — a crash mid-walk lets the next access retry without manual cleanup.
Properties:
- **Idle mirrors are quiet.** No requests means no walks means zero upstream traffic.
- **Active mirrors stay current as a side effect of normal use** (no explicit refresh gesture).
- **Revocation latency** is bounded by access frequency. Documented behavior, not a guarantee.
- **Bounded concurrency** keeps walks from starving the user's interactive requests on the same connection pool.
#### Writes: outbox + offline replay
`PUT` / `POST` / `DELETE` are handled by `cache.handleWrite`. Online: forwarded to upstream; on success the cached entry for the path (if any) is dropped so the next read fetches fresh. PUT/DELETE include `If-Unmodified-Since` from the cached file's mtime — the master returns `412 Precondition Failed` if its file changed since the cache observed it, so concurrent writes can't silently clobber.
When upstream is unreachable, the request is captured in the **outbox** (`zddc/internal/cache/outbox.go`) under `<root>/.zddc-outbox/<id>/``meta.json` (method, raw URI, content-type, base mtime, queued-at) + `body.bin` (request body, capped at `MaxOutboxBodyBytes` = 256 MiB). The client gets back `202 Accepted` + `X-ZDDC-Cache: queued` and a JSON envelope referencing the queued entry.
A background `RunReplayLoop` started by `runClient` in main.go replays in queue order:
- `2xx` → entry deleted; cached entry for the path (if any) dropped so the next read fetches fresh.
- `412` → entry renamed to `<id>.conflict-<RFC3339>/`. The conflict directory keeps both `meta.json` and `body.bin` intact for manual reconciliation.
- `4xx` other than `412` → entry dropped (won't succeed on retry; logged at `WARN`).
- `5xx` / transport error → left in place for the next pass.
Replay schedule: an eager pass at startup, then 30s while pending, 5min while idle. Honors graceful-shutdown context cancellation. Disabled in `--mode=proxy` (proxy mode persists nothing by design — offline writes just return `503`).
ID encoding (`<unix-nano-base16>-<hex-random>`) is lex-sortable so directory iteration replays in queue order without an explicit index. `MarkConflict` appends `.conflict-<ts>` to the directory name; if a same-second conflict collides (unlikely), a 4-char random suffix is appended.
The local cache is not updated for offline writes by design — until upstream confirms, the user reads still see the upstream-cached version (or 503 if uncached). Trade-off: the user doesn't see their own offline edits immediately, but no "did the queued write actually win?" ambiguity. Phase 5 will add a conflict-resolution UI that surfaces `.conflict-<ts>/` directories alongside the cached files in browse views.
#### Multi-tenancy: explicitly out of scope (v1)
The local instance forwards a single bearer (loaded from `--bearer-file` at startup) regardless of who's calling locally. Single-user-trust on a laptop. For multi-user scenarios, run multiple instances on the same host, or front the local server with your own auth proxy that injects per-user bearers downstream — both options keep the cache layer's design surface minimal.
#### Confused-deputy guard at startup
Because the cache forwards a bearer upstream without authenticating the local caller, exposing the bind on a non-loopback interface would turn the binary into an open-proxy laundering anyone's request through the master. The config layer (`zddc/internal/config/config.go`) enforces two defenses:
1. **Loopback default in client mode.** When `--upstream` is set, `--addr` defaults to `127.0.0.1:8443` instead of `:8443` — but only when `--addr` / `ZDDC_ADDR` was *not* set explicitly. CLI users on a laptop get safe-by-default; operators who want a non-loopback bind opt in explicitly.
2. **Refuse non-loopback bind + bearer without acknowledgement.** A non-loopback `--addr` *with* a configured `--bearer-file` *without* `--insecure-direct` (`ZDDC_INSECURE_DIRECT=1`) refuses to start. The error message names the bind, names the flag to acknowledge, and names the threat (open proxy confused-deputy). The helm `zddc-server-cache/` chart sets `ZDDC_INSECURE_DIRECT=1` and relies on Kubernetes-namespaced networking for the gating — that path is unaffected. The guard is bearer-file-conditional because proxy mode without a bearer doesn't have a credential to launder, and refusing it would needlessly block proxy-without-auth deployments.
### Bearer token issuance
zddc-server issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). The master is the identity provider; no external IDP, no JWKS rotation.
**Storage** — `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` per token. Filename is the **hash** of the token, never the plaintext value. File contents are YAML (`email`, `created`, `expires`, `description`). Mode 0600, directory mode 0700, atomic writes via temp+rename.
**Why hash-as-filename**: a leak of the tokens directory (backup tools, FS-level audit logs, accidental `ls` in a screen recording) exposes hashes, not credentials. Same posture as `/etc/shadow` storing password hashes rather than passwords. The plaintext exists only in transit (HTTP `Authorization` header) and on the operator's disk (a 0600 file they manage).
**Self-service flow**:
1. User signs in via the browser (master's normal upstream auth).
2. Visits `/.tokens` — small HTML page (`zddc/internal/handler/tokenhandler.go`) listing existing tokens and offering a creation form.
3. JS fetches the JSON API (`/.api/tokens`), POSTs a new token, displays the plaintext **once**.
4. User copies into a 0600 file; passes `--bearer-file <path>` to a CLI.
**API**:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (no plaintext) |
| `POST` | `/.api/tokens` | create; plaintext returned exactly once |
| `DELETE` | `/.api/tokens/<id>` | revoke (8-char ID or full 64-char hash) |
**Validation in the request path**: `ACLMiddleware` in `zddc/internal/handler/middleware.go` checks `Authorization: Bearer …` first; on success, sets the request email from the token file and falls through. Any failure (missing / malformed / expired) → `401`. There is no silent fallback to anonymous on Bearer failure — a misconfigured client must fail loudly rather than escalate to "no auth at all." When no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
**Directory shielding**: the tokens path is shielded by the existing `.`-prefix rules — `dispatch()` 404s any URL containing a dot-prefixed segment (other than the recognized virtual prefixes), and `fs.ListDirectory` filters dot entries from listings. The token system relies on this; a regression here is a credentials-leak vector. The token-handler test suite (`tokenhandler_test.go`) exercises the auth path; verifying the URL-level guard is the responsibility of `main_test.go` (`TestDispatchHidesDotPrefixedSegments`).
### `--no-auth` / "this instance is not the ACL boundary"
A symmetric flag, used in two distinct deployment shapes:
- **Master with `--no-auth`**: no ACL enforcement, no auth required. Anyone hitting the port reads everything in scope. Suitable for dev, internal trusted-LAN read-only tooling, or genuinely public archives.
- **Client with `--no-auth`** (downstream proxy/cache/mirror — see "Master + proxy / cache / mirror" below for context): the client trusts upstream's ACL filtering. Whatever the upstream returned is what the client serves; no per-request re-evaluation against `.zddc` files in the cache directory. Single-user-trust model on a laptop.
Implementation is a single swap: `policy.AllowAllDecider{}` replaces the configured decider when `cfg.NoAuth` is true. All existing handlers continue to call `policy.AllowFromChain` (or equivalent) unchanged; they just always get `allowed=true`. Logged at `WARN` on every restart so operators who set the flag inadvertently see it on stderr.
Distinct from `--insecure`, which only relaxes a startup-time safety check (refuse to start when no root `.zddc` exists). The two flags are independent.
### Commercial vs federal trust model
The current implementation is well-shaped for a commercial-tenant model with
@ -690,34 +511,14 @@ Cascade evaluation walks leaf→root for the first level whose entries match the
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
#### Canonical folders, URL routing & the `.zddc` cascade
#### Special folders
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
The schema keys that drive built-in behavior:
- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents.
| Key | Effect | Cascade rule |
|---|---|---|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app" | leaf→root (parent applies to descendants) |
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse` | leaf→root |
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`); fenced adds `acl.inherit:false` (private) | leaf-only |
| `virtual` | never materialise on disk; requests are virtual routes (`reviewing/`, `mdl`) | leaf-only |
| `drop_target` | browse shows a drag-drop upload overlay (surfaced via `X-ZDDC-Drop-Target`) | leaf-only |
| `worm` | list of principals — see WORM below | union across cascade (no reset) |
| `available_tools` | tools the server may auto-serve / browse may offer here | union leaf→root |
| `admins` | subtree-admin principals (email globs or role names) | concat-dedupe across cascade |
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
**Subtree download.** `GET /some/dir/?zip=1` (the query form works on both `/dir` and `/dir/`) streams an `application/zip` of every readable file under that directory, recursively — `Content-Disposition: attachment; filename="<dir>.zip"`. It's `handler.ServeSubtreeZip`: a `filepath.WalkDir` that ACL-gates each file by the `.zddc` chain of its containing directory (the same per-directory decision cache `serveArchiveListing` uses), skips hidden entries (`.`/`_`-prefixed: `.zddc`, `_template`, `_app`), and adds any `.zip` *file* it meets as opaque bytes (it does **not** recurse into it — that's the navigable-surface above, a different feature). The response is streamed straight onto the `ResponseWriter` (`zip.Store` for already-compressed extensions, `zip.Deflate` otherwise), so a fully-ACL-denied or empty subtree yields a valid empty zip rather than a 403 (a stream can't change status after the headers go out). The browse tool's toolbar **Download (zip)** button hits this for the directory in view in server mode; offline (file://) it walks the picked folder itself with JSZip (with a `confirm()` above ~2000 files / ~500 MB, since the whole tree is buffered in browser memory).
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.
### File API (authenticated CRUD)
@ -734,17 +535,7 @@ zddc-server exposes write methods on the same URL space as GET. Each method maps
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
#### `zddc-source.js` known gaps
The polyfill covers the FS Access surface tools actually use. A few corners are intentionally unimplemented — note them when adding new tool features:
- **Recursive directory removal is not implemented.** `HttpDirectoryHandle.removeEntry(name, { recursive: true })` is a no-op against the server because there is no recursive-DELETE endpoint. Tools that rename a non-empty directory by copy + remove (the FS-Access idiom) will leave the source directory orphaned in HTTP mode. Detect this case and either guard the operation or implement server-side `POST X-ZDDC-Op: move` for the directory.
- **Writes have no truncate semantics.** Each PUT replaces the whole file. There's no `FileSystemWritableFileStream.truncate(size)` analogue; partial-write support means partial-overwrite-via-streaming is the polyfill's only write path.
- **Directory listings are not cached on the client side.** Cache mode does cache file responses (and persists `.zddc-listing.<json|html>` sidecars on the *server* side), but the polyfill itself re-fetches `?json=1` listings on every traversal. Tools that re-enter the same directory many times in quick succession should cache results in tool state.
These are deliberate scope decisions, not bugs. Lift any of them only when a concrete tool feature pays for the implementation cost.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, and transmittal auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
### Why the tool-rooted view matters for third-party containment

View file

@ -21,11 +21,11 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/`, `tables/`, `browse/` — eight self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/` — six self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). The sixth tool, `form/`, is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); see AGENTS.md "Form-data system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/``base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all eight HTML tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all six tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
- `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo.
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
## Most-used commands
@ -42,7 +42,7 @@ This is a **monorepo of independent tools**, not one application:
./build alpha # cut alpha (cascades nothing)
./build beta # cut beta (cascades alpha → beta)
./build release # cut stable, coordinated next version
# (cascades alpha + beta → new stable; tags all nine artifacts)
# (cascades alpha + beta → new stable; tags all seven tools)
./build release X.Y.Z # cut stable at explicit version
./build help # usage
@ -71,10 +71,10 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. `dist/release-output/` is the local-only release bundle written by `./build alpha|beta|release`. Never hand-edit a `dist/` file.
- **Build vs deploy are separate verbs.** `./build` and `./build alpha|beta|release` produce artifacts under `dist/release-output/`. Nothing escapes the source tree until the operator runs `./deploy`, which rsyncs into `/srv/zddc/` (Caddy's bind-mount). This decouples local iteration from live state.
- **Channel/release cuts seed from live state.** Before running per-tool promote, `./build alpha|beta|release` clears `dist/release-output/` and copies `/srv/zddc/releases/` into it (preserving symlinks). The cut then mutates the channels being cut on top. Result: `dist/release-output/` is always a complete intended-live snapshot, the verifier sees a complete world, and `./deploy --releases` (rsync `--delete-after`) replaces live state cleanly.
- **Lockstep releases.** Every release cut bumps all nine artifacts (8 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are no longer the norm — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship. Stable cuts atomically (1) regenerate `zddc/internal/apps/embedded/` with stable-labeled bytes, (2) make a `release: vX.Y.Z lockstep` commit, (3) tag all nine artifacts at that commit. Tags ALWAYS point at a clean release commit — never at a source-side commit with alpha-dirty embedded files. (Fixed in May 2026; see git log around the v0.0.9 re-anchor.)
- **Lockstep releases.** Every release cut bumps all seven artifacts (6 HTML tools + zddc-server) to the same version, even if a tool didn't change. The coordinated next-stable target is `max(latest tag across all tools) + 1`. Per-tool independent versions are no longer the norm — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship. Stable cuts atomically (1) regenerate `zddc/internal/apps/embedded/` with stable-labeled bytes, (2) make a `release: vX.Y.Z lockstep` commit, (3) tag all seven artifacts at that commit. Tags ALWAYS point at a clean release commit — never at a source-side commit with alpha-dirty embedded files. (Fixed in May 2026; see git log around the v0.0.9 re-anchor.)
- **Bake-in invariant.** What zddc-server's binary embeds via `//go:embed`: prod images (built from `ZDDC_REF=stable`) ship the latest stable cut's bytes. Dev images (built from `ZDDC_REF=main`) ship whatever the last beta-or-stable cut wrote — no alpha. **Alpha is never baked in.** Active dev iteration uses `tool/dist/<tool>.html` opened directly, not the binary's embedded copy. The `./build` (no arg) and `./build alpha` paths intentionally leave `embedded/` untouched.
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`). HTML tools: per-version `<tool>_v<X.Y.Z>.html` (real immutable files) + partial-version pins (`<tool>_v<X.Y>.html`, `_v<X>.html`) + channel mirrors (`<tool>_{stable,beta,alpha}.html`) — all symlinks except per-version. zddc-server: `zddc-server_v<X.Y.Z>_<platform>` per-version binaries (raw bytes, no LFS), `_v<X.Y>_<platform>` / `_v<X>_<platform>` / `_<channel>_<platform>` symlinks, plus `zddc-server_<X>.html` stub pages that surface the four platform downloads in one matrix-cell link. Same cascade rule for both: stable cut → beta + alpha both reset to stable; beta cut → alpha cascades to beta.
- **No tags for alpha/beta.** Channel URLs are stable URLs by design — appending counter tags would defeat the purpose. The on-page label encodes `<date> · <sha>` for traceability. Stable cuts get clean `<tool>-vX.Y.Z` tags for every tool (nine tags per cut, all sharing the same X.Y.Z).
- **No tags for alpha/beta.** Channel URLs are stable URLs by design — appending counter tags would defeat the purpose. The on-page label encodes `<date> · <sha>` for traceability. Stable cuts get clean `<tool>-vX.Y.Z` tags for every tool (six tags per cut, all sharing the same X.Y.Z).
- **Pre-release semver in the on-page label.** Plain dev builds and `--release alpha|beta` cuts embed `vX.Y.Z-{alpha,beta}` in `{{BUILD_LABEL}}` where X.Y.Z is the next-stable target. Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only.
- **Channel-link verifier.** Every `./build alpha|beta|release` ends with a check that every `<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary mirrors + stub pages) resolves. Because cuts seed from live state, the verifier always sees a complete world; missing-link errors mean a real problem, not a sparse-bundle artifact.
- **`./build` (no arg) is a source-side dev build.** Assembles `tool/dist/` + cross-compiled binaries; does NOT touch `dist/release-output/` or the live site. Use it to iterate without affecting anything. To produce a deployable bundle, run `./build alpha|beta|release`. To publish, run `./deploy`. Nothing is pushed to Codeberg automatically.

View file

@ -17,9 +17,7 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
| **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** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. |
| **Browse** | File-tree navigator with previews; the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
| **Landing** | The project picker served at the deployment root of a `zddc-server`. |
| **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.
@ -34,13 +32,13 @@ Quick example: `123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf`
```bash
git clone https://codeberg.org/VARASYS/ZDDC.git && cd ZDDC
./build # dev build of every tool (writes to dist/ only)
sh archive/build.sh # iterate on one HTML tool
sh build.sh # build all tools (writes to dist/ only)
sh archive/build.sh # build one tool
./build alpha # lockstep alpha cut for all nine artifacts
./build beta # lockstep beta cut
./build release # lockstep stable, coordinated next version
./build release 1.2.0 # lockstep stable at explicit version
sh archive/build.sh --release # cut stable; auto-bumps patch from last tag
sh archive/build.sh --release 0.1.0 # explicit version
sh archive/build.sh --release alpha # cut alpha (mutable channel, no tag)
sh archive/build.sh --release beta # cut beta
npm install && npx playwright install chromium && npm test # tests
./dev-server start # cache-busting HTTP on :8000

View file

@ -19,11 +19,7 @@ trap cleanup EXIT
# CSS files to concatenate in order
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
"css/components.css" \
@ -39,15 +35,9 @@ concat_files \
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/vendor/xlsx.full.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/zip-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/init.js" \
"js/parser.js" \

View file

@ -643,7 +643,12 @@ input[type="checkbox"] {
color: var(--text-muted);
}
/* .welcome-list lives in shared/base.css. */
/* ── Welcome screen list ─────────────────────────────────────────────────── */
.welcome-list {
text-align: left;
margin: 0.5rem auto;
max-width: 400px;
}
/* ── Windows path tip (inside welcome screen) ────────────────────────────── */
.windows-tip {
@ -848,15 +853,6 @@ input[type="checkbox"] {
cursor: pointer;
}
/* Folder-name hint after the friendly title shown only when the
project's .zddc declares a different `title:`. Muted so the title
reads first; the folder name is reference info. */
.preset-project-folder {
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
.preset-footer-actions {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);

View file

@ -5,6 +5,18 @@
padding: 0.5rem 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
gap: 0.5rem;
align-items: center;
}
.preview-toggle-label {
display: flex;
align-items: center;
@ -191,7 +203,24 @@
}
/* Empty State — positioned below the app header */
/* .empty-state / .empty-state__inner / .welcome-list live in shared/base.css. */
.empty-state {
position: absolute;
top: 50px; /* clear the header */
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 10;
}
.empty-state-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
/* Project warning banner */
.project-warning-banner {
@ -216,6 +245,16 @@
flex-shrink: 0;
}
.empty-state-content h2 {
color: var(--text);
margin-bottom: 1rem;
}
.empty-state-content p {
margin-bottom: 1rem;
color: var(--text-muted);
}
/* Project access warning banner */
.project-warning-banner {
display: flex;

View file

@ -31,17 +31,6 @@
cursor: default;
}
/* Variant: content packs at the start instead of distributing across the
column header. Used by the Revisions column where a leading "select all"
checkbox sits beside the column title. */
.th-content--start {
justify-content: flex-start;
}
.th-content--start .select-all-checkbox {
margin-right: 0.5rem;
}
.sortable .th-content {
cursor: pointer;
}

View file

@ -74,39 +74,14 @@
}
// Auto-connect to the HTTP server
// Derives the base URL from the current page's location.
//
// Two URL shapes can serve the archive tool:
// (a) Filename-style: /Project-1/Archive/PartyA/archive.html
// (b) Auto-served: /Project-1/Archive/PartyA/Issued
//
// For (a) the URL ends with a filename (`*.html`) and the
// archive's scan root is the parent directory. For (b) the URL
// path IS the directory; trying to strip the last segment would
// wrongly scan the parent. Detect (b) by checking whether the
// path's last segment looks like a file (has a dot + extension).
// Derives the base URL from the current page's location
async function autoConnectHttpSource() {
var href = window.location.href;
// Strip query string and fragment
href = href.split('?')[0].split('#')[0];
var baseUrl;
if (href.endsWith('/')) {
// Directory URL, e.g. /Project-1/archive/ or /
baseUrl = href;
} else {
// Strip the filename to get the directory
var lastSlash = href.lastIndexOf('/');
var lastSegment = lastSlash >= 0 ? href.substring(lastSlash + 1) : href;
// A "filename" has a dot in its last segment. A bare
// directory name (e.g. "Issued") doesn't. Don't be fooled
// by dots in domain names — lastSlash is past the host.
if (lastSegment.indexOf('.') >= 0) {
baseUrl = href.substring(0, lastSlash + 1);
} else {
// Auto-served at a directory URL with no trailing
// slash. Treat the whole path as the scan root.
baseUrl = href + '/';
}
}
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
// Multi-project mode is opt-in via ?projects= in the URL.
// ?projects= absent → not multi-project; scan whatever the URL
@ -128,9 +103,6 @@
// Fetch the server's ACL-filtered project list so we can drop any
// listed names the user doesn't actually have access to (and so
// the empty-projects= "include everything" mode has a list to use).
// ProjectInfo carries an optional `title` field sourced from each
// project's .zddc — capture it so the dropdown can show the
// human-friendly label instead of the folder name.
var serverNames = null;
try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
@ -139,13 +111,6 @@
if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') {
serverNames = new Set(serverProjects.map(function(p) { return p.name; }));
var titles = {};
serverProjects.forEach(function (p) {
if (p && typeof p.title === 'string' && p.title) {
titles[p.name] = p.title;
}
});
window.app.projectTitles = titles;
}
}
} catch (e) {
@ -307,7 +272,7 @@
function showHttpErrorState(message) {
var el = document.getElementById('noDirectoryMessage');
if (!el) return;
var content = el.querySelector('.empty-state__inner');
var content = el.querySelector('.empty-state-content');
if (content) {
content.innerHTML =
'<h2>Could not connect to server</h2>' +
@ -337,8 +302,8 @@
function showUnsupportedBrowserMessage() {
const app = document.getElementById('appContainer');
app.innerHTML = `
<div class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<div class="empty-state">
<div class="empty-state-content">
<h2>Browser Not Supported</h2>
<p>This application requires a Chromium-based browser (Chrome, Edge, Brave) with File System Access API support.</p>
<p>Please use one of these browsers to access the Archive Browser.</p>

View file

@ -223,6 +223,19 @@
}
}
// Utility: Debounce function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Multi-select handling for folder lists
function setupFolderMultiSelect() {
let lastSelectedGroupingIndex = -1;

View file

@ -7,15 +7,6 @@
return !!(parsed && parsed.valid);
}
// A .zip whose name (minus the .zip extension) parses as a
// transmittal-folder name is treated as that transmittal folder —
// its members are scanned the same as an uncompressed folder's
// files. (A plain `archive.zip` etc. is just a file.)
function isTransmittalFolderZip(name) {
var parts = zddc.splitExtension(name);
return parts.extension === 'zip' && isTransmittalFolder(parts.name);
}
function groupFilesByTrackingNumber(files) {
const groups = {};
files.forEach(file => {
@ -67,7 +58,6 @@
window.app.modules.parser = {
isTransmittalFolder,
isTransmittalFolderZip,
groupFilesByTrackingNumber,
sortGroupedFiles,
};

View file

@ -46,24 +46,13 @@
var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort();
// Show the human-friendly title from each project's .zddc
// when present (captured during auto-detect into
// window.app.projectTitles), falling back to the folder name.
// The data-name attribute always carries the canonical folder
// name so URL state stays stable regardless of label.
var titles = window.app.projectTitles || {};
var projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : '';
var label = titles[name] || name;
var nAttr = escapeHtml(name);
var nLabel = escapeHtml(label);
var hint = (label !== name)
? ' <span class="preset-project-folder">(' + escapeHtml(name) + ')</span>'
: '';
var n = escapeHtml(name);
return '<div class="preset-project-item">'
+ '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
+ ' ' + nLabel + hint
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>'
+ ' ' + n
+ '</label>'
+ '</div>';
}).join('');

View file

@ -184,29 +184,6 @@
console.warn('Could not process directory ' + entry.name + ':', err);
}
} else if (entry.kind === 'file') {
// A zipped transmittal folder (e.g.
// "2025-05-12_DOC-001 (IFI) - Title.zip") is treated as
// that transmittal folder: open the zip in the browser
// and scan its members like an uncompressed folder's
// files. The .zip stays in the recorded path so it's
// unambiguous; the displayed name drops it.
if (window.app.modules.parser.isTransmittalFolderZip(entry.name)) {
const base = zddc.splitExtension(entry.name).name;
const zipPath = currentPath + '/' + entry.name;
try {
const zh = await window.zddc.zip.fromFileHandle(entry);
callbacks.onTransmittalFolder({
name: base,
path: zipPath,
displayPath: getDisplayPath(zipPath),
handle: zh
});
await scanLocalTransmittalFolder(zh, zipPath, 0, zipPath, callbacks);
} catch (zipErr) {
console.warn('Could not open zip transmittal ' + entry.name + ':', zipErr);
}
continue;
}
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
try {
@ -502,27 +479,6 @@
}
} else {
// It's a file
// A zipped transmittal folder at the grouping level:
// zddc-server serves "<…>.zip/" as a virtual directory
// of the zip's members, so recurse into it like an
// uncompressed transmittal folder. Members come back
// with URLs like "<…>.zip/<member>" that the server
// extracts on demand — no whole-zip download.
if (transmittalPath === null && window.app.modules.parser.isTransmittalFolderZip(rawName)) {
const base = zddc.splitExtension(rawName).name;
const zipDirUrl = itemUrl + '/'; // itemUrl is the .zip file URL (no trailing slash)
callbacks.onTransmittalFolder({
name: base,
path: logicalPath,
displayPath: getDisplayPath(logicalPath),
handle: null,
url: zipDirUrl
});
subdirPromises.push(
scanHttpRecursive(zipDirUrl, rootUrl, depth + 1, logicalPath, callbacks)
);
continue;
}
if (transmittalPath === null) {
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.

View file

@ -632,8 +632,8 @@
if (!container) return;
try {
// XLSX is bundled into the dist HTML (shared/vendor/xlsx.full.min.js),
// so window.XLSX is available synchronously — no runtime load.
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const arrayBuffer = await (file.handle
? file.handle.getFile().then(f => f.arrayBuffer())
: fetch(file.url).then(r => r.arrayBuffer()));

View file

@ -33,7 +33,7 @@
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>
</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>
@ -169,11 +169,11 @@
<div class="resize-handle"></div>
</th>
<th class="resizable" data-field="revisions">
<div class="th-content th-content--start">
<div class="th-content" style="justify-content: flex-start;">
<input type="checkbox"
id="selectAllVisibleCheckbox"
class="select-all-checkbox"
title="Select/deselect all visible files">
title="Select/deselect all visible files"
style="margin-right: 0.5rem;">
<span>Revisions</span>
</div>
<input type="text"
@ -237,8 +237,8 @@
</div>
<!-- No Directory Selected Message -->
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<div id="noDirectoryMessage" class="empty-state">
<div class="empty-state-content">
<h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>

View file

@ -17,16 +17,9 @@ js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
# CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor).
# CSS files: shared base first, then browse-specific.
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \
"css/base.css" \
"css/tree.css" \
> "$css_temp"
@ -38,25 +31,15 @@ concat_files \
# without an external HTTP dependency.
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \
"../shared/zddc-filter.js" \
"../shared/zip-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/preview-lib.js" \
"js/init.js" \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \
"js/preview-markdown.js" \
"js/grid.js" \
"js/upload.js" \
"js/download.js" \
"js/events.js" \
"js/app.js" \
> "$js_raw"

View file

@ -22,9 +22,37 @@ body {
min-height: 0;
}
/* .empty-state / .empty-state__inner live in shared/base.css. */
/* Empty / first-paint state */
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
/* .hidden lives in shared/base.css; no per-tool override needed. */
.empty-state__inner {
max-width: 640px;
color: var(--text-muted);
line-height: 1.5;
}
.empty-state__inner h2 {
color: var(--text);
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.empty-state__inner ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.empty-state__inner li {
margin: 0.4rem 0;
}
.hidden { display: none !important; }
/* Status bar — shows transient errors/info */
.status-bar {

View file

@ -1,112 +1,87 @@
/* ── Layout ──────────────────────────────────────────────────────────────── */
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: var(--font);
color: var(--text);
background-color: var(--bg);
}
#appMain {
position: relative;
height: calc(100vh - 2.65rem); /* clear .app-header */
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Toolbar above the listing */
.browse-root {
flex: 1;
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.browse-table-wrap {
flex: 1;
overflow: auto;
min-height: 0;
}
.browse-toolbar {
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 1rem;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.view-mode-toggle {
display: inline-flex;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-mode-toggle .btn {
border-radius: 0;
border: none;
border-right: 1px solid var(--border);
}
.view-mode-toggle .btn:last-child {
border-right: none;
}
.view-mode-toggle .btn[aria-selected="true"] {
background: var(--primary);
color: var(--text-light);
}
/* Breadcrumbs */
/* Breadcrumb path. The root node is a 🏠 link to "/" (online) or
the FS handle name (offline). Each segment is a clickable link in
server mode that re-navigates the browser; in FS-API mode they
render as plain spans because we don't keep ancestor handles. */
.breadcrumbs {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.15rem 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
min-width: 0;
}
.breadcrumbs a,
.breadcrumbs button {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
font-family: Consolas, Monaco, monospace;
font-size: 0.9rem;
color: var(--text-muted);
background: none;
border: 0;
padding: 0.1rem 0.3rem;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
font: inherit;
padding: 0.1rem 0;
/* Hide the scrollbar but keep horizontal scroll for very deep paths */
scrollbar-width: thin;
}
.breadcrumbs a:hover,
.breadcrumbs button:hover {
.breadcrumbs .bc-link {
color: var(--primary);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.breadcrumbs .bc-link:hover {
background: var(--bg-hover, rgba(0,0,0,0.05));
text-decoration: underline;
}
.breadcrumbs .bc-link--current {
color: var(--text);
background: var(--bg-hover);
font-weight: 500;
cursor: default;
}
.breadcrumbs .bc-link--current:hover {
background: transparent;
text-decoration: none;
}
.breadcrumbs .bc-sep {
color: var(--text-muted);
user-select: none;
margin: 0 0.05rem;
}
.breadcrumbs .bc-current {
color: var(--text);
font-weight: 600;
padding: 0.1rem 0.3rem;
.breadcrumbs .bc-root {
display: inline-flex;
align-items: center;
line-height: 1;
}
.bc-home-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
width: 1rem;
height: 1rem;
display: block;
color: currentColor;
}
.toolbar__count {
@ -115,573 +90,195 @@ html, body {
white-space: nowrap;
}
/* ── Two-pane browse view ────────────────────────────────────────────────── */
.browse-view {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.pane {
overflow: hidden;
background: var(--bg);
display: flex;
flex-direction: column;
}
.tree-pane {
width: 360px;
min-width: 200px;
max-width: 60%;
border-right: 1px solid var(--border);
flex-shrink: 0;
}
.tree-pane__body {
flex: 1;
overflow: auto;
padding: 0.25rem 0;
font-size: 0.875rem;
}
/* Pane resizer — 4px grab handle between tree and preview */
.pane-resizer {
width: 4px;
background: transparent;
cursor: col-resize;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.pane-resizer:hover,
.pane-resizer.is-dragging {
background: var(--primary);
}
.preview-pane {
flex: 1;
min-width: 0;
}
.preview-pane__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
/* Auto-filter rows in <thead>. Two rows one targets file rows
(📄 icon, with file-name + ext inputs), one targets folder rows
(📁 icon, with folder-name input). The icons make it visually
obvious which row controls which kind of filter. The rows are
non-sticky (only the sortable header row sticks) keeps the
stack-positioning math out of the picture and accepts that
filters scroll out of view on long lists. */
.browse-table thead .filter-row th {
position: static;
padding: 0.25rem 0.6rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 2.1rem;
cursor: default;
font-weight: normal;
z-index: 0;
}
.preview-pane__title {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
.browse-table thead .filter-row th:hover {
background: var(--bg-secondary);
}
.preview-pane__meta {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.preview-pane__body {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
background: var(--bg);
}
/* The body's children fill the available space. Plugins inject
different content here img, iframe, pre, custom markdown editor. */
.preview-pane__body > * {
flex: 1;
min-height: 0;
}
.preview-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.95rem;
padding: 2rem;
text-align: center;
}
.preview-pane__body img.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
display: block;
flex: none; /* avoid flex sizing interfering with object-fit */
}
.preview-pane__body iframe.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.preview-pane__body pre.preview-text {
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
overflow: auto;
background: var(--bg);
color: var(--text);
}
/* ── Tree (vertical, file-explorer style) ───────────────────────────────── */
.tree-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 0;
color: var(--text);
}
.tree-row:hover {
background: var(--bg-hover);
}
.tree-row.is-selected {
background: var(--bg-selected);
color: var(--text);
}
.tree-row.is-selected .tree-name__label {
color: var(--text);
}
.tree-name__chevron {
.filter-row__icon {
display: inline-block;
width: 1rem;
width: 1.2rem;
text-align: center;
color: var(--text-muted);
flex-shrink: 0;
font-family: monospace;
font-size: 0.65rem;
}
.tree-row[data-isdir="true"] .tree-name__chevron::before,
.tree-row[data-iszip="true"] .tree-name__chevron::before {
content: "▸";
}
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before,
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before {
content: "▾";
}
.tree-name__chevron--leaf::before {
content: "";
}
.tree-name__icon {
flex-shrink: 0;
margin-right: 0.3rem;
vertical-align: middle;
font-size: 0.95rem;
}
.tree-name__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.tree-row[data-isdir="true"] .tree-name__label,
.tree-row[data-iszip="true"] .tree-name__label {
font-weight: 500;
}
/* ── Drag-drop upload overlay ─────────────────────────────────────────────── */
/* Shown only while a drag is active over the page AND the current scope
accepts uploads. Pointer-events:none below dragover so the underlying
drop event still reaches the document handlers. */
.upload-overlay {
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
background: rgba(42, 90, 138, 0.18);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.12s ease;
}
.upload-overlay.is-active {
opacity: 1;
}
.upload-overlay__panel {
background: var(--bg);
border: 2px dashed var(--primary);
border-radius: var(--radius);
padding: 1.5rem 2.25rem;
text-align: center;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
pointer-events: none;
color: var(--text);
max-width: 80vw;
}
.upload-overlay__icon {
font-size: 2.5rem;
line-height: 1;
color: var(--primary);
}
.upload-overlay__title {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 600;
margin-top: 0.5rem;
}
.upload-overlay__path {
margin-top: 0.35rem;
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--text-muted);
word-break: break-all;
}
/* Virtual rows: synthesized client-side for folders that aren't on
disk yet (canonical project folders). Rendered muted so the user
reads them as "available but empty" rather than ordinary entries.
Hover/select states still apply; the hint sits to the right of the
label. */
.tree-row--virtual .tree-name__icon,
.tree-row--virtual .tree-name__label {
opacity: 0.65;
}
.tree-name__hint {
margin-left: 0.5rem;
font-size: 0.78rem;
color: var(--text-muted);
font-style: italic;
}
/* ── Grid view (Phase C) ─────────────────────────────────────────────────── */
.grid-view {
flex: 1;
overflow: auto;
background: var(--bg);
padding: 0;
}
.grid-empty {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
/* ── Status bar ──────────────────────────────────────────────────────────── */
.status-bar {
padding: 0.4rem 1rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-muted);
min-height: 1.6rem;
flex-shrink: 0;
}
.status-bar.is-error { color: var(--danger); }
.status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
(front matter top + TOC bottom), content on the RIGHT (informational
header above the Toast UI editor). The grid gives every cell a
definite size, which Toast UI needs to compute its scroll regions
correctly. */
.md-shell {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 280px 1fr; /* JS overrides on resize */
grid-template-areas: "sidebar content";
height: 100%;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
/* Sidebar (col 1): two stacked sections Front matter (top, fixed
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
.md-shell__sidebar {
grid-area: sidebar;
display: grid;
grid-template-rows: 180px 1fr; /* JS overrides on resize */
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--border);
background: var(--bg);
position: relative;
}
/* Vertical sidebar/content resizer. Sits absolutely on the column
boundary so it doesn't occupy a grid track. */
.md-shell__resizer {
grid-area: sidebar;
align-self: stretch;
justify-self: end;
width: 6px;
margin-right: -3px;
cursor: col-resize;
background: transparent;
z-index: 2;
transition: background 0.12s;
}
.md-shell__resizer:hover,
.md-shell__resizer.is-dragging,
.md-shell__resizer:focus-visible {
background: var(--primary);
outline: none;
}
/* Horizontal resizer between front-matter and TOC inside the sidebar.
Spans both rows by placement, then absolutely positioned to overlay
the grid-row boundary. */
.md-shell__fmresizer {
grid-column: 1;
grid-row: 1;
align-self: end;
justify-self: stretch;
height: 6px;
margin-bottom: -3px;
cursor: row-resize;
background: transparent;
z-index: 2;
transition: background 0.12s;
}
.md-shell__fmresizer:hover,
.md-shell__fmresizer.is-dragging,
.md-shell__fmresizer:focus-visible {
background: var(--primary);
outline: none;
}
/* Content (col 2): informational header above the Toast UI editor. */
.md-shell__content {
grid-area: content;
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* Informational header above the editor: file name on the left, then
dirty marker, status, source hint, save button. Reads as a header
for the content panel file metadata at a glance. */
.md-shell__infohdr {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.md-shell__title {
flex: 1;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.md-shell__dirty {
color: var(--text-muted);
font-size: 0.85rem;
min-width: 5.5rem;
text-align: right;
}
.md-shell__status {
color: var(--text-muted);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 14rem;
}
.md-shell__source {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
padding: 0.15rem 0.4rem;
border-radius: var(--radius);
background: var(--bg);
border: 1px solid var(--border);
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
.md-shell__editor {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-side {
display: grid;
grid-template-rows: auto 1fr;
min-height: 0;
overflow: hidden;
}
.md-side--toc {
border-top: 1px solid var(--border);
}
.md-side__header {
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.md-side__body {
overflow-y: auto;
min-height: 0;
padding: 0.3rem 0;
font-size: 0.85rem;
line-height: 1.45;
}
/* ── Outline list ───────────────────────────────────────────────────────── */
.md-toc__empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0.75rem;
margin: 0;
font-size: 0.82rem;
}
.md-toc__list {
list-style: none;
margin: 0;
padding: 0;
}
.md-toc__item {
margin: 0;
padding: 0.22rem 0.75rem;
color: var(--text);
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s, color 0.1s;
/* Truncate long headings rather than wrap; the title attribute
carries the full text. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-toc__item:hover {
background: var(--bg-secondary);
border-left-color: var(--primary);
}
.md-toc__item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.md-toc__item--l1 { padding-left: 0.75rem; font-weight: 600; }
.md-toc__item--l2 { padding-left: 1.4rem; }
.md-toc__item--l3 { padding-left: 2.05rem; font-size: 0.82rem; }
.md-toc__item--l4 { padding-left: 2.7rem; font-size: 0.8rem; color: var(--text-muted); }
.md-toc__item--l5 { padding-left: 3.35rem; font-size: 0.78rem; color: var(--text-muted); }
.md-toc__item--l6 { padding-left: 4rem; font-size: 0.78rem; color: var(--text-muted); }
/* Flash on click applied to the heading element in the editor pane.
The class is scoped to .md-toc__flash so it doesn't paint outside
this plugin. */
.md-toc__flash {
background-color: rgba(95, 168, 224, 0.25) !important;
transition: background-color 0.3s ease;
}
/* ── Front matter list ──────────────────────────────────────────────────── */
.md-fm__empty {
color: var(--text-muted);
font-style: italic;
font-size: 0.82rem;
margin: 0;
padding: 0.5rem 0.75rem;
}
.md-fm__list {
margin: 0;
padding: 0.3rem 0.75rem;
display: grid;
grid-template-columns: minmax(4.5rem, max-content) 1fr;
gap: 0.2rem 0.6rem;
font-size: 0.8rem;
}
.md-fm__list dt {
font-weight: 600;
color: var(--text-muted);
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-fm__list dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
/* ── Sort control ────────────────────────────────────────────────────────── */
.sort-control {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.sort-control__label {
user-select: none;
}
.sort-control__select {
font-family: var(--font);
font-size: 0.8rem;
.column-filter {
width: calc(100% - 1.5rem);
padding: 0.2rem 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: 3px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
font-family: Consolas, Monaco, monospace;
box-sizing: border-box;
}
.sort-control__select:focus {
outline: 2px solid var(--primary);
.filter-row th.col-name .column-filter {
width: calc(100% - 1.7rem); /* leave space for the icon */
}
.column-filter:focus {
outline: 1px solid var(--primary);
outline-offset: -1px;
}
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */
/* Table — folders + files in a tree */
.browse-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
background: var(--bg);
/* No flex:1 tables don't reliably distribute extra height across
rows the way flex columns do. With few rows we'd get tall rows
that shrink as more children are loaded. The wrap div handles
scrolling instead. */
}
.browse-table tbody tr {
/* Pin rows to a deterministic height so table layout never
redistributes vertical space across them. */
line-height: 1.4;
}
.browse-table thead th {
position: sticky;
top: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
text-align: left;
padding: 0.5rem 0.75rem;
font-weight: 600;
color: var(--text);
user-select: none;
z-index: 1;
}
.browse-table th.sortable {
cursor: pointer;
}
.browse-table th.sortable:hover {
background: var(--bg-hover, #e8e8e8);
}
.sort-arrow {
display: inline-block;
width: 0.7rem;
color: var(--text-muted);
font-size: 0.7rem;
margin-left: 0.2rem;
}
.browse-table th.sort-asc .sort-arrow::after { content: "▲"; color: var(--text); }
.browse-table th.sort-desc .sort-arrow::after { content: "▼"; color: var(--text); }
.browse-table tbody td {
padding: 0.3rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.browse-table tbody tr:hover {
background: var(--bg-hover, #f6faff);
}
/* Tree-row — name cell with indent + chevron */
.tree-name {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.tree-name__indent {
flex: 0 0 auto;
}
.tree-name__chevron {
width: 1rem;
text-align: center;
color: var(--text-muted);
cursor: pointer;
user-select: none;
flex: 0 0 1rem;
line-height: 1;
}
.tree-name__chevron--leaf { visibility: hidden; }
.tree-name__chevron::before { content: "▶"; font-size: 0.65rem; }
.tree-row.expanded > td .tree-name__chevron::before { content: "▼"; }
.tree-name__icon {
flex: 0 0 1.1rem;
text-align: center;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
.tree-name__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.tree-name__label.is-folder {
font-weight: 500;
}
.tree-name__label.is-file {
cursor: pointer;
color: var(--primary);
text-decoration: none;
}
.tree-name__label.is-file:hover {
text-decoration: underline;
}
/* Numeric columns right-aligned */
.col-size, .col-date {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-muted);
}
.col-ext {
color: var(--text-muted);
font-family: Consolas, Monaco, monospace;
font-size: 0.85rem;
}
/* Loading row */
.tree-row--loading td {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem);
}

View file

@ -8,17 +8,6 @@
var tree = window.app.modules.tree;
var events = window.app.modules.events;
// Virtual canonical folder injection used to live here (browse
// appended archive/working/staging/reviewing entries at a project
// root when missing). zddc-server now emits them in the listing
// directly so the .zddc `display:` map can override their labels
// the same as real entries. This pass-through stub keeps the
// events.js rescope contract intact without doing any merging.
function passThroughEntries(entries) { return entries; }
// Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = passThroughEntries;
async function bootstrap() {
events.init();
@ -34,37 +23,8 @@
events.statusInfo('Loaded ' + detected.entries.length + ' item'
+ (detected.entries.length === 1 ? '' : 's')
+ ' from ' + detected.path);
// The initial events.init() applied view mode before the
// cascade headers were available (no fetch yet). Now that
// state.scopeDefaultTool is set from the detection
// response, re-resolve so an /incoming URL auto-activates
// grid mode.
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
}
// Else: empty state stays visible; user can click Select Directory.
// Browser back / forward: client-side rescope when the URL
// changes via popstate. We can't tell server-vs-fs mode from
// popstate alone, so only honor it in server mode.
window.addEventListener('popstate', async function () {
if (window.app.state.source !== 'server') return;
var path = location.pathname;
if (!path.endsWith('/')) path += '/';
try {
var es = await loader.fetchServerChildren(path);
window.app.state.currentPath = path;
window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null;
tree.setRoot(es);
tree.render();
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';
var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected';
// Reapply view mode for the new URL (incoming/ → grid, etc).
if (events.applyResolvedViewMode) events.applyResolvedViewMode();
} catch (_e) { /* swallow — leave the tree as-is */ }
});
}
if (document.readyState === 'loading') {

View file

@ -1,141 +0,0 @@
// download.js — "Download (zip)" for the currently-viewed directory.
//
// Server mode: just point an <a download> at "<currentPath>?zip=1" —
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing
// is held in the browser.
//
// FS-API (offline) mode: there's no server, so we walk the picked
// folder ourselves, bundle every file with JSZip, and download the
// blob. A two-pass walk (metadata first, then bytes) lets us warn
// before loading a very large tree into memory.
(function () {
'use strict';
var state = window.app.state;
// Soft thresholds for the offline bundle: above either, confirm()
// before loading everything into memory.
var WARN_FILE_COUNT = 2000;
var WARN_TOTAL_BYTES = 500 * 1024 * 1024;
function events() { return window.app.modules.events; }
function isHiddenName(name) {
return name.length === 0 || name[0] === '.' || name[0] === '_';
}
function fmtMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }
// Trigger a browser download of a Blob (revokes the object URL after).
function downloadBlob(filename, blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function () {
URL.revokeObjectURL(a.href);
a.remove();
}, 0);
}
// Trigger a download from a same-origin server URL via Content-Disposition.
function downloadUrl(filename, url) {
var a = document.createElement('a');
a.href = url;
a.download = filename; // hint; the server's Content-Disposition wins
document.body.appendChild(a);
a.click();
setTimeout(function () { a.remove(); }, 0);
}
// Recursively collect every (non-hidden) file under dirHandle into
// `out` as { relPath, handle, size }, accumulating into `tally`.
// relPrefix is the slash-terminated path within the picked root
// ("" at the root).
async function collectFiles(dirHandle, relPrefix, out, tally) {
for await (var pair of dirHandle.entries()) {
var name = pair[0];
var handle = pair[1];
if (isHiddenName(name)) continue;
if (handle.kind === 'directory') {
await collectFiles(handle, relPrefix + name + '/', out, tally);
} else {
var size = 0;
try {
var f = await handle.getFile();
size = f.size || 0;
} catch (_e) { /* permission lost — count it as 0 */ }
out.push({ relPath: relPrefix + name, handle: handle, size: size });
tally.count++;
tally.bytes += size;
}
}
}
async function downloadFsSubtree(rootHandle) {
var ev = events();
ev.statusInfo('Scanning ' + rootHandle.name + '…');
var files = [];
var tally = { count: 0, bytes: 0 };
await collectFiles(rootHandle, '', files, tally);
if (files.length === 0) {
ev.statusInfo(rootHandle.name + ' is empty — nothing to download.');
return;
}
if (tally.count > WARN_FILE_COUNT || tally.bytes > WARN_TOTAL_BYTES) {
var ok = window.confirm(
'This folder has ' + tally.count + ' files (~' + fmtMB(tally.bytes) + ').\n\n'
+ 'Building the zip loads them all into memory — it may be slow or crash the tab.\n\n'
+ 'Continue?');
if (!ok) { ev.statusClear(); return; }
}
var zip = new window.JSZip();
for (var i = 0; i < files.length; i++) {
ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')');
var f = await files[i].handle.getFile();
var buf = await f.arrayBuffer();
zip.file(rootHandle.name + '/' + files[i].relPath, buf);
}
ev.statusInfo('Generating ' + rootHandle.name + '.zip…');
var blob = await zip.generateAsync({ type: 'blob' });
downloadBlob(rootHandle.name + '.zip', blob);
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)');
}
function downloadServerSubtree() {
var dir = (state.currentPath || '/').replace(/\/$/, '');
var name = (dir.split('/').filter(Boolean).pop()) || 'download';
events().statusInfo('Preparing ' + name + '.zip…');
downloadUrl(name + '.zip', dir + '/?zip=1');
// The browser owns the download from here; clear the hint shortly.
setTimeout(function () { events().statusClear(); }, 2500);
}
var busy = false;
async function downloadCurrentSubtree() {
if (busy) return;
var btn = document.getElementById('downloadZipBtn');
busy = true;
if (btn) btn.disabled = true;
try {
if (state.source === 'server') {
downloadServerSubtree();
} else if (state.source === 'fs' && state.rootHandle) {
await downloadFsSubtree(state.rootHandle);
} else {
events().statusError('Nothing to download — open a directory first.');
}
} catch (e) {
events().statusError('Download failed: ' + (e && e.message ? e.message : e));
} finally {
busy = false;
if (btn) btn.disabled = false;
}
}
window.app.modules.download = {
downloadCurrentSubtree: downloadCurrentSubtree
};
})();

View file

@ -69,7 +69,6 @@
function applySourceUI() {
var add = document.getElementById('addDirectoryBtn');
var refresh = document.getElementById('refreshHeaderBtn');
var dlZip = document.getElementById('downloadZipBtn');
if (add) {
if (state.source === 'server') {
add.classList.remove('btn-primary');
@ -86,15 +85,6 @@
refresh.classList.add('hidden');
}
}
// "Download (zip)" is meaningful once a directory is loaded
// (server or local); it zips the directory currently in view.
if (dlZip) {
if (state.source) {
dlZip.classList.remove('hidden');
} else {
dlZip.classList.add('hidden');
}
}
}
async function refreshListing() {
@ -132,296 +122,95 @@
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
var dlZip = document.getElementById('downloadZipBtn');
if (dlZip) dlZip.addEventListener('click', function () {
var d = window.app.modules.download;
if (d) d.downloadCurrentSubtree();
});
// Sort dropdown — change → tree re-renders with the new sort.
// Format of option value: "<key>:<asc|desc>". Defaults match
// state.sort initial values (name:asc).
var sortSel = document.getElementById('sortBy');
if (sortSel) {
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
sortSel.addEventListener('change', function () {
var parts = sortSel.value.split(':');
var key = parts[0];
var dir = parts[1] === 'desc' ? -1 : 1;
tree.setSortExplicit(key, dir);
// Auto-filter row inputs. There are three of them (file, folder,
// ext) — wire each by its `data-filter` attribute. Idempotent
// re: re-init.
var filterInputs = document.querySelectorAll('input.column-filter[data-filter]');
for (var fi = 0; fi < filterInputs.length; fi++) {
(function (input) {
var which = input.dataset.filter;
input.addEventListener('input', function () {
tree.setFilter(which, input.value);
});
})(filterInputs[fi]);
}
// No view-mode buttons; mode is derived from the URL on every
// scope change (resolveViewMode below). Pass-through for the
// initial path.
applyResolvedViewMode();
// Pop-out preview button — opens the current preview in a separate window.
var popout = document.getElementById('previewPopout');
if (popout) popout.addEventListener('click', function () {
var p = previewMod();
if (p && state.lastPreviewedNodeId != null) {
var n = state.nodes.get(state.lastPreviewedNodeId);
if (n) p.showFilePreview(n, { popup: true });
}
});
// Pane resizer (tree pane width). Drag horizontally; clamps to
// [180, 60% of viewport]. State stays in-memory only — refresh
// resets to the default 360px.
var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
var treePane = document.getElementById('treePane');
if (resizer && treePane) {
var dragging = false;
var startX = 0;
var startWidth = 0;
resizer.addEventListener('mousedown', function (e) {
dragging = true;
resizer.classList.add('is-dragging');
startX = e.clientX;
startWidth = treePane.getBoundingClientRect().width;
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
var dx = e.clientX - startX;
var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
treePane.style.width = w + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragging) return;
dragging = false;
resizer.classList.remove('is-dragging');
// Sort headers
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
(function (th) {
th.addEventListener('click', function () {
tree.setSort(th.dataset.sort);
});
})(ths[i]);
}
// Tree-row clicks (event delegation on the tree body).
// Tree-row clicks (event delegation on tbody).
// Click semantics on a folder row:
// - plain click → toggle expand (deferred so dblclick wins)
// - shift-click → recursive expand/collapse of the subtree
// - alt-click → ALSO recursive
// - dblclick → navigate into the folder
// File rows: plain click → preview in right pane; modifier-click
// and middle-click open in new tab.
//
// The plain-click toggle for folders is intentionally deferred
// via setTimeout. Reason: toggling re-renders the tree, which
// replaces the clicked row element. The browser detects a
// double-click only when the second click lands on the same
// target element as the first; replacing the row breaks that
// continuity and the dblclick event never fires. The deferred
// toggle lets a pending dblclick cancel it.
var pendingFolderToggle = null;
var treeBody = document.getElementById('treeBody');
if (treeBody) {
treeBody.addEventListener('click', function (e) {
var row = e.target.closest('.tree-row');
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody');
if (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row');
if (!row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
var clickedChevron = !!e.target.closest('.tree-name__chevron');
if (isExpandable) {
// For folders + zips: click anywhere on the row
// toggles. Modifier-click → recursive expand.
e.preventDefault();
if (e.shiftKey || e.altKey) {
// Modifier-click skips the dblclick race — it's
// an explicit recursive toggle, never followed
// by a dblclick.
if (node.expanded) tree.collapseSubtree(id);
else tree.expandSubtree(id);
return;
}
// ZIPs don't navigate-into; toggle immediately.
if (row.dataset.iszip === 'true') {
} else {
tree.toggleFolder(id);
return;
}
// Folder: defer the toggle so a pending dblclick
// can pre-empt it.
if (pendingFolderToggle) {
clearTimeout(pendingFolderToggle.timer);
}
pendingFolderToggle = {
id: id,
timer: setTimeout(function () {
pendingFolderToggle = null;
tree.toggleFolder(id);
}, 220)
};
return;
}
// File row: modifier-click → open URL in new tab if
// available (server mode preserves the original URL,
// useful for direct download / sharing).
// Plain file row.
// Modifier-click (ctrl/cmd) and middle-click → fall
// through to the <a> tag's natural target=_blank
// behavior (open in new tab). For server-backed
// files, that opens the real URL via zddc-server.
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
if (node.url) window.open(node.url, '_blank', 'noopener');
return;
}
// Plain click → preview in the right pane.
// Plain click → preview popup. Intercept default nav.
e.preventDefault();
state.selectedId = id;
state.lastPreviewedNodeId = id;
tree.render(); // refresh selection highlight
var p = previewMod();
if (p) p.showFilePreview(node);
});
// Double-click on a folder → "navigate into" it. Distinct
// from single-click (which expands inline) so users keep
// both UX models. Server mode jumps to the folder URL —
// zddc-server returns a fresh browse instance scoped to
// that directory. FS-API mode swaps state.rootHandle to
// the folder's handle and re-loads, so the user sees
// only that subtree at the root level.
//
// Files: dblclick is left alone — the single-click preview
// is already a "look at this file" action; a separate
// navigate-into doesn't apply.
// ZIPs: skipped too — they're inspected via inline
// expansion (JSZip), not navigated into.
treeBody.addEventListener('dblclick', function (e) {
var row = e.target.closest('.tree-row');
if (!row) return;
if (row.dataset.isdir !== 'true') return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
e.preventDefault();
// Pre-empt the deferred single-click toggle so the user
// doesn't see a flicker of expand/collapse before nav.
if (pendingFolderToggle) {
clearTimeout(pendingFolderToggle.timer);
pendingFolderToggle = null;
}
navigateIntoFolder(node);
// Middle-click (auxclick) — same fall-through logic.
tbody.addEventListener('auxclick', function (e) {
if (e.button !== 1) return; // middle only
// Browser handles target=_blank natively for middle
// click; don't preventDefault, just don't intercept.
});
}
}
// View mode is URL-driven, not UI-driven.
//
// ?view=grid → grid mode (only honored where classifier is
// available; otherwise falls back to browse)
// ?view=browse → browse mode (always)
// default → path-based: grid when inside an incoming/
// subtree, browse everywhere else
//
// resolveViewMode reads the current location and returns the mode
// to render; applyResolvedViewMode toggles the panes accordingly.
// Called on initial load and on every client-side rescope.
function resolveViewMode() {
var qs = new URLSearchParams(window.location.search);
var explicit = (qs.get('view') || '').toLowerCase();
var grid = window.app.modules.grid;
var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
if (explicit === 'browse') return 'browse';
return classifierHere ? 'grid' : 'browse';
}
function applyResolvedViewMode() {
var mode = resolveViewMode();
state.viewMode = mode;
var browseView = document.getElementById('browseView');
var gridView = document.getElementById('gridView');
if (mode === 'grid') {
if (browseView) browseView.classList.add('hidden');
if (gridView) gridView.classList.remove('hidden');
var grid = window.app.modules.grid;
if (grid) {
if (grid.reset) grid.reset();
if (grid.activate) grid.activate();
}
} else {
if (browseView) browseView.classList.remove('hidden');
if (gridView) gridView.classList.add('hidden');
}
}
async function navigateIntoFolder(node) {
if (state.source === 'server') {
// Rescope client-side rather than hard-navigating. A hard
// nav would let zddc-server's auto-serve kick in and swap
// us out of browse for canonical folders (e.g. /archive/
// → archive tool, /staging/ → transmittal). Staying in
// browse is what the user asked for; pushState keeps the
// URL bar accurate so a reload would re-load browse at the
// new scope.
var url = window.app.modules.tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
await rescopeServer(url, node.name);
return;
}
if (state.source === 'fs') {
if (!node.handle || node.handle.kind !== 'directory') return;
state.rootHandle = node.handle;
state.currentPath = node.handle.name + '/';
var raw;
try {
raw = await loader.fetchFsChildren(node.handle);
} catch (e) {
statusError('Failed to enter ' + node.name + ': ' + e.message);
return;
}
tree.setRoot(raw);
tree.render();
statusInfo('Entered ' + node.name);
}
}
// Client-side rescope for server mode. Updates the URL via
// history.pushState, fetches the new directory listing, and
// re-renders the tree from scratch. Page DOES NOT reload.
async function rescopeServer(url, displayName) {
var entries;
try {
entries = await loader.fetchServerChildren(url);
} catch (e) {
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
return;
}
state.currentPath = url;
// Selection / preview belong to the old scope; clear them so
// the new root doesn't carry stale highlight state.
state.selectedId = null;
state.lastPreviewedNodeId = null;
// Virtual canonical folders are emitted by zddc-server itself
// (so .zddc display: overrides apply uniformly); no client-side
// merge needed.
tree.setRoot(entries);
tree.render();
// Reset the preview pane so the user sees an "empty selection"
// state at the new scope instead of the previous file.
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';
var previewTitle = document.getElementById('previewTitle');
if (previewTitle) previewTitle.textContent = 'No file selected';
var previewMeta = document.getElementById('previewMeta');
if (previewMeta) previewMeta.textContent = '';
// pushState so the URL bar reflects the new scope. A real
// reload would re-load browse at this URL (trailing slash →
// ServeDirectory → embedded browse SPA).
try {
history.pushState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
statusInfo('Entered ' + displayName);
// The new scope may have a different default view (grid inside
// incoming/, browse elsewhere). Re-resolve from the URL now
// that pushState has updated it.
applyResolvedViewMode();
}
// Public API
window.app.modules.events = {
init: init,
statusError: statusError,
statusInfo: statusInfo,
statusClear: statusClear,
showBrowseRoot: showBrowseRoot,
applyResolvedViewMode: applyResolvedViewMode
showBrowseRoot: showBrowseRoot
};
})();

View file

@ -1,69 +0,0 @@
// grid.js — "Grid mode" plugin for browse. Loads the classifier tool
// as an iframe scoped to the current directory so users get classifier's
// full bulk-rename workflow without leaving browse.
//
// Availability: the cascade decides. Grid auto-activates wherever the
// .zddc cascade resolves default_tool=classifier (defaults.zddc.yaml
// declares this for archive/<party>/incoming/). Operators can extend
// — e.g. setting default_tool=classifier on a custom dir activates
// grid mode there too — without touching this code.
//
// Iframe src resolution: <currentDirURL>/classifier.html. Iframe
// embedding only works in server mode; file:// pages don't get the
// Grid toggle.
(function () {
'use strict';
var state = window.app.state;
var mounted = false;
function classifierAvailableHere() {
// state.scopeDefaultTool is set by the loader from the
// X-ZDDC-Default-Tool response header on every listing fetch.
// Grid mode is meaningful exactly where the cascade picks
// classifier as the default — no client-side path matching.
return state.scopeDefaultTool === 'classifier';
}
function activate() {
var host = document.getElementById('gridView');
if (!host) return;
if (mounted) return;
if (state.source !== 'server' || !classifierAvailableHere()) return;
// Compute the iframe src: current page's directory + classifier.html.
var pathname = window.location.pathname || '/';
if (!pathname.endsWith('/')) {
var lastSlash = pathname.lastIndexOf('/');
pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/';
}
var src = pathname + 'classifier.html';
host.innerHTML = '';
var frame = document.createElement('iframe');
frame.src = src;
frame.title = 'ZDDC Classifier (Grid mode)';
frame.style.cssText = 'width:100%;height:100%;border:0;display:block;'
+ 'background:var(--bg);';
host.appendChild(frame);
mounted = true;
}
// When the user navigates between scopes (client-side rescope on
// dblclick), the iframe needs to be reloaded for the new path.
// Callers reset before re-activating.
function reset() {
mounted = false;
var host = document.getElementById('gridView');
if (host) host.innerHTML = '';
}
window.app.modules.grid = {
activate: activate,
reset: reset,
// Hook for events.js to show/hide the Grid toggle button.
availableHere: function () {
return state.source === 'server' && classifierAvailableHere();
}
};
})();

View file

@ -25,26 +25,25 @@
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
sort: { key: 'name', dir: 1 },
// Currently-selected tree node id (for highlight + pop-out).
selectedId: null,
lastPreviewedNodeId: null,
// View mode: 'browse' (tree + preview, default) | 'grid' (classifier).
viewMode: 'browse',
// Auto-filter row state. Each is a raw string from the input,
// plus a parsed AST (zddc.filter.parse) cached on every keystroke.
// Empty raw → AST empty → matches everything.
filters: {
file: { raw: '', ast: null }, // matches against file basename
folder: { raw: '', ast: null }, // matches against folder basename
ext: { raw: '', ast: null } // matches against file extension
},
// The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, handle, depth,
// parentId, expanded, loaded, childIds, isZip,
// _zipDirHandle, virtual }
// - isZip: the node IS a .zip file; expanding it lists
// the zip's members (server "<…>.zip/" listing
// online, JSZip behind a ZipDirectoryHandle
// offline). Members are ordinary dir/file nodes.
// - _zipDirHandle: cached ZipDirectoryHandle for an opened zip
// (offline / nested-in-zip path only).
// - handle: a FileSystemFileHandle/DirectoryHandle (fs
// mode) — or, inside an opened zip, a
// ZipFileHandle/ZipDirectoryHandle.
// { id, name, isDir, size, modTime, ext, url, depth,
// parentId, expanded, loaded, childIds, isZip, zipFile,
// zipPath }
// - isZip: set when the node IS a .zip file we know how to
// expand inline (server file or FS handle).
// - zipFile: cached JSZip instance for this archive (set
// after first expand).
// - zipPath: relative path WITHIN a zip (set on virtual
// children of an expanded zip; null otherwise).
// Stored flat in a Map keyed by id; render order derived
// from a depth-first walk.
nodes: new Map(),
@ -53,14 +52,6 @@
// Single shared popup window for file preview (across
// multiple file clicks). Same pattern as archive's preview.
previewWindow: null,
// Cascade-resolved scope flags, refreshed on each listing
// fetch from response headers.
// scopeDropTarget: cascade's drop_target at currentPath
// scopeDefaultTool: cascade's default_tool at currentPath
// (empty when no default declared)
scopeDropTarget: false,
scopeDefaultTool: ''
previewWindow: null
};
})();

View file

@ -21,21 +21,13 @@
function fromServerEntry(e) {
// Server returns directory names with a trailing "/". Strip
// it for display; the is_dir flag is the canonical signal.
var name = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
// displayName is the friendlier label set by the parent .zddc
// `display:` map (when present). The on-disk basename stays in
// .name so URL composition (pathFor) and the chevron's title
// attribute still reflect the real folder name.
var displayName = (typeof e.display_name === 'string' && e.display_name)
? e.display_name
: '';
var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
return {
name: name,
displayName: displayName,
name: displayName,
isDir: e.is_dir,
size: e.size || 0,
modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(name),
ext: e.is_dir ? '' : splitExt(displayName),
url: e.url || null,
// FS-API specific (null in server mode):
handle: null
@ -70,38 +62,12 @@
// Fetch children of a directory in server mode.
// path must end with '/' so the request hits the directory route.
//
// 404 is treated as "empty directory" rather than a hard error.
// A directory that doesn't exist on the server (e.g. a fresh
// project's working/ before any drafts have been created, or a
// dir deleted between listing and expand) is functionally
// indistinguishable from an empty one for tree-rendering purposes.
// Server-side, zddc-server already returns 200 + [] for canonical
// project folders that are missing on disk; this fallback covers
// the same UX for anything else and for non-zddc-server backends.
async function fetchServerChildren(path) {
if (!path.endsWith('/')) path += '/';
var resp = await fetch(path, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
// Capture cascade-resolved scope flags from response headers
// before bailing on 404. zddc-server emits X-ZDDC-Drop-Target
// for directories the cascade marks as upload destinations
// (see zddc/internal/zddc/lookups.go DropTargetAt). The flag
// is leaf-only — it describes THIS path, not its descendants
// — so a rescope or popstate re-reads it from the new listing.
var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase();
window.app.state.scopeDropTarget = dropTargetHdr === 'true';
// X-ZDDC-Default-Tool surfaces the cascade-resolved default
// tool name for the current path. Browse uses it to decide
// grid-mode auto-activation (when default_tool==classifier)
// without re-implementing the cascade client-side.
window.app.state.scopeDefaultTool =
(resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase();
if (resp.status === 404) {
return [];
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
}

View file

@ -1,596 +0,0 @@
// preview-markdown.js — markdown plugin for the browse preview pane.
//
// Layout (CSS Grid):
// ┌─────────────────────────────────────────────────────────────────┐
// │ toolbar: Save | ● modified | status | source │
// ├────────────────────────────────────────┬────────────────────────┤
// │ │ Outline │
// │ │ • Heading 1 │
// │ Toast UI Editor │ • Subheading │
// │ (md / wysiwyg / preview) │ • Heading 2 │
// │ ├────────────────────────┤
// │ │ Front matter │
// │ │ title: Foo │
// │ │ revision: A │
// └────────────────────────────────────────┴────────────────────────┘
// Grid keeps every cell's size definite, which is what Toast UI needs
// to compute its inner scroll regions correctly. The previous nested-
// flexbox layout produced indeterminate heights and a fragile TOC
// pane width — grid fixes both.
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
// read-only — Save stays disabled. Toast UI is vendored
// (shared/vendor/toastui-editor-all.min.js); window.toastui is
// available synchronously before this module runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var SIDEBAR_MIN_WIDTH = 180;
var SIDEBAR_MAX_WIDTH = 480;
var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT;
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
function dispose() {
if (currentInstance && currentInstance.editor) {
try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ }
}
currentInstance = null;
}
// ── Front matter ────────────────────────────────────────────────────────
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
function parseFrontMatter(content) {
if (!content || content.indexOf('---\n') !== 0) {
return { data: {}, body: content || '' };
}
var endIdx = content.indexOf('\n---\n', 4);
if (endIdx === -1) return { data: {}, body: content };
var fmText = content.substring(4, endIdx);
var body = content.substring(endIdx + 5);
var data = {};
var lines = fmText.split('\n');
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.charAt(0) === '#') continue;
var colon = line.indexOf(':');
if (colon <= 0) continue;
var key = line.substring(0, colon).trim();
var val = line.substring(colon + 1).trim();
val = val.replace(/^["']|["']$/g, '');
if (val.startsWith('[') && val.endsWith(']')) {
val = val.slice(1, -1).split(',').map(function (s) {
return s.trim().replace(/^["']|["']$/g, '');
});
}
data[key] = val;
}
return { data: data, body: body };
}
function renderFrontMatter(fmEl, content) {
if (!fmEl) return;
var parsed = parseFrontMatter(content);
var keys = Object.keys(parsed.data);
if (keys.length === 0) {
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
return;
}
var html = '<dl class="md-fm__list">';
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
var v = parsed.data[k];
var displayV = Array.isArray(v)
? v.map(escapeHtml).join(', ')
: escapeHtml(String(v));
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
}
html += '</dl>';
fmEl.innerHTML = html;
}
// ── TOC (table of contents) ────────────────────────────────────────────
// ATX headings only; the body markdown drives the outline. Clicking
// a heading routes to whichever Toast UI pane is currently active
// (WYSIWYG or markdown preview).
function parseHeadings(content) {
var headings = [];
// Strip front matter so headings inside the envelope (e.g. comments)
// don't appear in the outline.
var parsed = parseFrontMatter(content);
var body = parsed.body;
var lines = body.split('\n');
var inFence = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// Skip fenced code blocks — headings inside them aren't real.
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
if (!m) continue;
var text = m[2]
.replace(/\\(.)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.trim();
headings.push({ level: m[1].length, text: text, lineIndex: i });
}
return headings;
}
function scrollEditorToHeading(editor, heading) {
try {
var els = editor.getEditorElements();
if (editor.isWysiwygMode && editor.isWysiwygMode()) {
var ww = els.wwEditor;
if (!ww) return;
var hs = ww.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var i = 0; i < hs.length; i++) {
if (hs[i].textContent.trim() === heading.text) {
var scroller = findScrollParent(hs[i]) || ww;
scroller.scrollTo({
top: hs[i].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(hs[i]);
return;
}
}
} else {
var line = heading.lineIndex + 1;
try { editor.setSelection([line, 1], [line, 1]); } catch (_) { /* ignore */ }
// Find the matching heading in the live markdown preview
// (right column of split view). If preview is collapsed
// (markdown-only) this is a no-op.
var preview = els.mdPreview;
if (preview) {
var phs = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
for (var j = 0; j < phs.length; j++) {
if (phs[j].textContent.trim() === heading.text) {
var pscroller = findScrollParent(phs[j]) || preview;
pscroller.scrollTo({
top: phs[j].offsetTop - 12,
behavior: 'smooth'
});
flashHeading(phs[j]);
return;
}
}
}
}
} catch (_e) { /* swallow; click was best-effort */ }
}
function findScrollParent(el) {
var cur = el.parentElement;
while (cur) {
var s = getComputedStyle(cur);
if (/(auto|scroll)/.test(s.overflowY)) return cur;
cur = cur.parentElement;
}
return null;
}
function flashHeading(el) {
if (!el) return;
el.classList.add('md-toc__flash');
setTimeout(function () { el.classList.remove('md-toc__flash'); }, 900);
}
function renderToc(tocEl, content, editor) {
if (!tocEl) return;
if (!content || !content.trim()) {
tocEl.innerHTML = '<p class="md-toc__empty">Empty file.</p>';
return;
}
var headings = parseHeadings(content);
if (headings.length === 0) {
tocEl.innerHTML = '<p class="md-toc__empty">No headings yet.</p>';
return;
}
// Build a flat list; CSS handles indentation. Using a flat list
// (rather than nested <ul>s) keeps the click target a clean,
// full-width row regardless of heading depth.
var html = '<ul class="md-toc__list">';
for (var i = 0; i < headings.length; i++) {
var h = headings[i];
html += '<li class="md-toc__item md-toc__item--l' + h.level + '"'
+ ' data-line="' + h.lineIndex + '"'
+ ' data-text="' + escapeHtml(h.text) + '"'
+ ' title="' + escapeHtml(h.text) + '">'
+ escapeHtml(h.text)
+ '</li>';
}
html += '</ul>';
tocEl.innerHTML = html;
tocEl.querySelectorAll('.md-toc__item').forEach(function (li) {
li.addEventListener('click', function () {
var idx = parseInt(li.dataset.line, 10);
var text = li.dataset.text;
scrollEditorToHeading(editor, { text: text, lineIndex: idx });
});
});
}
function debounce(fn, ms) {
var t;
return function () {
clearTimeout(t);
var args = arguments, self = this;
t = setTimeout(function () { fn.apply(self, args); }, ms);
};
}
// ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
}
// A markdown file living inside a .zip is read-only: a ZipFileHandle
// refuses createWritable (offline / nested), and zddc-server refuses
// writes to a "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
return false;
}
function canSave(node) {
if (isZipMemberNode(node)) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
}
// ── Mount ───────────────────────────────────────────────────────────────
async function render(node, container, ctx) {
if (typeof window.toastui === 'undefined') {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Toast UI Editor isn\'t bundled in this build.</div>';
return;
}
dispose();
// Read content.
var text;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': '
+ escapeHtml(e.message || String(e)) + '</div>';
return;
}
// Wipe the container and install a single shell child. The
// shell mirrors mdedit's layout: sidebar on the LEFT (front
// matter top, TOC bottom), content on the RIGHT (informational
// header above the Toast UI editor). CSS Grid keeps every
// cell sized definitely so Toast UI's scroll regions resolve
// correctly.
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'md-shell';
shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
container.appendChild(shell);
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
shell.appendChild(sidebar);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
// Horizontal resizer between front-matter and TOC.
var fmResizer = document.createElement('div');
fmResizer.className = 'md-shell__fmresizer';
fmResizer.setAttribute('role', 'separator');
fmResizer.setAttribute('aria-orientation', 'horizontal');
fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
fmResizer.tabIndex = 0;
sidebar.appendChild(fmResizer);
var tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div');
tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div');
tocBody.className = 'md-side__body md-toc__body';
tocSection.appendChild(tocHeader);
tocSection.appendChild(tocBody);
sidebar.appendChild(tocSection);
// Vertical resizer between sidebar and content.
var resizer = document.createElement('div');
resizer.className = 'md-shell__resizer';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', 'Resize sidebar');
resizer.tabIndex = 0;
shell.appendChild(resizer);
// ── Content (col 2): informational header + editor ──────────────────
var content = document.createElement('div');
content.className = 'md-shell__content';
shell.appendChild(content);
// Informational header above the editor: file name + save +
// dirty indicator + status + source hint. Renamed from
// "toolbar" to read as a header, since it titles the content.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) {
sourceEl.textContent = 'read-only (zip)';
} else if (node.handle) {
sourceEl.textContent = 'local';
} else if (node.url) {
sourceEl.textContent = 'server';
}
infohdr.appendChild(titleEl);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
infohdr.appendChild(saveBtn);
content.appendChild(infohdr);
// Editor host.
var editorHost = document.createElement('div');
editorHost.className = 'md-shell__editor';
content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
var initialHash = await hashContent(text);
var editor = new window.toastui.Editor({
el: editorHost,
height: '100%',
initialEditType: 'markdown',
previewStyle: 'vertical',
initialValue: text,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock']
]
});
currentInstance = {
editor: editor,
container: container,
dirty: false,
node: node,
hash: initialHash,
tocEl: tocBody,
fmEl: fmBody
};
var writable = canSave(node);
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
}
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
// ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the
// sidebar; left shrinks it.
(function () {
var dragging = false;
var startX = 0;
var startW = 0;
function onMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
var w = startW + dx;
w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
e.preventDefault();
}
function onUp() {
dragging = false;
resizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
resizer.addEventListener('mousedown', function (e) {
dragging = true;
resizer.classList.add('is-dragging');
startX = e.clientX;
startW = sidebar.getBoundingClientRect().width;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
resizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var step = e.key === 'ArrowLeft' ? -24 : 24;
var w = Math.max(SIDEBAR_MIN_WIDTH,
Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
});
})();
// ── Front-matter / TOC vertical resizer ─────────────────────────────
(function () {
var FM_MIN = 60;
var dragging = false;
var startY = 0;
var startH = 0;
function maxFmHeight() {
var sidebarRect = sidebar.getBoundingClientRect();
// Leave at least 120 px for the TOC body + headers.
return Math.max(FM_MIN, sidebarRect.height - 160);
}
function onMove(e) {
if (!dragging) return;
var dy = e.clientY - startY;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
e.preventDefault();
}
function onUp() {
dragging = false;
fmResizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
fmResizer.addEventListener('mousedown', function (e) {
dragging = true;
fmResizer.classList.add('is-dragging');
startY = e.clientY;
startH = fmSection.getBoundingClientRect().height;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
fmResizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
var step = e.key === 'ArrowUp' ? -24 : 24;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
});
})();
// ── Change tracking + auto-rerender ────────────────────────────────
function markDirty(isDirty) {
currentInstance.dirty = isDirty;
saveBtn.disabled = !isDirty || !writable;
dirtyEl.textContent = isDirty ? '● modified' : '';
}
var onChange = debounce(async function () {
var current = editor.getMarkdown();
var h = await hashContent(current);
markDirty(h !== currentInstance.hash);
renderToc(tocBody, current, editor);
renderFrontMatter(fmBody, current);
}, 250);
editor.on('change', onChange);
// ── Save ───────────────────────────────────────────────────────────
async function save() {
if (!currentInstance.dirty || !writable) return;
var content = editor.getMarkdown();
try {
statusEl.textContent = 'Saving…';
await saveContent(node, content);
currentInstance.hash = await hashContent(content);
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
} catch (e) {
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
}
}
saveBtn.addEventListener('click', save);
container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
save();
}
});
}
window.app.modules.markdown = {
render: render,
dispose: dispose
};
})();

View file

@ -1,14 +1,11 @@
// preview.js — file-preview rendering for the browse tool's right pane.
// preview.js — file preview popup. Reuses shared/preview-lib.js for
// TIFF, ZIP listing, and image-rendering helpers; native iframe for
// PDF and HTML; <pre> for text; download button for everything else.
//
// Default flow: showFilePreview(node) renders into the inline preview
// pane (#previewBody). Popup flow: showFilePreview(node, {popup:true})
// opens a separate window — kept for users who want previews on a
// second monitor.
//
// Rendering uses shared/preview-lib.js for content types it handles
// (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes;
// text into a <pre>; markdown into the dedicated markdown plugin
// (preview-markdown.js); unknown extensions show a download button.
// Lifecycle: a single popup window is reused across multiple file
// clicks (state.previewWindow). Subsequent clicks rewrite its
// contents instead of spawning a new window — same UX as the archive
// tool.
(function () {
'use strict';
@ -16,7 +13,9 @@
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
console.error('[browse] zddc.preview not loaded — preview disabled.');
// shared/preview-lib.js wasn't concatenated in. Bail loudly so
// the bug shows up in console rather than mysteriously failing.
console.error('[browse] zddc.preview not loaded — preview popup disabled.');
}
function escapeHtml(s) {
@ -33,25 +32,23 @@
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel'
'js': 'text/javascript', 'css': 'text/css'
};
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// Pull bytes for a file node. Three sources:
// - server URL (zddc-server-backed file, including downloads
// of archived files served at real paths)
// - FS-API handle (local folder)
// - JSZip entry (file inside an expanded zip; reads from
// parent's cached JSZip instance)
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so
// it falls through the same getFile() path as any local file.
if (node.zipParentId != null) {
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipFile) {
throw new Error('parent zip not loaded');
}
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
}
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
@ -64,11 +61,15 @@
throw new Error('no source for file');
}
function getMime(ext) {
return MIME[ext] || 'application/octet-stream';
}
// Build a blob URL for the file's bytes. For server-mode regular
// files (not in a zip), prefer the live URL — relative links and
// server-side interception (e.g. .archive resolution) work then.
async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type
// and lets relative links inside HTML resolve back to the server.
if (state.source === 'server' && node.url) {
if (state.source === 'server' && node.url && node.zipParentId == null) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
@ -76,134 +77,14 @@
return { url: URL.createObjectURL(blob), fromServer: false };
}
// ── Inline rendering ────────────────────────────────────────────────────
function renderEmpty(container, msg) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
}
function renderError(container, msg) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ escapeHtml(msg) + '</div>';
}
async function renderInline(node) {
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
if (titleEl) titleEl.textContent = node.name;
if (metaEl) {
var meta = [];
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
if (node.ext) meta.push(node.ext.toUpperCase());
metaEl.textContent = meta.join(' · ');
}
if (popoutBtn) popoutBtn.classList.remove('hidden');
var ext = (node.ext || '').toLowerCase();
// Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
return;
}
// PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {
var info = await getBlobUrl(node);
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Plain images (jpg/png/gif/webp/svg) → <img>. TIFF goes through preview-lib.
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
}
return;
}
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
}
return;
}
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars)';
}
container.innerHTML = '';
var pre = document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
container.innerHTML =
'<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
+ '<br><a class="btn btn-primary btn-sm" download="' + escapeHtml(node.name)
+ '" href="' + escapeHtml(fallbackInfo.url) + '" style="margin-top:1rem">'
+ 'Download ' + escapeHtml(node.name) + '</a>'
+ '</div>';
} catch (e) {
renderError(container, 'No source for ' + node.name);
}
}
// ── Popup window (kept for "Pop out" button) ────────────────────────────
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
// Inline PDF and HTML previews load in iframes. HTML uses
// sandbox="allow-same-origin allow-popups
// allow-popups-to-escape-sandbox" — same posture as archive's
// preview: links navigate, scripts blocked, popups allowed.
var contentHtml;
if (ext === 'pdf') {
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
@ -247,47 +128,64 @@
+ '</' + 'script></body></html>';
}
async function renderInPopupWindow(node, win, info) {
var ext = (node.ext || '').toLowerCase();
if (ext === 'pdf' || ext === 'html' || ext === 'htm') return;
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) return;
async function renderTextInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
if (preview && preview.isTiff(ext)) {
var tb = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, tb, { fileName: node.name });
} else if (preview && preview.isZip(ext)) {
var zb = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, zb, { fileName: node.name });
} else if (preview && preview.isText(ext)) {
var txb = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txb);
var buf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
var MAX = 200000;
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars — Download for full file)';
}
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} else {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderInPopup(node) {
async function renderTiffInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error rendering TIFF: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function renderZipInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error reading ZIP: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function showFilePreview(node) {
if (node.isDir) return;
var ext = (node.ext || '').toLowerCase();
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Pop-out failed: ' + e.message);
window.app.modules.events.statusError('Preview failed: ' + e.message);
return;
}
var html = popupShell(node, info.url);
var win = state.previewWindow;
if (win && !win.closed) {
win.document.open();
@ -303,6 +201,7 @@
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
+ ',resizable=yes,scrollbars=yes');
if (!win) {
// Popup blocked — fall back to opening the file directly.
window.open(info.url, '_blank', 'noopener');
return;
}
@ -311,21 +210,30 @@
win.focus();
state.previewWindow = win;
}
await renderInPopupWindow(node, win, info);
// Async content rendering for the non-iframe types.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
return; // iframe wired in popupShell
}
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
return; // <img> wired in popupShell
}
if (preview && preview.isTiff(ext)) {
await renderTiffInWindow(node, win);
} else if (preview && preview.isZip(ext)) {
await renderZipInWindow(node, win);
} else if (preview && preview.isText(ext)) {
await renderTextInWindow(node, win);
} else {
// Unknown type — show a friendly "no preview, click
// download" placeholder.
var c = win.document.getElementById('previewContent');
if (c) {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
}
}
// ── Public entry ────────────────────────────────────────────────────────
async function showFilePreview(node, opts) {
if (node.isDir) return;
opts = opts || {};
if (opts.popup) return renderInPopup(node);
return renderInline(node);
}
window.app.modules.preview = {
showFilePreview: showFilePreview,
// Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer
};
window.app.modules.preview = { showFilePreview: showFilePreview };
})();

View file

@ -14,19 +14,13 @@
function newNode(raw, parentId, depth) {
var id = state.nextId++;
// A .zip file is treated as a folder for tree purposes — the
// chevron expands it. On expand, server mode fetches the
// server's "<…>.zip/" virtual-directory listing; offline mode
// opens the zip with JSZip behind a ZipDirectoryHandle. Either
// way the zip's members become ordinary directory/file nodes.
// ZIP files are treated as folders for tree purposes — the
// chevron lets the user expand them inline. The actual
// contents are loaded on first expand via JSZip.
var isZip = !raw.isDir && raw.ext === 'zip';
var node = {
id: id,
name: raw.name,
// displayName is the rendered label when set by the parent
// .zddc display: map. Sort + lookup continues to use .name
// (the on-disk basename) so URL composition stays canonical.
displayName: raw.displayName || '',
isDir: raw.isDir,
size: raw.size,
modTime: raw.modTime,
@ -39,11 +33,9 @@
loaded: false,
childIds: [],
isZip: isZip,
_zipDirHandle: null, // cached ZipDirectoryHandle (offline / nested zips)
// True when this entry was synthesized client-side (e.g.
// canonical project folders that don't exist on disk yet).
// Rendered with a muted style + an "(empty)" hint.
virtual: !!raw.virtual
zipFile: null, // cached JSZip instance
zipPath: raw.zipPath || null, // path within zip (for virtual children)
zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries)
};
state.nodes.set(id, node);
return node;
@ -110,14 +102,17 @@
parent.loaded = true;
}
// Walk nodes in render order. Skips the children of a collapsed
// expandable.
// Walk visible nodes in render order. Excludes nodes whose
// node.visible is false (filter-hidden) and skips the children of
// a collapsed expandable. Filter visibility is computed by
// recomputeVisibility() before this is called from render().
function visibleIds() {
var out = [];
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
if (n.visible === false) continue;
out.push(ids[i]);
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
}
@ -154,53 +149,166 @@
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) —
// the preview pane handles click navigation, and a Ctrl/Cmd-
// click can fall back to opening the file's url in a new tab
// via the events.js click handler (it sees the modifier key).
function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0;
var indent = node.depth * 1.2;
var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
var selected = state.selectedId === node.id ? ' is-selected' : '';
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
var virtualHint = node.virtual
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>'
: '';
var nameInner;
if (node.isDir) {
nameInner = '<span class="tree-name__label is-folder">'
+ escapeHtml(node.name) + '</span>';
} else {
// File / zip: clickable. Plain click → preview popup.
// Modifier-click (ctrl/cmd) and middle-click → open in
// new tab (browser default for the href). Server mode
// gets the real URL (so right-click → save-link-as also
// works); FS mode and zip-virtual children get '#'.
var href = node.url || '#';
nameInner = '<a class="tree-name__label is-file"'
+ ' href="' + escapeHtml(href) + '"'
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
}
return ''
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '" data-iszip="' + node.isZip + '">'
+ '<td class="col-name">'
+ '<span class="tree-name">'
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.displayName || node.name) + '</span>'
+ virtualHint
+ '</div>';
+ nameInner
+ '</span>'
+ '</td>'
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
+ '</tr>';
}
function render() {
var body = document.getElementById('treeBody');
if (!body) return;
var tbody = document.getElementById('browseTbody');
if (!tbody) return;
recomputeVisibility();
var ids = visibleIds();
var html = '';
for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i]));
}
body.innerHTML = html;
tbody.innerHTML = html;
updateCount();
updateSortHeaders();
renderBreadcrumbs();
}
// Count nodes that render at the root + every expanded subtree.
// Compute model-level visibility per node based on the three
// filter ASTs:
// - fileFilter → matches a file's basename
// - folderFilter→ matches a folder's basename
// - extFilter → matches a file's extension (no leading dot)
//
// Visibility rules:
// 1. A FILE is "self-matched" when it passes both file+ext filter.
// 2. A FOLDER is "self-matched" when it passes the folder filter.
// 3. A file is "in-scope" when either no folder filter is active,
// OR at least one ancestor folder is folder-self-matched.
// 4. A file is VISIBLE when self-matched AND in-scope.
// 5. A folder is VISIBLE when:
// - any descendant is visible (so the path to a hit is
// always shown), OR
// - the folder itself is folder-self-matched AND no file
// filter is active (when a file filter is set, we hide
// folders that have no matching files inside — keeps
// the result list focused).
//
// Pure model walk; the renderer just consumes node.visible. Hidden
// expandable nodes get their `expanded` flag respected even though
// they're not in the DOM, so toggling filters preserves the user's
// expand state.
function recomputeVisibility() {
var fileAst = state.filters.file.ast;
var folderAst = state.filters.folder.ast;
var extAst = state.filters.ext.ast;
var hasFile = !!(state.filters.file.raw);
var hasFolder = !!(state.filters.folder.raw);
var hasExt = !!(state.filters.ext.raw);
var anyActive = hasFile || hasFolder || hasExt;
// Fast path: nothing filtered → everything visible.
if (!anyActive) {
state.nodes.forEach(function (n) { n.visible = true; });
return;
}
var f = window.zddc && window.zddc.filter;
// Walk top-down to propagate folder scope, then bottom-up to
// propagate descendant visibility. Done in one DFS recursion.
// ZIPs are hybrids — they match FILE filter (their name is a
// filename) AND can be matched by FOLDER filter (they're
// container-like — clicking expands them like a folder).
function visit(nodeId, ancestorMatchesFolder) {
var n = state.nodes.get(nodeId);
if (!n) return false;
if (!(n.isDir || n.isZip)) {
// Plain file. Visible iff its name+ext pass file/ext
// filters AND it's inside the folder-filter scope.
var nameOk = f.matches(n.name, fileAst);
var extOk = f.matches(n.ext || '', extAst);
n.visible = nameOk && extOk && ancestorMatchesFolder;
return n.visible;
}
// Folder or zip — has childIds and contributes to scope.
// Folder self-match: the folder/zip name passes folder
// filter. A folder match also opens the file-filter scope
// for descendants.
var asFolderMatch = f.matches(n.name, folderAst);
// A zip can also match the FILE filter (it's a file too).
// Typing a zip name into file filter surfaces the zip.
// Gate on hasFile||hasExt — when neither is active, the
// empty filter matches every name and would falsely
// surface every zip regardless of the active folder filter.
var asFileMatch = n.isZip
&& (hasFile || hasExt)
&& f.matches(n.name, fileAst)
&& f.matches(n.ext || '', extAst);
var nextAncestorScope = ancestorMatchesFolder
|| asFolderMatch || asFileMatch;
var anyChildVisible = false;
for (var i = 0; i < n.childIds.length; i++) {
if (visit(n.childIds[i], nextAncestorScope)) anyChildVisible = true;
}
// Visible if:
// - any descendant is visible (path-to-hit visibility), or
// - self-folder-match with no file/ext filter active
// (let the folder surface even if it's empty/unloaded), or
// - self-file-match (for zips, where the user is searching
// for the archive by name in the file filter).
n.visible = anyChildVisible
|| (asFolderMatch && !hasFile && !hasExt)
|| asFileMatch;
return n.visible;
}
// Initial ancestor scope = folder filter empty (so files don't
// require ancestor matches when there's no folder filter).
var initialScope = !hasFolder;
for (var i = 0; i < state.rootIds.length; i++) {
visit(state.rootIds[i], initialScope);
}
}
// Count nodes that would render if no filter were active
// (i.e. anything at the root, or under an expanded ancestor).
// Used to express "<visible> of <total> shown" while a filter is on.
function expandedSetSize() {
var n = 0;
function walk(ids) {
@ -219,8 +327,14 @@
function updateCount() {
var el = document.getElementById('entryCount');
if (!el) return;
var visible = visibleIds().length;
var total = expandedSetSize();
el.textContent = total + ' item' + (total === 1 ? '' : 's');
var anyFilter = state.filters.file.raw
|| state.filters.folder.raw
|| state.filters.ext.raw;
el.textContent = anyFilter
? visible + ' of ' + total + ' shown'
: total + ' item' + (total === 1 ? '' : 's');
}
// ── Breadcrumbs ──────────────────────────────────────────────────────
@ -276,64 +390,38 @@
el.innerHTML = html;
}
// Sort headers no longer exist in the DOM (the tree replaced the
// table); the tree.setSort() method still works but only via
// programmatic callers — there's no UI for changing sort yet.
// True when this .zip node lives inside another zip, so its bytes
// can't be fetched as a standalone server resource: we read them
// through the containing handle (offline / nested) or by fetching
// the inner-zip member URL. In server mode a zip-inside-a-zip's URL
// contains ".zip/"; offline it has a handle that is itself a zip
// entry.
function zipNestedInsideZip(node) {
if (state.source === 'server') {
return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
function updateSortHeaders() {
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('sort-asc', 'sort-desc');
if (ths[i].dataset.sort === state.sort.key) {
ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc');
}
return !!(node.handle && node.handle.isZipEntry);
}
// Open a .zip node as a directory handle (a ZipDirectoryHandle over
// a JSZip instance), cached on the node. Bytes come from a real
// FileSystemFileHandle / ZipFileHandle when present (offline, or a
// zip nested in a zip), else from a server URL — zddc-server returns
// the raw .zip for "<…>.zip" and the inner-zip bytes for
// "<outer>.zip/inner.zip".
async function zipDirHandle(node) {
if (node._zipDirHandle) return node._zipDirHandle;
await loader.ensureJSZip();
var zh;
if (node.handle) {
zh = await window.zddc.zip.fromFileHandle(node.handle);
} else if (node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
} else {
throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
}
node._zipDirHandle = zh;
return zh;
}
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
// - regular folder → server JSON listing OR FS-API entries
// - top-level .zip, server mode → the server's "<…>.zip/" virtual-
// directory listing (no whole-zip
// download — zddc-server extracts a
// member only when one is requested)
// - .zip otherwise (offline, or a zip nested in a zip)
// → open it with JSZip and enumerate
// it as a directory handle; members
// become ordinary dir/file nodes
// - regular folder → server JSON listing OR FS-API enumeration
// - zip file → fetch+JSZip; entries become virtual children
// - zip child dir → already-listed entries from the parent zip
// (zips are enumerated whole, so child dirs
// are pre-populated when the zip expands)
async function loadChildren(node) {
if (node.loaded) return;
try {
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
} else if (node.isZip) {
setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
if (node.isZip) {
await loadZipChildren(node);
} else if (node._zipSyntheticDir) {
// Synthetic dir node materialized when a zip's entry
// list referenced "a/b/file" but had no "a/" entry.
// Re-walk the owning zip's flat entry list with the
// dir's full prefix.
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipEntries) {
throw new Error('zip parent not loaded');
}
setZipDirChildren(node, owner, node.zipPath + '/');
} else if (node.isDir) {
var raw;
if (state.source === 'server') {
@ -351,6 +439,117 @@
}
}
// Fetch a zip's bytes, parse with JSZip, and materialize its
// entries as a tree of virtual nodes. JSZip's entry list is flat
// (full paths); we reconstruct the directory hierarchy on top.
async function loadZipChildren(zipNode) {
await loader.ensureJSZip();
var arrayBuffer;
if (state.source === 'server' && zipNode.url) {
var resp = await fetch(zipNode.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
arrayBuffer = await resp.arrayBuffer();
} else if (zipNode.handle) {
// FS-API: top-level zip in a local folder.
var f = await zipNode.handle.getFile();
arrayBuffer = await f.arrayBuffer();
} else if (zipNode.zipParentId != null) {
// Nested zip inside another zip — read from parent JSZip.
var parent = state.nodes.get(zipNode.zipParentId);
if (!parent || !parent.zipFile) {
throw new Error('parent zip not loaded');
}
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
} else {
throw new Error('cannot fetch zip bytes (no source)');
}
var zip = await window.JSZip.loadAsync(arrayBuffer);
zipNode.zipFile = zip;
// Build a path → raw-entry map. Entry paths are
// "dir/sub/file.ext" or "dir/" for directories. We slice
// to immediate children of zipNode (i.e. zero slashes after
// a leading prefix). For nested directories, we synthesize
// folder nodes that lazy-expand to the next level via the
// same raw-entry list — keep it on the zipNode for replay.
zipNode.zipEntries = []; // for re-walk on expand of subdirs
zip.forEach(function (relPath, entry) {
zipNode.zipEntries.push({
path: relPath.replace(/\/$/, ''),
isDir: entry.dir,
size: (entry._data && entry._data.uncompressedSize) || 0,
modTime: entry.date instanceof Date ? entry.date : null,
rawPath: relPath
});
});
// Now seed top-level children of the zip itself.
setZipDirChildren(zipNode, zipNode, '');
}
// Populate node's childIds with the entries directly under
// pathPrefix (relative to the owning zip). Directory entries
// become folder nodes whose own children are seeded on first
// expand by this same function (recursively descending zipPath).
function setZipDirChildren(node, zipOwner, pathPrefix) {
var seen = new Map(); // immediate child name → raw entry
zipOwner.zipEntries.forEach(function (e) {
if (!e.path.startsWith(pathPrefix)) return;
var rest = e.path.substring(pathPrefix.length);
if (rest === '') return;
// Take the FIRST segment of the remaining path
var slash = rest.indexOf('/');
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
var isImmediateFile = !e.isDir && slash === -1;
var isImmediateDir = e.isDir && slash === -1;
// For deeply-nested entries (rest contains a slash), we
// surface only the first segment as a synthetic folder
// entry. For immediate entries, we emit the entry as-is.
if (isImmediateFile || isImmediateDir) {
// Immediate entry — use the real metadata.
seen.set(firstSeg, {
name: firstSeg,
isDir: e.isDir,
size: e.size,
modTime: e.modTime,
ext: e.isDir ? '' : loader.splitExt(firstSeg),
url: null,
handle: null,
zipPath: e.path,
zipParentId: zipOwner.id
});
} else if (slash !== -1 && !seen.has(firstSeg)) {
// Deeper entry, no explicit dir entry yet — synthesize.
seen.set(firstSeg, {
name: firstSeg,
isDir: true,
size: 0,
modTime: null,
ext: '',
url: null,
handle: null,
zipPath: pathPrefix + firstSeg,
zipParentId: zipOwner.id
});
}
});
// Drop existing children (re-load case)
node.childIds.forEach(function (id) { state.nodes.delete(id); });
node.childIds = [];
seen.forEach(function (raw) {
var n = newNode(raw, node.id, node.depth + 1);
// Synthetic dir nodes inside zip don't have a dedicated
// load path — they re-walk zipEntries on expand. Mark
// them so the dispatcher knows.
if (raw.isDir && !n.isZip) {
n._zipSyntheticDir = true;
}
node.childIds.push(n.id);
});
sortNodes(node.childIds);
node.loaded = true;
}
// Toggle a folder's expanded state. Loads children on first expand.
// Treats "expandable" as either a real directory OR a zip file
// (zip files act like folders for tree purposes — the chevron
@ -448,11 +647,15 @@
}
render();
},
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
// Used by the toolbar's sort dropdown.
setSortExplicit: function (key, dir) {
state.sort.key = key;
state.sort.dir = (dir === -1 ? -1 : 1);
// Update one of the three column filters and re-render. `which`
// is 'file' | 'folder' | 'ext'. Empty raw → AST cleared.
setFilter: function (which, raw) {
var slot = state.filters[which];
if (!slot) return;
slot.raw = raw || '';
slot.ast = slot.raw && window.zddc && window.zddc.filter
? window.zddc.filter.parse(slot.raw)
: null;
render();
},
pathFor: pathFor

View file

@ -1,220 +0,0 @@
// upload.js — drag-drop file upload into the current scope.
//
// Active only in server mode and only at paths where the cascade
// declares drop_target: true (see zddc/internal/zddc/lookups.go
// DropTargetAt + defaults.zddc.yaml). The loader captures the
// X-ZDDC-Drop-Target response header on every directory listing
// fetch and stamps state.scopeDropTarget; this module just reads it.
//
// At scopes where drop_target is false (or unset), the handlers
// stay armed but ignore drops silently — no visible drop-zone
// overlay. An operator can flip working/staging/incoming on or
// extend the cascade to mark additional dirs as drop targets via
// .zddc; the client follows automatically without code change.
//
// Wire model:
// - dragenter on the document raises a counter; first-enter shows
// the overlay.
// - dragleave decrements; reaching zero hides the overlay.
// - drop short-circuits: prevent default, PUT each file under the
// current state.currentPath, surface per-file toast results,
// refetch the listing on completion.
//
// The PUT uses fetch(`<currentPath><filename>`, method: 'PUT'). The
// server's authorizeAction enforces write ACL on the parent; a 403
// surfaces as an error toast and the rest of the batch proceeds.
//
// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
// are rejected client-side with a clear toast — the server would
// accept them in chunks but browse's v1 PUT is a single body, and
// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
// poor experience. Operators with larger uploads should use a
// dedicated client (zddc-cli or the cache/mirror downstream).
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
var state = window.app.state;
var enterCount = 0;
var overlayEl = null;
function ensureOverlay() {
if (overlayEl) return overlayEl;
overlayEl = document.createElement('div');
overlayEl.className = 'upload-overlay';
overlayEl.setAttribute('aria-hidden', 'true');
overlayEl.innerHTML =
'<div class="upload-overlay__panel">'
+ '<div class="upload-overlay__icon">⤴</div>'
+ '<div class="upload-overlay__title">Drop to upload</div>'
+ '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
+ '</div>';
document.body.appendChild(overlayEl);
return overlayEl;
}
function currentScopeAllows() {
if (!state || state.source !== 'server') return false;
// state.scopeDropTarget is set by the loader on every listing
// fetch from the X-ZDDC-Drop-Target response header; it's a
// boolean read of the cascade's effective drop_target flag at
// the current path. Defaults to false when the header is
// absent (older server or non-server response).
return !!state.scopeDropTarget;
}
function showOverlay() {
var el = ensureOverlay();
var pathEl = el.querySelector('#uploadOverlayPath');
if (pathEl) pathEl.textContent = state.currentPath || '/';
el.classList.add('is-active');
}
function hideOverlay() {
if (overlayEl) overlayEl.classList.remove('is-active');
}
function dragHasFiles(e) {
if (!e.dataTransfer || !e.dataTransfer.types) return false;
var types = e.dataTransfer.types;
for (var i = 0; i < types.length; i++) {
if (types[i] === 'Files') return true;
}
return false;
}
function uploadUrl(filename) {
var base = state.currentPath || '/';
if (!base.endsWith('/')) base += '/';
return base + encodeURIComponent(filename);
}
async function uploadOne(file) {
if (file.size > UPLOAD_MAX_BYTES) {
return {
file: file,
ok: false,
status: 0,
message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)'
};
}
try {
var resp = await fetch(uploadUrl(file.name), {
method: 'PUT',
body: file,
credentials: 'same-origin',
headers: {
'Content-Type': file.type || 'application/octet-stream'
}
});
return {
file: file,
ok: resp.ok,
status: resp.status,
message: resp.ok ? '' : ('HTTP ' + resp.status)
};
} catch (e) {
return {
file: file,
ok: false,
status: 0,
message: (e && e.message) ? e.message : 'network error'
};
}
}
async function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
enterCount = 0;
hideOverlay();
if (!currentScopeAllows()) return;
var dt = e.dataTransfer;
if (!dt || !dt.files || dt.files.length === 0) return;
var files = Array.from(dt.files);
var note = window.zddc && window.zddc.toast;
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info');
// Sequential — predictable progress + ordering. Can parallelise
// later if it matters.
var ok = 0, fail = 0;
for (var i = 0; i < files.length; i++) {
var res = await uploadOne(files[i]);
if (res.ok) {
ok++;
} else {
fail++;
if (note) {
note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error');
}
}
}
if (note) {
if (fail === 0) {
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success');
} else if (ok === 0) {
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
} else {
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
}
}
// Refresh the listing so newly-uploaded files appear.
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
if (loader && tree && state.currentPath) {
try {
var es = await loader.fetchServerChildren(state.currentPath);
tree.setRoot(es);
tree.render();
} catch (_e) { /* swallow; user can hard-reload */ }
}
}
function onEnter(e) {
if (!dragHasFiles(e)) return;
enterCount++;
if (enterCount === 1 && currentScopeAllows()) {
showOverlay();
}
}
function onLeave(e) {
if (!dragHasFiles(e)) return;
enterCount = Math.max(0, enterCount - 1);
if (enterCount === 0) hideOverlay();
}
function onOver(e) {
if (!dragHasFiles(e)) return;
// preventDefault on dragover is required for drop to fire.
e.preventDefault();
if (e.dataTransfer && currentScopeAllows()) {
e.dataTransfer.dropEffect = 'copy';
} else if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'none';
}
}
function init() {
document.addEventListener('dragenter', onEnter, false);
document.addEventListener('dragleave', onLeave, false);
document.addEventListener('dragover', onOver, false);
document.addEventListener('drop', handleDrop, false);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.app.modules.upload = {
currentScopeAllows: currentScopeAllows,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
};
})();

View file

@ -25,7 +25,7 @@
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>
</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>
@ -34,67 +34,69 @@
</header>
<main id="appMain">
<div id="emptyState" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<div id="emptyState" class="empty-state">
<div class="empty-state__inner">
<h2>ZDDC Browse</h2>
<p>A two-pane file browser for ZDDC archives — and any directory.</p>
<ul class="welcome-list">
<p>A simple directory listing for ZDDC archives — and any directory.
Pick how you want to browse:</p>
<ul>
<li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li>
</ul>
<p>Once loaded: click folders to expand, click files to preview them in
the right pane. Markdown files open in a full editor with TOC.
Switch to <b>Grid</b> mode to bulk-rename ZDDC files
spreadsheet-style.</p>
<p>Once loaded: click a folder to expand it, <b>shift-click</b>
to expand its entire subtree (or collapse it again),
click column headers to sort. Use the 📄 row to filter files
(and the 📁 row to scope to matching folders) — file matches
stay visible together with their containing folders.
Click any file to open it.</p>
</div>
</div>
<div id="browseRoot" class="browse-root hidden">
<div class="browse-toolbar">
<div class="toolbar">
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
<span class="toolbar__count" id="entryCount"></span>
<button id="downloadZipBtn" class="btn btn-sm btn-secondary hidden"
title="Download this folder (and everything under it you can access) as a .zip"
aria-label="Download this folder as a zip">⤓ Download (zip)</button>
<label class="sort-control" for="sortBy" title="Sort tree entries">
<span class="sort-control__label">Sort:</span>
<select id="sortBy" class="sort-control__select" aria-label="Sort tree entries">
<option value="name:asc">Name (A→Z)</option>
<option value="name:desc">Name (Z→A)</option>
<option value="date:desc">Modified (new→old)</option>
<option value="date:asc">Modified (old→new)</option>
<option value="size:desc">Size (large→small)</option>
<option value="size:asc">Size (small→large)</option>
<option value="ext:asc">Type (A→Z)</option>
</select>
</label>
</div>
<!-- Browse mode (default): two-pane tree + preview -->
<div id="browseView" class="browse-view">
<div class="pane tree-pane" id="treePane">
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
<div class="pane preview-pane" id="previewPane">
<div class="preview-pane__header">
<span class="preview-pane__title" id="previewTitle">No file selected</span>
<span class="preview-pane__meta" id="previewMeta"></span>
<button id="previewPopout" class="btn btn-sm btn-secondary hidden" title="Pop out into a separate window" aria-label="Pop out into a separate window">⤴ Pop out</button>
</div>
<div class="preview-pane__body" id="previewBody">
<div class="preview-empty">Click a file in the tree to preview it.</div>
</div>
</div>
</div>
<!-- Grid mode: classifier-style spreadsheet rooted at the current dir -->
<div id="gridView" class="grid-view hidden">
<div class="grid-empty">
Grid view is loading…
</div>
<div class="browse-table-wrap">
<table class="browse-table" id="browseTable">
<thead>
<tr class="header-row">
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</tr>
<tr class="filter-row filter-row--file" title="Filter files">
<th class="col-name">
<span class="filter-row__icon" aria-hidden="true">📄</span>
<input type="text" class="column-filter" data-filter="file"
placeholder="filter files…" spellcheck="false"
aria-label="Filter by file name">
</th>
<th class="col-size"></th>
<th class="col-ext">
<input type="text" class="column-filter" data-filter="ext"
placeholder="ext…" spellcheck="false"
aria-label="Filter by extension">
</th>
<th class="col-date"></th>
</tr>
<tr class="filter-row filter-row--folder" title="Filter folders">
<th class="col-name">
<span class="filter-row__icon" aria-hidden="true">📁</span>
<input type="text" class="column-filter" data-filter="folder"
placeholder="filter folders…" spellcheck="false"
aria-label="Filter by folder name">
</th>
<th class="col-size"></th>
<th class="col-ext"></th>
<th class="col-date"></th>
</tr>
</thead>
<tbody id="browseTbody"></tbody>
</table>
</div>
</div>
</main>
@ -109,43 +111,66 @@
</div>
<div class="help-panel__body">
<h3>What is Browse?</h3>
<p>Browse is the ZDDC file experience. Two top-level modes:</p>
<p>Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:</p>
<dl>
<dt>Browse mode</dt>
<dd>File tree on the left, preview on the right. Click a folder to
expand, click a file to preview. Markdown files open in a full editor;
PDF, image, ZIP, XLSX, DOCX, TIFF all render inline.</dd>
<dt>Grid mode</dt>
<dd>Spreadsheet view of the current subtree's files for bulk
ZDDC renaming. Edit cells directly, copy/paste with Excel,
save back to disk.</dd>
<dt>Online</dt>
<dd>When the page is served by zddc-server, the listing for the current
URL directory loads automatically. Breadcrumbs link to ancestor folders.</dd>
<dt>Local</dt>
<dd>Click <strong>Add Local Directory</strong> to pick any folder on your
computer. Local mode requires a Chromium-based browser (File System
Access API).</dd>
</dl>
<h3>Tree navigation (Browse mode)</h3>
<h3>Tree navigation</h3>
<dl>
<dt>Click a folder</dt>
<dd>Expand or collapse it inline.</dd>
<dd>Toggle expand/collapse on that folder.</dd>
<dt>Shift-click a folder</dt>
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dd>Recursive expand or collapse — applies to the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Preview it in the right pane.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>
<dd>Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click
opens in a new tab.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>⤓ Download (zip)</dt>
<dd>Downloads the directory you're currently viewing — and everything
under it that you're allowed to see — as a single <code>.zip</code>.
Navigate into a subfolder first to download just that subtree. Online,
the server streams it; locally, the browser bundles the picked folder
(a confirmation appears if it's very large).</dd>
<dt>Column headers</dt>
<dd>Click to sort; click again to reverse.</dd>
<dt>Refresh</dt>
<dd>Re-fetches the current directory listing — works for both
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
</dl>
<h3>Filter rows</h3>
<p>Two filter rows live in the table header:</p>
<dl>
<dt>📄 file row</dt>
<dd>Filter by file name (left input) and/or extension (Type input).
File matches stay visible together with their ancestor folders, so
the path to each hit is always shown.</dd>
<dt>📁 folder row</dt>
<dd>Filter by folder name. Matching folders show with their entire
subtree. Combined with file filter: file must also be inside a
matching folder's subtree (intersection).</dd>
</dl>
<p>Filter syntax (shared across all ZDDC tools):</p>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain</dd>
<dt><code>^term</code></dt>
<dd>Starts with</dd>
<dt><code>term$</code></dt>
<dd>Ends with</dd>
<dt><code>a b</code></dt>
<dd>Both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Either (OR)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex — any-char + any-sequence</dd>
</dl>
<h3>Header buttons</h3>
<dl>
<dt>Add Local Directory</dt>

25
build
View file

@ -199,21 +199,18 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
echo "Populated $EMBED_DIR/ for //go:embed"
fi
# The unified tables renderer ships both table-mode and form-mode in
# one HTML — see tables/template.html and tables/js/mode.js. The Go
# server embeds a single tables.html (//go:embed in tablehandler.go);
# 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.
# The form renderer lives next to its handler (no cascade needed — it's a
# fixed renderer, not a per-folder-override tool).
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"
if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
# 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
@ -978,10 +975,10 @@ if [ "$RELEASE_CHANNEL" = "stable" ] || [ "$RELEASE_CHANNEL" = "beta" ]; then
# Stage the artifacts that are part of the release. dist/ is
# gitignored everywhere — none of the tools' dist/<tool>.html files
# are tracked. The release commit only carries the bake-in artifacts
# that the binary needs at //go:embed time + the unified form/tables
# template (form-mode is hosted by tables.html via the zddcMode
# dispatcher; there is no separate form.html //go:embed target).
# that the binary needs at //go:embed time + the embedded form +
# tables templates.
git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \
"$SCRIPT_DIR/zddc/internal/handler/form.html" \
"$SCRIPT_DIR/zddc/internal/handler/tables.html"
if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then

View file

@ -19,11 +19,7 @@ trap cleanup EXIT
# CSS files to concatenate in order
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
"css/spreadsheet.css" \
@ -37,15 +33,10 @@ concat_files \
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/vendor/xlsx.full.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \

View file

@ -28,5 +28,38 @@
cursor: pointer;
}
/* Toast notifications come from shared/toast.css (.zddc-toast); the
classifier-local .toast block was promoted there. */
/* ── Toast notifications (classifier-only) ───────────────────────────────── */
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
animation: zddc-toast-in 0.3s ease-out;
}
.toast-success { border-left: 4px solid var(--success); }
.toast-error { border-left: 4px solid var(--danger); }
.toast-info { border-left: 4px solid var(--info); }
.toast-warning { border-left: 4px solid var(--warning); }
.toast-fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}

View file

@ -1,8 +1,46 @@
/* Classifier layout — tokens from shared/base.css */
/* .empty-state / .empty-state__inner / .welcome-list live in
shared/base.css. Classifier keeps the .drag-over modifier locally
because it's the only tool whose empty state is a drop target. */
/* Empty State — positioned below the app header */
.empty-state {
position: absolute;
top: 50px; /* clear the header */
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 10;
}
.empty-state-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
.empty-state-content h2 {
color: var(--text);
margin-bottom: 1rem;
}
.empty-state-content p {
margin-bottom: 1rem;
color: var(--text-muted);
}
.empty-state-content .note {
font-size: 0.85rem;
font-style: italic;
}
.welcome-list {
text-align: left;
margin: 0.5rem auto;
max-width: 400px;
}
.empty-state.drag-over {
background: var(--primary-light);
outline: 2px dashed var(--primary);
@ -43,6 +81,13 @@
padding: 0.5rem 1rem;
}
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-divider {
color: var(--border);
margin: 0 0.25rem;

View file

@ -411,16 +411,14 @@
color: var(--danger);
}
/* Inline tree-empty placeholder shown by tree.js when the folder
list is empty. Distinct from the top-level .empty-state overlay
(shared/base.css) which is the welcome screen. */
.tree-empty {
/* Empty State */
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.tree-empty h3 {
.empty-state h3 {
margin-bottom: 0.5rem;
}

View file

@ -6,19 +6,26 @@
'use strict';
/**
* Thin wrapper over the shared toast helper. Keeps the
* window.app.modules.excel.showToast call sites in classifier
* unchanged while delegating to the canonical implementation in
* shared/toast.js (window.zddc.toast).
* Show toast notification
*/
function showToast(message, type = 'info') {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(message, type);
} else {
// shared/toast.js missing from the build — log so the
// problem is visible without crashing the caller.
console.warn('[classifier] window.zddc.toast unavailable;', type, message);
// Remove existing toast
const existing = document.querySelector('.toast');
if (existing) {
existing.remove();
}
// Create toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.add('toast-fade');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
/**

View file

@ -403,8 +403,8 @@
if (!container) return;
try {
// XLSX bundled into the dist HTML; window.XLSX is available
// synchronously, no runtime load needed.
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });

View file

@ -13,7 +13,7 @@
container.innerHTML = '';
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>';
container.innerHTML = '<div class="empty-state">No folders found</div>';
return;
}

View file

@ -129,15 +129,9 @@
</div>
<!-- Empty State — shown until a directory is selected -->
<div id="welcomeScreen" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<div id="welcomeScreen" class="empty-state">
<div class="empty-state-content">
<h2>ZDDC Classifier</h2>
<p style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;">
<strong>This standalone tool is being absorbed into the Browse app.</strong>
Browse's <em>Grid</em> view-mode now provides the same spreadsheet
workflow alongside file navigation. This standalone build remains
available for offline use and air-gapped environments.
</p>
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>

View file

@ -18,19 +18,12 @@ cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/form.css" \
> "$css_temp"
concat_files \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"js/app.js" \
"js/context.js" \

View file

@ -1,8 +1,5 @@
/* form/ ZDDC generic form renderer.
Form-specific layout only; theme tokens (--primary, --bg, --text,
--border, --bg-secondary, --text-muted, --font-mono, --radius) come
from shared/base.css. Button styles (.btn, .btn-primary,
.btn-secondary, .btn-sm) likewise inherit from shared. */
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
.form-main {
max-width: 800px;
@ -13,20 +10,20 @@
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius);
border: 1px solid var(--border);
border-radius: 4px;
border: 1px solid var(--color-border);
}
.form-status.is-error {
background: var(--bg-secondary);
border-color: var(--danger);
color: var(--danger);
background: var(--color-bg-alt);
border-color: #c43;
color: #c43;
}
.form-status.is-success {
background: var(--bg-secondary);
border-color: var(--success);
color: var(--success);
background: var(--color-bg-alt);
border-color: #283;
color: #283;
}
.form-root {
@ -47,24 +44,24 @@
}
.form-field__label .required-mark {
color: var(--danger);
color: #c43;
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--text-muted);
color: var(--color-text-muted, #666);
}
.form-field__error {
font-size: 0.85rem;
color: var(--danger);
color: #c43;
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--text-muted);
color: var(--color-text-muted, #666);
font-style: italic;
}
@ -72,10 +69,10 @@
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
width: 100%;
box-sizing: border-box;
@ -89,14 +86,14 @@
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--primary);
outline: 2px solid var(--color-primary, #1e3a5f);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: var(--danger);
border-color: #c43;
}
.form-field__radio-group,
@ -116,8 +113,8 @@
}
.form-fieldset {
border: 1px solid var(--border);
border-radius: var(--radius);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
@ -139,10 +136,10 @@
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--border);
border-radius: var(--radius);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
background: var(--bg-secondary);
background: var(--color-bg-alt, #f6f6f8);
}
.form-array__row-body {
@ -168,31 +165,36 @@
gap: 0.5rem;
}
/* Standalone welcome shown when form.html is opened directly (no
server-injected #form-context). */
.form-welcome {
max-width: 36rem;
margin: 2rem auto;
padding: 1.5rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-bg, #fff);
color: var(--color-text, #111);
cursor: pointer;
font: inherit;
}
.form-welcome h2 {
margin-bottom: 0.5rem;
font-size: 1.25rem;
.btn:hover {
background: var(--color-bg-alt, #f6f6f8);
}
.form-welcome h3 {
margin: 1rem 0 0.35rem;
font-size: 0.95rem;
.btn-primary {
background: var(--color-primary, #1e3a5f);
color: #fff;
border-color: var(--color-primary, #1e3a5f);
}
.form-welcome p { margin-bottom: 0.75rem; line-height: 1.5; }
.form-welcome ol { margin: 0 0 0.75rem 1.25rem; }
.form-welcome li { margin-bottom: 0.35rem; }
.form-welcome code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg-secondary);
padding: 0.05em 0.3em;
border-radius: 3px;
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-small {
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -54,7 +54,7 @@
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-sm btn-secondary',
className: 'btn btn-small',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
@ -85,7 +85,7 @@
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-sm btn-secondary form-array__add',
className: 'btn btn-small form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);

View file

@ -1,54 +1,11 @@
(function (app) {
'use strict';
// Friendly empty-state shown when the form is opened standalone
// (file:// or otherwise without a server-injected #form-context
// payload). The form renderer is always driven by the host —
// zddc-server's form handler injects schema+ui+data; the tool has
// no client-side picker because there's nothing it could pick from
// outside that contract.
function renderStandaloneWelcome(root) {
if (!root) return;
root.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'form-welcome';
wrap.innerHTML = [
'<h2>ZDDC Form Renderer</h2>',
'<p>This tool renders a form spec injected by <code>zddc-server</code>',
' at <code>&lt;name&gt;.form.html</code> URLs. There is no schema',
' to render here — most likely you opened the standalone HTML directly.</p>',
'<h3>To use it</h3>',
'<ol>',
'<li>Run <code>zddc-server</code> against an archive that contains a',
' <code>&lt;name&gt;.form.yaml</code> spec.</li>',
'<li>Visit <code>&lt;path&gt;/&lt;name&gt;.form.html</code> in the browser.</li>',
'</ol>',
'<p>See <a href="https://zddc.varasys.io/reference.html" target="_blank" rel="noopener">',
'zddc.varasys.io/reference.html</a> for the full ZDDC reference.</p>'
].join('');
root.appendChild(wrap);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.hidden = true;
}
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();
if (app.context.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');
const t = document.getElementById('form-title');
if (t) {
t.textContent = app.context.title;
}
@ -63,12 +20,6 @@
app.context.ui || {},
app.context.data
);
} else if (root) {
// No schema — server-injected context is empty. Most common
// when the standalone form.html is opened from file:// without
// a host. Show a friendly explanation instead of a blank page.
renderStandaloneWelcome(root);
return;
}
if (app.context.errors && app.context.errors.length) {

View file

@ -1,7 +1,7 @@
# Helm charts
Three example charts for deploying [zddc-server](../zddc/) on Kubernetes.
All compile zddc-server from source via an init container — no
Two example charts for deploying [zddc-server](../zddc/) on Kubernetes.
Both compile zddc-server from source via an init container — no
container image needs to be pulled from a registry, and no binary needs
to be built ahead of time. The init container clones the repo at a
configured git ref and runs `go build`; the main container is plain
@ -11,24 +11,16 @@ alpine + the freshly built static binary.
| Chart | When to use |
|---|---|
| **`zddc-server-prod/`** | Production **master**. Pin `zddc.gitRef` to a stable tag (`zddc-server-vX.Y.Z`). Slower probe cadence; image-pull policy `IfNotPresent`. Mounts the data PVC directly RW at `ZDDC_ROOT`. The token system is enabled automatically (tokens persist on the data PVC at `<ZDDC_ROOT>/.zddc.d/tokens/`); operators visit `/.tokens` to issue them. |
| **`zddc-server-dev/`** | Development / soak **master**. Tracks `main` by default; `helm upgrade` triggers a pod recreate so each rollout pulls the latest commit. Faster probes; debug-level logging (request headers logged — sensitive). Wraps the data PVC in **OverlayFS** (lower = PVC mounted RO, upper = ephemeral `emptyDir`) so dev-side writes never mutate the underlying store. Use this shape when the dev replica points at the same data as prod. |
| **`zddc-server-cache/`** | Downstream **client** (proxy / cache / mirror) of an upstream master. Set `zddc.upstream.url` + `zddc.upstream.mode`; the binary skips master-side machinery and forwards all requests to the master, persisting responses under the cache PVC (in cache or mirror modes). Bearer auth via a separately-created Kubernetes Secret. Use cases: corporate-master → DR-mirror, vendor-scoped mirror in a vendor's own cluster, regional edge cache, dev environment that mirrors prod read-only. Mirror mode adds an access-triggered subtree walker. |
| **`zddc-server-prod/`** | Production. Pin `zddc.gitRef` to a stable tag (`zddc-server-vX.Y.Z`). Slower probe cadence; image-pull policy `IfNotPresent`. Mounts the data PVC directly RW at `ZDDC_ROOT`. |
| **`zddc-server-dev/`** | Development / soak. Tracks `main` by default; `helm upgrade` triggers a pod recreate so each rollout pulls the latest commit. Faster probes; debug-level logging (request headers logged — sensitive). Wraps the data PVC in **OverlayFS** (lower = PVC mounted RO, upper = ephemeral `emptyDir`) so dev-side writes never mutate the underlying store. Use this shape when the dev replica points at the same data as prod. |
The prod and dev chart values are nearly identical; the differences
The chart values are nearly identical between the two; the differences
are encoded as defaults in each chart's `values.yaml.example`. The
dev chart's overlay-isolation layer is a structural difference, not a
values-level toggle — see `zddc-server-dev/templates/deployment.yaml`
for the privileged init container and the `data-readonly` /
`overlay-scratch` / `data` volume sandwich.
The cache chart shares the same source-build pattern but adds
client-mode env wiring (`ZDDC_UPSTREAM`, `ZDDC_MODE`, `ZDDC_BEARER_FILE`,
`ZDDC_NO_AUTH`, `ZDDC_SKIP_TLS_VERIFY`, mirror-mode subtree config),
a Recreate strategy (single-instance — multiple replicas would race
the cache directory), and TCP-socket probes (HTTP probes against `/`
would fail when both upstream is down AND the cache is empty).
## Quick start
```sh
@ -56,30 +48,6 @@ helm install zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml
# Trigger a rebuild from latest main HEAD (dev chart)
helm upgrade zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml
# Cache install (downstream client of an upstream master)
#
# 1) Issue a bearer token on the master at https://<master>/.tokens
# 2) Create the Secret (do NOT put the token in values.yaml):
kubectl create secret generic zddc-cache-bearer \
--from-literal=token=<paste-token-here>
# 3) Create a cache PVC (separate from the master's data PVC; can
# be smaller — sized to the working set you expect to mirror):
kubectl apply -f - <<'PVC'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: zddc-cache }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 50Gi } }
storageClassName: your-block-storage
PVC
# 4) Install the chart, pointing at your master:
cp helm/zddc-server-cache/values.yaml.example my-cache-values.yaml
$EDITOR my-cache-values.yaml # set zddc.upstream.url, mode, etc.
helm install zddc-server-cache helm/zddc-server-cache/ -f my-cache-values.yaml
```
## What the chart does and doesn't do
@ -139,12 +107,8 @@ for tracking-main convenience).
```sh
helm lint helm/zddc-server-prod/
helm lint helm/zddc-server-dev/
helm lint helm/zddc-server-cache/
# Render to inspect (uses default values from values.yaml.example):
helm template test-prod helm/zddc-server-prod/ \
--values helm/zddc-server-prod/values.yaml.example
helm template test-cache helm/zddc-server-cache/ \
--values helm/zddc-server-cache/values.yaml.example
```

View file

@ -1,32 +0,0 @@
apiVersion: v2
name: zddc-server-cache
description: |
Downstream cache / mirror deployment of zddc-server. Compiles from
source via an init container at deploy time (no image pull from a
registry); the main container is alpine + the freshly-built binary.
Runs in client mode against an upstream zddc-server master, caching
every accessed file (and, in mirror mode, proactively walking
configured subtrees).
Use cases: corporate-master → DR-mirror, vendor-scoped mirror in a
vendor's own cluster, regional edge cache, dev/staging environment
that mirrors prod. Distinct from `zddc-server-prod` (which IS a
master) and `zddc-server-dev` (a master with overlay isolation).
TLS upstream is verified by default (set --skip-tls-verify only for
self-signed dev masters or internal CAs you haven't yet added to
the trust store).
type: application
version: 0.1.0
appVersion: "0.0.7" # zddc-server git tag this chart was last verified against
home: https://zddc.varasys.io/
sources:
- https://codeberg.org/VARASYS/ZDDC
maintainers:
- name: VARASYS
keywords:
- zddc
- cache
- mirror
- file-server
- document-control

View file

@ -1,33 +0,0 @@
{{/*
Common labels and the fullname helper. Stays minimal; chart consumers
who want richer labels can override via metadata.labels in their
values.yaml or post-render kustomize.
*/}}
{{- define "zddc-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "zddc-server.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "zddc-server.labels" -}}
app.kubernetes.io/name: {{ include "zddc-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.zddc.gitRef | quote }}
app.kubernetes.io/component: cache
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- end -}}
{{- define "zddc-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "zddc-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: cache
{{- end -}}

View file

@ -1,162 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "zddc-server.fullname" . }}
labels:
{{- include "zddc-server.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
# Cache writes serialize through the local filesystem; running two
# replicas would race the cache directory + double the upstream
# walker traffic. Recreate strategy ensures only one pod holds the
# cache PVC at a time.
strategy:
type: Recreate
selector:
matchLabels:
{{- include "zddc-server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "zddc-server.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: zddc-bin
emptyDir: {}
- name: data
persistentVolumeClaim:
claimName: {{ .Values.data.pvcName }}
{{- if .Values.bearer.secretName }}
- name: bearer
secret:
secretName: {{ .Values.bearer.secretName | quote }}
defaultMode: 0400
items:
- key: {{ .Values.bearer.secretKey | quote }}
path: token
{{- end }}
initContainers:
# Build zddc-server from the pinned git ref. Same flow as the
# master charts — the binary is the same; client mode is
# selected at runtime via ZDDC_UPSTREAM.
- name: build-zddc-server
image: {{ printf "%s:%s" .Values.buildImage.repository .Values.buildImage.tag | quote }}
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -eu
apk add --no-cache git
git clone --depth 1 --branch "$GIT_REF" "$GIT_REPO" /workspace
cd /workspace/zddc
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-ldflags="-s -w -X main.version=$GIT_REF" \
-o /out/zddc-server \
./cmd/zddc-server
echo "built /out/zddc-server from $GIT_REF"
env:
- name: GIT_REPO
value: {{ .Values.zddc.gitRepo | quote }}
- name: GIT_REF
value: {{ .Values.zddc.gitRef | quote }}
volumeMounts:
- name: zddc-bin
mountPath: /out
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
containers:
- name: zddc-server
image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }}
imagePullPolicy: IfNotPresent
command: ["/zddc/zddc-server"]
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: ZDDC_ROOT
value: {{ .Values.zddc.env.rootPath | quote }}
- name: ZDDC_ADDR
value: {{ .Values.zddc.env.addr | quote }}
- name: ZDDC_TLS_CERT
value: "none"
- name: ZDDC_INSECURE_DIRECT
value: "1"
- name: ZDDC_EMAIL_HEADER
value: {{ .Values.zddc.env.emailHeader | quote }}
- name: ZDDC_CORS_ORIGIN
value: {{ .Values.zddc.env.corsOrigin | quote }}
- name: ZDDC_LOG_LEVEL
value: {{ .Values.zddc.env.logLevel | quote }}
- name: ZDDC_INDEX_PATH
value: {{ .Values.zddc.env.indexPath | quote }}
{{- if .Values.zddc.env.noAuth }}
- name: ZDDC_NO_AUTH
value: "1"
{{- end }}
# Client-mode flags. ZDDC_UPSTREAM activates client mode
# in cmd/zddc-server/main.go's runClient short-circuit.
- name: ZDDC_UPSTREAM
value: {{ .Values.zddc.upstream.url | quote }}
- name: ZDDC_MODE
value: {{ .Values.zddc.upstream.mode | quote }}
{{- if .Values.zddc.upstream.skipTLSVerify }}
- name: ZDDC_SKIP_TLS_VERIFY
value: "1"
{{- end }}
{{- if .Values.bearer.secretName }}
- name: ZDDC_BEARER_FILE
value: "/etc/zddc/bearer/token"
{{- end }}
{{- if eq .Values.zddc.upstream.mode "mirror" }}
{{- with .Values.zddc.upstream.mirrorSubtree }}
- name: ZDDC_MIRROR_SUBTREE
value: {{ . | quote }}
{{- end }}
{{- with .Values.zddc.upstream.mirrorMinInterval }}
- name: ZDDC_MIRROR_MIN_INTERVAL
value: {{ . | quote }}
{{- end }}
{{- end }}
volumeMounts:
- name: zddc-bin
mountPath: /zddc
- name: data
mountPath: {{ .Values.zddc.env.rootPath }}
{{- with .Values.data.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- if .Values.bearer.secretName }}
- name: bearer
mountPath: /etc/zddc/bearer
readOnly: true
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
# TCP-socket probes only — HTTP probes against `/` would
# fail when both upstream is unreachable AND the cache is
# empty (the cache layer returns 503 in that state). TCP
# probes verify the server process is alive without
# depending on upstream reachability or cache contents.
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 5
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 2
periodSeconds: 10
timeoutSeconds: 3

View file

@ -1,29 +0,0 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "zddc-server.fullname" . }}
labels:
{{- include "zddc-server.labels" . | nindent 4 }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "zddc-server.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}

View file

@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "zddc-server.fullname" . }}
labels:
{{- include "zddc-server.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
selector:
{{- include "zddc-server.selectorLabels" . | nindent 4 }}

View file

@ -1,159 +0,0 @@
# values.yaml.example — zddc-server-cache
#
# Copy to values.yaml (or pass via --values) and customize for your
# environment. Contains NO secrets — the upstream bearer token MUST be
# provided via a separately-created Kubernetes Secret (see `bearer:`
# below). Do not paste the token value here.
# Source-build configuration. The init container clones the repo at
# `gitRef` and compiles cmd/zddc-server. Pin gitRef to a stable tag
# (zddc-server-vX.Y.Z) for production caches; tracking main is fine
# for dev mirrors.
zddc:
gitRepo: https://codeberg.org/VARASYS/ZDDC.git
gitRef: zddc-server-v0.0.7 # pin to a stable tag
# ZDDC environment-variable contract — see zddc/README.md "Client mode".
env:
# Local cache directory (mounted from the cache PVC; see `data:`
# below). The cache layer writes files here as they're fetched.
rootPath: /srv
# Listening address for incoming requests to this cache instance.
# Plain HTTP — ingress / mesh terminates TLS upstream of the pod.
#
# NOTE: in client mode the binary refuses to start with a non-
# loopback bind AND a configured bearer UNLESS ZDDC_INSECURE_DIRECT=1
# is also set. The cache forwards the bearer to upstream without
# authenticating the local caller, so a bare bind would be an open
# proxy. The chart's deployment.yaml sets ZDDC_INSECURE_DIRECT=1
# and relies on the Kubernetes-namespaced pod network + ingress
# auth proxy for that gating. If you remove either you must
# redirect the bind to 127.0.0.1.
addr: ":8080"
# Email-header convention from your authenticating reverse proxy.
# Used for AccessLog only in client mode (auth flows to upstream
# as a bearer; the cache layer doesn't enforce ACL locally when
# noAuth: true).
emailHeader: X-Auth-Request-Email
# CORS allowlist for the local instance. Same semantics as the
# master chart — empty disables CORS, which is the right default
# for embedded-tools / same-origin browsing.
corsOrigin: ""
# info / warn / error / debug.
logLevel: info
indexPath: ".archive"
# Skip ACL enforcement on incoming requests. Almost always true
# for a personal/field-engineer cache (the laptop is single-user-
# trust and the upstream master already filtered). Set to false
# only if you've put your own auth proxy in front of this cache
# AND want it to re-evaluate ACLs against cached `.zddc` files.
noAuth: true
# Upstream master configuration.
upstream:
# The master URL. Required. Don't include a trailing slash.
url: "https://zddc.example.com"
# proxy / cache / mirror.
# proxy — forward live, no disk persistence
# cache — persist responses on access (default; field-engineer use)
# mirror — cache + access-triggered subtree warmer (vendor /
# backup / complete-offline use)
mode: cache
# Accept self-signed / untrusted upstream TLS certs. Distinct from
# noAuth. Use only for dev masters with self-signed certs or for
# internal CAs your cluster's trust store doesn't yet have.
skipTLSVerify: false
# Mirror-mode only. Comma-separated URL subtrees the access-
# triggered walker keeps current. Empty + mode=mirror = full
# mirror ("/"). Ignored when mode != mirror.
mirrorSubtree: ""
# Mirror-mode only. Min gap between walks of the same subtree.
# Idle subtrees generate zero upstream traffic until next access.
# Default 1h.
mirrorMinInterval: 1h
# Bearer token — required when the upstream master enforces auth.
# Create a Secret separately (do NOT paste the token here):
#
# 1. On the master, sign in via your auth proxy and visit
# https://<master>/.tokens to issue a token.
# 2. Wrap it in a Kubernetes Secret:
#
# kubectl create secret generic zddc-cache-bearer \
# --from-literal=token=<paste-token-here>
#
# 3. Reference the Secret here.
#
# Set `secretName: ""` to disable bearer auth (only valid when the
# upstream is `--no-auth` or behind your own auth proxy that doesn't
# require bearer auth from internal callers).
bearer:
secretName: zddc-cache-bearer
secretKey: token
# Cache-storage PVC. Sized for the working set you expect to mirror —
# can be smaller than the master's data volume since only accessed
# files (or, in mirror mode, files under configured subtrees) get
# cached. Operators provision the PVC themselves; this chart only
# references it by name. ReadWriteOnce is fine — the cache is single-
# instance by design.
data:
pvcName: zddc-cache # name of an existing PersistentVolumeClaim
subPath: ""
# Service exposure. The cache listens on a plain HTTP port; ingress
# (or mesh sidecar) terminates TLS and forwards to this service.
service:
type: ClusterIP
port: 8080
# Ingress is optional — disabled by default since most cache
# deployments wire into an existing ingress / auth-proxy stack.
ingress:
enabled: false
className: ""
host: zddc-cache.example.com
tls:
enabled: false
secretName: zddc-cache-tls
# Pod resource limits. Cache instances are mostly I/O bound; the
# defaults below suit a small mirror (~1k files in working set).
# Bump cpu/memory for mirror mode against larger trees.
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
# Replicas. Cache instances are single-instance by design — multiple
# replicas would race on writes to the same cache directory and
# duplicate the upstream walker traffic. Use a separate cache
# deployment per region/tenant if you need fan-out.
replicaCount: 1
# Build-stage Go image (init container).
buildImage:
repository: docker.io/golang
tag: 1.24-alpine
# Runtime image (main container).
runtimeImage:
repository: docker.io/alpine
tag: "3.19"
# Image pull credentials, if your registry requires them.
imagePullSecrets: []
# - name: regcred

View file

@ -147,10 +147,6 @@ spec:
value: {{ .Values.zddc.env.logLevel | quote }}
- name: ZDDC_INDEX_PATH
value: {{ .Values.zddc.env.indexPath | quote }}
{{- if .Values.zddc.env.noAuth }}
- name: ZDDC_NO_AUTH
value: "1"
{{- end }}
volumeMounts:
- name: zddc-bin
mountPath: /zddc

View file

@ -30,15 +30,6 @@ zddc:
logLevel: debug # full request headers logged; sensitive!
indexPath: ".archive"
# Skip ACL enforcement entirely. Useful in trusted-LAN dev clusters
# where authentication isn't needed and you want to iterate without
# configuring an upstream auth proxy. Default false.
noAuth: false
# Token system: enabled automatically — tokens persist at
# <ZDDC_ROOT>/.zddc.d/tokens/ on the data PVC. Sign in via your
# cluster's auth proxy and visit /.tokens to issue one.
data:
pvcName: zddc-root-dev # name of an existing PVC in your dev namespace
subPath: ""

View file

@ -86,10 +86,6 @@ spec:
value: {{ .Values.zddc.env.logLevel | quote }}
- name: ZDDC_INDEX_PATH
value: {{ .Values.zddc.env.indexPath | quote }}
{{- if .Values.zddc.env.noAuth }}
- name: ZDDC_NO_AUTH
value: "1"
{{- end }}
volumeMounts:
- name: zddc-bin
mountPath: /zddc

View file

@ -44,23 +44,6 @@ zddc:
# collision with a real directory named ".archive".
indexPath: ".archive"
# Skip ACL enforcement entirely on this instance. Anyone hitting
# the port reads everything in scope. Only enable for genuinely-
# public archives (and even then, only behind an authenticating
# ingress that doesn't gate on identity for /). Distinct from
# --insecure (which gates the startup check requiring a root .zddc).
# Default false.
noAuth: false
# Bearer-token system. Master automatically self-issues tokens via
# /.tokens (browser) and /.api/tokens (JSON). The token store lives
# at <ZDDC_ROOT>/.zddc.d/tokens/<sha256> on the data PVC; no Helm
# configuration required. Operators sign in via the upstream auth
# proxy, visit /.tokens, copy the displayed token into a 0600 file,
# and pass --bearer-file to any CLI / cache / mirror that needs to
# authenticate against this master. See zddc/README.md "Bearer
# tokens" for the full lifecycle.
# Persistent storage for ZDDC_ROOT. Operators provide their own PVC,
# typically backed by a shared filesystem (NFS, CephFS, SMB) so multiple
# replicas of zddc-server (and your sync tooling) see the same tree.

View file

@ -18,11 +18,7 @@ cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/landing.css" \
> "$css_temp"
@ -30,9 +26,6 @@ concat_files \
"../shared/zddc.js" \
"../shared/zddc-filter.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"js/landing.js" \
> "$js_raw"

View file

@ -342,179 +342,3 @@ body {
text-align: center;
color: var(--text-muted);
}
/* ── Project mode ──────────────────────────────────────────────────────── */
/* Activated when location.pathname is a single project segment (e.g.
/Project-1). Picker UI is hidden; this block surfaces the four
lifecycle-stage cards and MDL editing instructions. */
.project-title {
font-size: 1.6rem;
margin: 0 0 0.25rem;
font-weight: 600;
}
.project-title__subtle {
color: var(--text-muted);
font-weight: normal;
font-size: 0.9rem;
}
.lead {
color: var(--text-muted);
margin: 0.25rem 0 1.5rem;
}
.stages {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
margin: 1rem 0 1.5rem;
}
.stage-card {
display: block;
padding: 1rem 1.1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
cursor: pointer;
}
.stage-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stage-card:active {
transform: translateY(1px);
}
.stage-card h3 {
margin: 0 0 0.3rem;
font-size: 1rem;
color: var(--primary);
font-weight: 600;
}
.stage-card p {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
}
/* MDL card variant: same outer styling as a stage card but contains
an interactive control (party <select> + Open button) instead of
navigating on click of the whole card. The :hover lift applies
regardless. */
.stage-card--mdl {
cursor: default;
}
.stage-card--mdl:active {
transform: none;
}
.stage-card__action {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.65rem;
}
.mdl-party-select {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.5rem;
font-family: var(--font);
font-size: 0.9rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
color: var(--text);
}
.mdl-party-select:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 2px rgba(95, 168, 224, 0.25);
}
.mdl-party-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.stage-card__hint {
margin: 0.65rem 0 0 !important;
font-size: 0.78rem !important;
color: var(--text-muted) !important;
line-height: 1.4;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.browse-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.browse-link:hover {
text-decoration: underline;
}
#projectView ol {
padding-left: 1.5rem;
}
#projectView ol li {
margin-bottom: 0.4rem;
}
#projectView code {
font-family: var(--font-mono);
background: var(--bg-secondary);
padding: 0.1em 0.35em;
border-radius: 3px;
font-size: 0.86em;
}
#projectView h2 {
font-size: 1.1rem;
margin: 2.25rem 0 0.5rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.party-list {
padding-left: 1.5rem;
margin: 0.4rem 0 1rem;
}
.party-list li {
margin-bottom: 0.25rem;
}
.party-list a {
color: var(--primary);
text-decoration: none;
}
.party-list a:hover {
text-decoration: underline;
}
.party-list-none-yet {
color: var(--text-muted);
font-style: italic;
}

View file

@ -429,22 +429,8 @@
function openArchiveWith(names) {
if (!names || names.length === 0) return;
var base = location.pathname.replace(/\/[^\/]*$/, '/');
var v = new URLSearchParams(location.search).get('v');
if (names.length === 1) {
// Single project → canonical project-subtree URL so the user
// can edit the address bar to swap archive.html for
// working/, staging/, reviewing/, etc. zddc-server's
// availability.go auto-serves the right tool at each.
// Multi-project (the `else` branch) keeps the ?projects=
// form because there's no single subtree root.
var url = base + encodeURIComponent(names[0]) + '/archive.html';
if (v) url += '?v=' + encodeURIComponent(v);
navigate(url);
return;
}
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
var v = new URLSearchParams(location.search).get('v');
if (v) params.push('v=' + encodeURIComponent(v));
navigate(base + 'archive.html?' + params.join('&'));
}
@ -612,214 +598,9 @@
catch (e) { /* private mode / quota */ }
}
// ── Project mode ─────────────────────────────────────────────────────────
//
// The same landing tool serves at /<project> as the project-workspace
// page. Mode is determined from location.pathname:
//
// / → 'picker' (existing behavior)
// /<single-segment> → 'project'
// /index.html → 'picker' (file:// + standalone-served root)
// anything else → 'picker' (best-effort fallback)
//
// Project mode shows the four canonical lifecycle-stage cards, a
// "browse all files" link, and a Master Deliverables List section
// with direct links to any parties currently in archive/. The party
// list is fetched from <project>/<archive>/?json=1; failures fall
// back to the static "no parties yet" copy.
function detectMode() {
if (typeof location === 'undefined') return 'picker';
var path = location.pathname || '/';
// Strip any trailing /index.html so the deployment-root case
// matches even on file:// or behind some servers.
var trimmed = path.replace(/\/index\.html$/, '/');
if (trimmed === '' || trimmed === '/') return 'picker';
// Single non-slash, non-dot segment → project root.
var parts = trimmed.split('/').filter(Boolean);
if (parts.length === 1 && parts[0].indexOf('.') === -1) {
return 'project';
}
return 'picker';
}
function projectFromPath() {
var parts = (location.pathname || '/').split('/').filter(Boolean);
return parts[0] || '';
}
// Render the project-workspace view: title, four stage links, MDL
// section. Stage hrefs use the no-trailing-slash form so the server
// routes them to each canonical default tool (mdedit for working/,
// transmittal for staging/, etc.). Browse-all and the archive deep
// link use the slash form to land on the directory listing.
async function renderProjectMode() {
var project = projectFromPath();
if (!project) return;
// Hide picker, show project view.
var picker = document.getElementById('pickerView');
var projectView = document.getElementById('projectView');
if (picker) picker.classList.add('hidden');
if (projectView) projectView.classList.remove('hidden');
document.title = project + ' — ZDDC';
var titleEl = document.getElementById('projectName');
if (titleEl) titleEl.textContent = project;
var p = encodeURIComponent(project);
var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' },
{ id: 'stageWorking', href: '/' + p + '/working' },
{ id: 'stageStaging', href: '/' + p + '/staging' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
];
for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id);
if (a) a.setAttribute('href', stages[i].href);
}
var browseAll = document.getElementById('browseAllLink');
if (browseAll) {
browseAll.setAttribute('href', '/' + p + '/');
browseAll.textContent = 'Browse all files →';
}
// MDL card. Same shape as the stage cards above, but
// interactive: a <select> populated with party folders and an
// Open button that opens the chosen party's MDL. The view
// auto-renders at any archive/<party>/mdl/ URL even when the
// folder doesn't exist on disk (zddc-server commit 3fc3717),
// so we offer the operator-supplied party list directly AND
// a "type a new party name" affordance via a free-text last
// option.
var mdlSelect = document.getElementById('mdlPartySelect');
var mdlOpenBtn = document.getElementById('mdlOpenBtn');
var mdlHint = document.getElementById('mdlHint');
if (!mdlSelect || !mdlOpenBtn) return;
// Wire the Open button regardless of fetch outcome — even if
// party enumeration fails, an operator can still navigate by
// typing the party folder name in the URL bar.
function openSelectedMdl() {
var party = mdlSelect.value;
if (!party) return;
// No trailing slash: per the convention, the no-slash form
// serves the tables tool with the MDL view. The slash form
// would serve browse, which is not what the user wants when
// they click "Open MDL".
var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl';
window.location.assign(url);
}
mdlOpenBtn.addEventListener('click', openSelectedMdl);
mdlSelect.addEventListener('change', function () {
mdlOpenBtn.disabled = !mdlSelect.value;
});
// Enter inside the select also opens.
mdlSelect.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
openSelectedMdl();
}
});
var parties = await fetchParties(p);
// Repopulate the select. mdlSelect starts with a single
// "Loading…" option; replace its contents either way.
mdlSelect.innerHTML = '';
if (parties == null) {
// Network error or unauthenticated. Leave the select
// disabled but visible; user can still navigate via URL.
var optErr = document.createElement('option');
optErr.value = '';
optErr.textContent = '(could not enumerate parties)';
mdlSelect.appendChild(optErr);
mdlSelect.disabled = true;
mdlOpenBtn.disabled = true;
return;
}
if (parties.length === 0) {
// No parties yet, but per the hint the URL still works for
// any party name. Give a placeholder option that disables
// Open until the user has typed something — except we
// don't have a text input. So just say "(none yet)" and
// disable. Operator can still navigate via the URL bar.
var optNone = document.createElement('option');
optNone.value = '';
optNone.textContent = '(no party folders yet)';
mdlSelect.appendChild(optNone);
mdlSelect.disabled = true;
mdlOpenBtn.disabled = true;
if (mdlHint) {
mdlHint.innerHTML =
'No <code>archive/&lt;party&gt;/</code> folders yet. The MDL view still '
+ 'auto-renders at any such URL, even before the folder exists — type a '
+ 'party name into the URL bar (or wait for the first transmittal) to start editing.';
}
return;
}
// Populate the select with each party.
var optPlaceholder = document.createElement('option');
optPlaceholder.value = '';
optPlaceholder.textContent = 'Choose a party…';
mdlSelect.appendChild(optPlaceholder);
for (var j = 0; j < parties.length; j++) {
var opt = document.createElement('option');
opt.value = parties[j].name;
opt.textContent = parties[j].name;
mdlSelect.appendChild(opt);
}
mdlSelect.disabled = false;
// Open button stays disabled until the user picks something.
mdlOpenBtn.disabled = true;
}
// Returns an array of {name, url} for each party folder in the
// project's archive/, sorted by name. Returns null if the listing
// can't be fetched (offline, 4xx, or non-JSON response). Returns
// [] if the listing succeeds but archive/ is empty / has no
// visible party folders.
async function fetchParties(projectURL) {
try {
var resp = await fetch('/' + projectURL + '/archive/', {
headers: { 'Accept': 'application/json' },
cache: 'no-cache',
credentials: 'same-origin'
});
if (!resp.ok) return null;
var ctype = resp.headers.get('Content-Type') || '';
if (!ctype.toLowerCase().includes('json')) return null;
var data = await resp.json();
if (!Array.isArray(data)) return null;
// Server emits directories with trailing "/" on the name.
// Filter to dirs only, strip the slash for display.
var out = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e.is_dir) continue;
var nm = String(e.name || '').replace(/\/$/, '');
if (!nm) continue;
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
out.push({ name: nm, url: e.url || ('/' + projectURL + '/archive/' + encodeURIComponent(nm) + '/') });
}
out.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
return out;
} catch (e) {
return null;
}
}
// ── Bootstrap ────────────────────────────────────────────────────────────
async function init() {
if (detectMode() === 'project') {
await renderProjectMode();
return;
}
await initPicker();
}
async function initPicker() {
loadGroups();
urlRestore();
@ -874,9 +655,6 @@
saveGroup: saveGroup,
openSelectedVisible: openSelectedVisible,
dismissWarning: dismissWarning,
// Project-mode entry points (also tested directly).
detectMode: detectMode,
renderProjectMode: renderProjectMode,
// Test-only: override the navigation function (avoids the messy
// browser-locked-down state of window.location).
_setNavigate: function(fn) { navigate = fn; }

View file

@ -31,9 +31,7 @@
</div>
</header>
<main id="landingMain" class="landing-main">
<!-- Picker mode (deployment root /). Project picker + groups. -->
<div id="pickerView">
<main class="landing-main">
<!-- Welcome / hero -->
<section class="landing-hero">
<h1>Welcome to the ZDDC Archive</h1>
@ -92,56 +90,6 @@
<div class="project-list-loading">Loading projects…</div>
</div>
</div>
</div><!-- /pickerView -->
<!-- Project mode (/<project>). Stage cards + MDL section. Shown
by landing.js when location.pathname is a single segment. -->
<div id="projectView" class="hidden">
<h1 id="projectTitle" class="project-title">
<span id="projectName"></span>
<span class="project-title__subtle">— project workspace</span>
</h1>
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
<div class="stages">
<a class="stage-card" id="stageArchive">
<h3>Archive</h3>
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
</a>
<a class="stage-card" id="stageWorking">
<h3>Working</h3>
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
</a>
<a class="stage-card" id="stageStaging">
<h3>Staging</h3>
<p>Outbound transmittals being prepared for issue.</p>
</a>
<a class="stage-card" id="stageReviewing">
<h3>Reviewing</h3>
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
</a>
<!-- MDL card. Visually matches the four stage cards above but
is interactive rather than a plain link: pick a party from
the select, then Open. -->
<div class="stage-card stage-card--mdl" id="stageMdl">
<h3>Master Deliverables List</h3>
<p>The editable list of expected deliverables for each counterparty.</p>
<div class="stage-card__action">
<label class="visually-hidden" for="mdlPartySelect">Party</label>
<select id="mdlPartySelect" class="mdl-party-select" disabled>
<option value="">Loading parties…</option>
</select>
<button id="mdlOpenBtn" class="btn btn-primary btn-sm" disabled>Open MDL</button>
</div>
<p class="stage-card__hint" id="mdlHint">
The MDL view renders even when <code>archive/&lt;party&gt;/mdl/</code> doesn't yet exist —
you can start editing before any transmittals have been exchanged.
</p>
</div>
</div>
<p><a id="browseAllLink" class="browse-link">Browse all files →</a></p>
</div><!-- /projectView -->
</main>
<!-- Help Panel -->

View file

@ -11,8 +11,8 @@ output_html="$output_dir/mdedit.html"
# Vendor files (bundled dependencies — no CDN required at runtime)
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
# a hand-written subset of only the utility classes used in template.html.
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
toastui_js="$root_dir/vendor/toastui-editor-all.min.js"
toastui_css="$root_dir/vendor/toastui-editor.min.css"
mkdir -p "$output_dir"
ensure_exists "$src_html"
@ -29,11 +29,7 @@ trap cleanup EXIT
# CSS files to concatenate in order
concat_files \
"css/tailwind-utils.css" \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/editor.css" \
"css/toc.css" \
@ -42,16 +38,9 @@ concat_files \
# JavaScript files to concatenate in order
concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/vendor/docx-preview.min.js" \
"../shared/vendor/xlsx.full.min.js" \
"../shared/vendor/utif.min.js" \
"../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \

View file

@ -221,6 +221,14 @@
font-style: italic;
}
/* ── App header layout ────────────────────────────────────────────────────── */
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ── Tailwind class overrides: use CSS tokens instead of hardcoded colours ── */
/* bg-white / bg-gray-100 are used on the pane backgrounds in template.html. */
/* Override them here so they follow the design-token system (light + dark). */
@ -395,11 +403,3 @@
justify-content: flex-end;
gap: 0.5rem;
}
/* File-nav pane: initial width + minimum size. Runtime resizer (resizer.js)
overrides via inline style.width when the user drags; the min-width here
is a defensive backstop. */
#file-nav {
width: 450px;
min-width: 200px;
}

View file

@ -217,9 +217,9 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
tocDepthSelector.addEventListener('change', function () {
const depth = parseInt(this.value);
if (editorInstance) {
if (window.updateToc && editorInstance) {
const currentContent = editorInstance.getMarkdown();
updateToc(currentContent, tocContainer, editorInstance, depth);
window.updateToc(currentContent, tocContainer, editorInstance, depth);
}
});
}
@ -266,16 +266,16 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
}
// Generate initial TOC
if (isMarkdown && tocContainer) {
if (isMarkdown && window.updateToc && tocContainer) {
try {
updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
window.updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
} catch (error) {
console.error('Error generating TOC:', error);
}
const debouncedUpdateToc = debounce(() => {
const currentContent = editorInstance.getMarkdown();
updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
window.updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
}, 300);
editorInstance.on('change', () => {

View file

@ -71,7 +71,7 @@ function setupTocDepthSelector() {
const content = instance.editor.getMarkdown();
try {
updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
window.updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC depth:', error);
}

View file

@ -471,9 +471,9 @@ async function reloadFileFromDisk(filePath) {
}
}, 100);
if (editorInstance.tocContainer) {
if (editorInstance.tocContainer && window.updateToc) {
try {
updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC during reload:', error);
}
@ -683,38 +683,9 @@ async function readServerDirectory(dirUrl, parentNode, depth) {
async function loadServerDirectory() {
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
// Compute the directory URL the file tree should be rooted at.
//
// <project>/working/ → root = <project>/working/
// <project>/working/x/y/ → root = <project>/working/x/y/
// <project>/working → root = <project>/working/ (no-slash
// canonical-folder URL — the dispatcher
// routes mdedit here directly without
// a redirect, so we infer "directory"
// from the absence of a `.` in the
// last segment rather than stripping
// back to the parent.)
// <project>/x/y/mdedit.html → root = <project>/x/y/ (the leaf
// segment IS a file; strip to parent.)
//
// The rule: if the last path segment contains a "." it's a file,
// strip it; otherwise treat the whole path as the directory.
let href = window.location.href.split('?')[0].split('#')[0];
let baseUrl;
if (href.endsWith('/')) {
baseUrl = href;
} else {
const lastSlash = href.lastIndexOf('/');
const lastSeg = lastSlash >= 0 ? href.substring(lastSlash + 1) : href;
if (lastSeg.indexOf('.') !== -1) {
// Looks like a file (has an extension) — strip to parent.
baseUrl = lastSlash >= 0 ? href.substring(0, lastSlash + 1) : href + '/';
} else {
// Looks like a directory — append the trailing slash so all
// subsequent listing URLs are computed correctly.
baseUrl = href + '/';
}
}
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
// Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails

View file

@ -630,8 +630,9 @@ async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModi
editorInstances.set(filePath, instanceData);
try {
// jszip + docx-preview bundled into the dist HTML; window.JSZip
// and window.docx are available synchronously.
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
const arrayBuffer = await file.arrayBuffer();
docxContainer.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, docxContainer);
@ -691,8 +692,8 @@ async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModi
editorInstances.set(filePath, instanceData);
try {
// XLSX bundled into the dist HTML; window.XLSX is available
// synchronously, no runtime load needed.
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });

View file

@ -250,5 +250,7 @@ function setActiveTocItem(tocContainer, headerText) {
}
}
// Reachable at top-level scope to other concatenated mdedit JS files via the
// build's flat-IIFE-less module pattern; no window.* exports needed.
// Export globally
window.updateToc = updateToc;
window.clearActiveTocItem = clearActiveTocItem;
window.setActiveTocItem = setActiveTocItem;

View file

@ -30,7 +30,7 @@
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</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>
@ -40,7 +40,7 @@
<main class="flex-1 overflow-hidden relative">
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav" style="width: 450px; min-width: 200px;">
<div class="pane-header flex flex-col px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 select-none">
<div class="flex justify-between items-center w-full">
<span>Files</span>
@ -59,13 +59,6 @@
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;border-radius:var(--radius);max-width:36rem">
<strong>The Browse app now opens markdown files in this same editor.</strong>
Browse provides a unified file tree + per-file-type preview where
<code>.md</code> files render in this Toast UI editor. The
standalone Markdown Editor remains available for offline single-file
editing and air-gapped environments.
</div>
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
</div>

View file

@ -2,11 +2,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// tokens.spec.js builds the Go binary on first run via podman + waits
// for the spawned master to listen — both can take longer than the
// default 30s on a cold cache. Other specs are file:// driven and
// unaffected by this bump.
timeout: 60000,
timeout: 30000,
retries: 0,
reporter: [['line'], ['html', { open: 'never' }]],
@ -51,26 +47,6 @@ export default defineConfig({
name: 'mdedit',
testMatch: 'mdedit.spec.js',
},
{
name: 'browse',
testMatch: 'browse.spec.js',
},
{
name: 'zddc-source',
testMatch: 'zddc-source.spec.js',
},
{
name: 'toast',
testMatch: 'toast.spec.js',
},
{
name: 'nav',
testMatch: 'nav.spec.js',
},
{
name: 'logo',
testMatch: 'logo.spec.js',
},
{
name: 'zddc',
testMatch: 'zddc.spec.js',
@ -95,23 +71,6 @@ export default defineConfig({
name: 'schema',
testMatch: 'schema.spec.js',
},
{
// Server-backed: starts a real zddc-server master via
// tests/lib/server.mjs (which builds the binary on first run
// through the canonical podman/zddc-go:1.24 invocation), drives
// Chromium against http://127.0.0.1:<port>/.tokens, exercises
// create/list/revoke + bearer round-trip + cross-user 404 +
// XSS-guard. The binary build is cached at zddc/dist/zddc-server-
// test and invalidated by a hash of cmd/+internal/+go.{mod,sum}
// so a second run only takes the master-startup time (~1s).
// First run takes ~30s for the build.
//
// The lifecycle is per-spec via beforeAll/afterAll — Playwright's
// top-level webServer hook would fire for every project, including
// the file://-driven tool tests that don't need the server.
name: 'tokens',
testMatch: 'tokens.spec.js',
},
],
});

View file

@ -35,16 +35,8 @@
/* Shape */
--radius: 4px;
/* Typography. --font-display covers headings (Source Serif 4 a refined
transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans
distinctive engineering sans, with proper figures and tabular nums).
Both are base64-inlined via shared/fonts.css; system fallbacks kick in
when fonts.css isn't loaded (e.g. unbuilt component preview). --font-mono
stays as a system stack; engineering tools rarely benefit from a custom
mono and platform mono fonts are already excellent. */
--font-display: 'Source Serif 4', ui-serif, Charter, 'Iowan Old Style', Georgia, serif;
--font: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
@ -53,9 +45,9 @@
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #5fa8e0;
--primary-hover: #74b6e6;
--primary-active: #88c4ec;
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
@ -74,9 +66,9 @@
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #5fa8e0;
--primary-hover: #74b6e6;
--primary-active: #88c4ec;
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
@ -111,19 +103,8 @@ html, body {
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: 600;
line-height: 1.2;
/* Source Serif 4 has subtle optical sizing; let the browser opt in
where supported (modern Chromium/Firefox). */
font-optical-sizing: auto;
}
/* Tracking numbers and other engineering identifiers should align in
columns when stacked vertically. Apply tabular figures wherever we
render structured numeric data. */
table, .tabular-nums, code {
font-variant-numeric: tabular-nums;
}
a {
@ -294,31 +275,12 @@ a:hover {
flex-shrink: 0;
}
/* Left and right groups inside .app-header. Both flex-row so their
children (logo, title, action button, theme icon, etc.) lay out
horizontally rather than stacking. Left side gets a slightly
larger gap because it carries the title group and an action
button; right side is just icon buttons. */
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */
/* Tool name inside the header */
.app-header__title {
font-family: var(--font-display);
font-size: 18px;
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0;
letter-spacing: 0.01em;
white-space: nowrap;
}
@ -332,40 +294,6 @@ a:hover {
display: block;
}
/* Page-load reveal. The header is the first thing a user sees a
short staggered fade-in over ~360ms turns "instant pop-in" into a
subtle "the tool is composing itself for you" beat. Pure CSS, no
JS; respects prefers-reduced-motion. The stagger order (logo
title action buttons right-side icons) mirrors the reading
order of the chrome itself. */
@keyframes zddc-header-rise {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.app-header__logo,
.header-title-group,
.header-left > .btn,
.header-right > * {
animation: zddc-header-rise 360ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.app-header__logo { animation-delay: 0ms; }
.header-title-group { animation-delay: 60ms; }
.header-left > .btn { animation-delay: 120ms; }
.header-right > *:nth-child(1) { animation-delay: 180ms; }
.header-right > *:nth-child(2) { animation-delay: 220ms; }
.header-right > *:nth-child(3) { animation-delay: 260ms; }
@media (prefers-reduced-motion: reduce) {
.app-header__logo,
.header-title-group,
.header-left > .btn,
.header-right > * {
animation: none;
}
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
@ -399,88 +327,7 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in shared/toast.css; loaded by every tool's build. */
/* ── Empty state ──────────────────────────────────────────────────────────── */
/* The "nothing's loaded yet" screen. By default, centers its inner
content in whatever space the parent gives it (works inside a flex
column). Tools that need to overlay an existing layout (archive,
classifier) add .empty-state--overlay; the screen pins below the
app header and on top of whatever underlying layout already exists.
Inner content uses BEM-ish .empty-state__inner with two variants:
plain (left-aligned, doc-style) and --centered (centered card). */
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: var(--bg);
}
.empty-state--overlay {
position: absolute;
top: 50px; /* clear the app-header */
left: 0;
right: 0;
bottom: 0;
z-index: 10;
flex: none;
}
.empty-state__inner {
max-width: 640px;
color: var(--text-muted);
line-height: 1.5;
}
.empty-state__inner h2 {
color: var(--text);
margin: 0 0 1rem;
font-size: 1.5rem;
}
.empty-state__inner p {
margin-bottom: 1rem;
}
.empty-state__inner ul,
.empty-state__inner ol {
margin: 1rem 0;
padding-left: 1.5rem;
}
.empty-state__inner li {
margin: 0.4rem 0;
}
.empty-state__inner .note {
font-size: 0.85rem;
font-style: italic;
}
/* Centered variant: tighter max-width + centered text. Used by tools
whose empty-state reads as a "welcome card" (archive, classifier)
rather than a doc-style page (browse). */
.empty-state__inner--centered {
max-width: 500px;
text-align: center;
padding: 2rem;
}
/* Bullet list inside an empty-state keep the bullets left-aligned
even when the surrounding card is centered. */
.welcome-list {
text-align: left;
margin: 0.5rem auto;
max-width: 400px;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
@ -669,62 +516,3 @@ body.help-open .app-header {
.column-filter::placeholder {
color: var(--text-muted);
}
/* Narrow-viewport behavior
ZDDC tools are desktop-first (engineering workstations, large monitors),
but a baseline narrow rule keeps them usable on a tablet in landscape or
a window split next to a document. Three principled moves:
1. Smaller header padding so the chrome doesn't dominate the viewport.
2. The build-timestamp inside .header-title-group is hidden it's a
traceability artifact, never an immediate-action element. (The full
label remains visible via the help panel and the "About" surface.)
3. .header-right gap tightens; the action button next to the title
drops to a 32x32 icon-only square via the .btn-square pattern (tools
that haven't adopted .btn-square just keep the text button graceful).
Each tool is welcome to add its own narrow-mode rules in css/layout.css;
this block is the shared baseline. */
@media (max-width: 800px) {
.app-header {
padding: 0.3rem 0.6rem;
}
.app-header__title {
font-size: 16px;
}
.header-left {
gap: 0.5rem;
}
.header-right {
gap: 0.25rem;
}
/* Hide the build-timestamp on narrow viewports it's reference info,
not a primary affordance, and steals horizontal space from the title.
Still reachable via the help panel and DOM. */
.header-title-group .build-timestamp {
display: none;
}
/* Action buttons that have an emoji-only or symbol-only label keep
their full width; text-labeled action buttons in the header shrink
to a more compact pad to fit. */
.header-left > .btn {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
}
}
/* Very narrow (phone-width). Stack the header-left children vertically so
the title and action button each get their own line; tools can override
this in their own CSS if they have a dedicated mobile layout. */
@media (max-width: 480px) {
.app-header {
align-items: flex-start;
flex-direction: column;
gap: 0.4rem;
}
.header-left,
.header-right {
width: 100%;
justify-content: space-between;
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,63 +0,0 @@
# shared/fonts/
Source `.woff2` files for the typography baked into every tool. These are
the *raw* font bytes; the actual `@font-face` declarations live in
`shared/fonts.css` as base64 data URIs so single-file HTML tools render
identically offline (`file://`) and online with no CDN dependency.
## Files
- `ibm-plex-sans-400.woff2` — UI body text (regular weight)
- `ibm-plex-sans-600.woff2` — UI emphasis (semibold)
- `source-serif-4-600.woff2` — display/headings (semibold)
Latin subsets only. ~60 KB total raw; ~80 KB once base64-inlined.
## Regenerating `shared/fonts.css`
Run from the repo root:
```sh
python3 - <<'PY'
import base64, pathlib
fonts = [
('IBM Plex Sans', 400, 'ibm-plex-sans-400.woff2'),
('IBM Plex Sans', 600, 'ibm-plex-sans-600.woff2'),
('Source Serif 4', 600, 'source-serif-4-600.woff2'),
]
out = pathlib.Path('shared/fonts.css')
lines = ['/* shared/fonts.css — base64-inlined woff2. Generated by',
' * shared/fonts/README.md instructions; do NOT edit by hand. */', '']
for family, weight, fn in fonts:
b64 = base64.b64encode((pathlib.Path('shared/fonts') / fn).read_bytes()).decode('ascii')
lines += ['@font-face {',
f" font-family: '{family}';",
' font-style: normal',
f' font-weight: {weight};',
' font-display: swap;',
f" src: url(data:font/woff2;base64,{b64}) format('woff2');",
'}', '']
out.write_text('\n'.join(lines))
PY
```
## Adding or swapping a font
1. Download the new `.woff2` into this directory (latin subset only — keep
the bundle small).
2. Update the `fonts` list in the snippet above to include the new family
+ weight + filename.
3. Re-run the regen snippet.
4. Update `--font` or `--font-display` tokens in `shared/base.css`.
5. `./build` and verify every tool's `dist/*.html` still includes
`@font-face` (three by default).
## Sourcing
Originally downloaded from the `@fontsource` npm packages via jsdelivr:
- IBM Plex Sans 400/600: `@fontsource/ibm-plex-sans@5.0.18`
- Source Serif 4 600: `@fontsource/source-serif-4@5.0.5`
Both licenses are SIL Open Font License 1.1. Fontsource bundles only the
latin subset by default, which is exactly what we want.

Binary file not shown.

Binary file not shown.

View file

@ -1,21 +0,0 @@
/* shared/logo.css paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */
.app-header__logo-link {
display: inline-flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius);
transition: opacity 0.15s, box-shadow 0.15s;
}
.app-header__logo-link:hover .app-header__logo,
.app-header__logo-link:focus-visible .app-header__logo {
opacity: 0.82;
}
.app-header__logo-link:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}

View file

@ -1,82 +0,0 @@
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to:
//
// file:// → no wrap (no server home)
// http(s)://host/ → wrap, href = /
// http(s)://host/<tool>.html (deployment root)→ wrap, href = /
// http(s)://host/<project>/... → wrap, href = /<project>
//
// When inside a project, the logo takes the user to the project
// landing (synthetic page with the four lifecycle-stage cards + MDL
// instructions). When at the deployment root, the logo points at /
// (the project picker). Offline, the logo stays decorative — there's
// no real "home" to go to.
//
// Mounts as a sibling-replacement on DOMContentLoaded: wraps the
// existing logo SVG in an <a>, preserving classes and attributes.
// Idempotent: re-mounting on an already-wrapped logo is a no-op.
//
// Tools that want to override (e.g. a deployment that pins logo to
// an external URL) can set window.zddc.logo.disabled = true before
// DOMContentLoaded and inject their own anchor.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.logo) return;
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
// Tool HTMLs at the deployment root (index.html, archive.html
// with ?projects=...) don't carry a project segment.
if (first.indexOf('.') !== -1) return null;
return first;
}
function targetHref() {
if (typeof location === 'undefined') return null;
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
if (window.zddc.logo && window.zddc.logo.disabled) return null;
var seg = projectSegment(location.pathname);
return seg ? '/' + encodeURIComponent(seg) : '/';
}
function mount() {
var logo = document.querySelector('.app-header__logo');
if (!logo) return;
// Already wrapped (template-supplied anchor, or a previous mount).
if (logo.parentElement && logo.parentElement.tagName === 'A' &&
logo.parentElement.classList.contains('app-header__logo-link')) {
return;
}
var href = targetHref();
if (!href) return;
var a = document.createElement('a');
a.href = href;
a.className = 'app-header__logo-link';
var label = href === '/' ? 'ZDDC home' : 'Project home';
a.title = label;
a.setAttribute('aria-label', label);
logo.parentNode.insertBefore(a, logo);
a.appendChild(logo);
}
window.zddc.logo = {
mount: mount,
// Test seam.
_projectSegment: projectSegment,
_targetHref: targetHref,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();

View file

@ -1,56 +0,0 @@
/* shared/nav.css lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}

View file

@ -1,204 +0,0 @@
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();

View file

@ -119,10 +119,7 @@
opts = opts || {};
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
// UTIF is bundled (shared/vendor/utif.min.js) — window.UTIF is
// available synchronously. Promise.resolve() keeps the existing
// .then() chain shape so callers don't need to change.
return Promise.resolve().then(function () {
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
var ifds;
try {
ifds = window.UTIF.decode(arrayBuffer);
@ -387,9 +384,9 @@
opts = opts || {};
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
// JSZip is bundled in every tool that uses preview-lib (each
// tool's build.sh concatenates shared/vendor/jszip.min.js).
return window.JSZip.loadAsync(arrayBuffer).then(function (zip) {
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
return window.JSZip.loadAsync(arrayBuffer);
}).then(function (zip) {
var entries = [];
zip.forEach(function (relativePath, zipEntry) {
if (zipEntry.dir) return;

View file

@ -1,40 +0,0 @@
/* shared/toast.css single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}

View file

@ -1,76 +0,0 @@
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {
toast(String(msg == null ? '' : msg), 'error');
};
}
})();

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -171,24 +171,7 @@
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var rawName = stripSlash(e.name);
// Listing entries can carry an explicit URL for virtual
// links (e.g. the reviewing-aggregator's received/+staged/
// entries point to canonical archive/+staging paths).
// Use it when present so navigation follows the listing's
// own routing rather than computing a synthetic child URL
// off the parent. Caddy-shape listings don't set url
// (or set it to a relative form) — joinUrl handles those.
var childUrl;
if (e.url && /^https?:\/\/|^\//.test(e.url)) {
// Absolute or root-relative: use as-is, normalised against origin.
var u = e.url;
if (u[0] === '/') {
u = location.origin + u;
}
childUrl = u;
} else {
childUrl = joinUrl(url, rawName, e.is_dir);
}
var childUrl = joinUrl(url, rawName, e.is_dir);
if (e.is_dir) {
yield new HttpDirectoryHandle(childUrl, rawName);
} else {

View file

@ -1,269 +0,0 @@
// shared/zip-source.js — present the contents of a .zip as a tree of
// File System Access API handles, so tools written against
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
// browse's tree) can navigate into a zip with no special-casing.
//
// Mirrors shared/zddc-source.js's HttpDirectoryHandle / HttpFileHandle
// pair, but read-only and backed by a JSZip instance instead of HTTP.
// Online tools that talk to zddc-server should use the server's
// "<…>.zip/" virtual-directory route instead (no whole-zip download);
// this adapter is for the offline (file://) path where the zip bytes
// are already in hand, and for zips nested inside other zips.
//
// Requires window.JSZip (vendored at shared/vendor/jszip.min.js and
// concatenated by the tool's build.sh) — referenced lazily, only from
// fromBlob / fromFileHandle, so this module is harmless to include in
// a build that doesn't bundle JSZip (it just won't be usable there).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Minimal extension → media-type map so getFile() returns a Blob
// with a usable `type` (iframes/img tags need it to render inline).
var MIME = {
pdf: 'application/pdf',
html: 'text/html', htm: 'text/html',
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
json: 'application/json', xml: 'application/xml',
yaml: 'application/yaml', yml: 'application/yaml',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
zip: 'application/zip',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
};
function mimeFor(name) {
var dot = name.lastIndexOf('.');
if (dot < 0) return '';
return MIME[name.slice(dot + 1).toLowerCase()] || '';
}
function baseName(p) {
var s = p.replace(/\/+$/, '');
var i = s.lastIndexOf('/');
return i >= 0 ? s.slice(i + 1) : s;
}
// Reject zip entry names that aren't safe to surface: absolute
// paths, backslash separators, or anything that escapes via "..".
// Returns the cleaned forward-slash path (trailing "/" preserved
// for directory entries) or null.
function cleanEntryName(name) {
if (!name || name.indexOf('\\') !== -1 || name[0] === '/') return null;
var isDir = name.endsWith('/');
var parts = [];
var segs = name.split('/');
for (var i = 0; i < segs.length; i++) {
var s = segs[i];
if (s === '' || s === '.') continue;
if (s === '..') return null;
parts.push(s);
}
if (parts.length === 0) return null;
return parts.join('/') + (isDir ? '/' : '');
}
// -----------------------------------------------------------------
// ZipFileHandle — FileSystemFileHandle polyfill (read-only)
// -----------------------------------------------------------------
function ZipFileHandle(jszip, fullPath, size, modTime) {
this.kind = 'file';
this.name = baseName(fullPath);
this._zip = jszip;
this._path = fullPath; // path within the zip (no trailing /)
this._size = size || 0;
this._modTime = modTime || null;
}
ZipFileHandle.prototype.getFile = async function () {
var entry = this._zip.file(this._path);
if (!entry) {
var err = new Error('NotFoundError: ' + this._path);
err.name = 'NotFoundError';
throw err;
}
var buf = await entry.async('arraybuffer');
return new File([buf], this.name, {
type: mimeFor(this.name),
lastModified: this._modTime ? this._modTime.getTime() : Date.now()
});
};
ZipFileHandle.prototype.createWritable = async function () {
var err = new Error('Zip archives are read-only');
err.name = 'NoModificationAllowedError';
throw err;
};
ZipFileHandle.prototype.queryPermission = async function () { return 'granted'; };
ZipFileHandle.prototype.requestPermission = async function () { return 'granted'; };
ZipFileHandle.prototype.isZipEntry = true;
// -----------------------------------------------------------------
// ZipDirectoryHandle — FileSystemDirectoryHandle polyfill (read-only)
// -----------------------------------------------------------------
// jszip: the JSZip instance. prefix: "" for the zip root, else
// "<dir>/". name: the label for this level.
function ZipDirectoryHandle(jszip, prefix, name) {
this.kind = 'directory';
this._zip = jszip;
this._prefix = prefix || '';
this.name = name != null ? name : (this._prefix ? baseName(this._prefix) : '');
}
// Walk the flat entry list once, returning a Map of immediate child
// name → { isDir, size, modTime, fullPath }. Synthesises directory
// children that have no explicit "<dir>/" entry.
ZipDirectoryHandle.prototype._children = function () {
var prefix = this._prefix;
var seen = new Map();
var zip = this._zip;
Object.keys(zip.files).forEach(function (rawName) {
var clean = cleanEntryName(rawName);
if (clean === null) return;
var entryIsDir = clean.endsWith('/');
var bare = entryIsDir ? clean.slice(0, -1) : clean;
if (prefix) {
if (bare === prefix.slice(0, -1)) return; // the prefix dir itself
if (bare.indexOf(prefix) !== 0) return;
}
var rest = prefix ? bare.slice(prefix.length) : bare;
if (rest === '') return;
var slash = rest.indexOf('/');
var seg = slash === -1 ? rest : rest.slice(0, slash);
var nested = slash !== -1;
var existing = seen.get(seg);
if (nested) {
if (!existing) {
seen.set(seg, { isDir: true, size: 0, modTime: null, fullPath: prefix + seg });
} else {
existing.isDir = true;
}
} else if (entryIsDir) {
var entry = zip.files[rawName];
seen.set(seg, {
isDir: true, size: 0,
modTime: entry && entry.date instanceof Date ? entry.date : null,
fullPath: prefix + seg
});
} else {
if (!existing || existing.isDir !== false) {
var fent = zip.files[rawName];
seen.set(seg, {
isDir: false,
size: (fent && fent._data && fent._data.uncompressedSize) || 0,
modTime: fent && fent.date instanceof Date ? fent.date : null,
fullPath: prefix + seg
});
}
}
});
return seen;
};
ZipDirectoryHandle.prototype._handleFor = function (seg, info) {
if (info.isDir) {
return new ZipDirectoryHandle(this._zip, this._prefix + seg + '/', seg);
}
return new ZipFileHandle(this._zip, info.fullPath, info.size, info.modTime);
};
ZipDirectoryHandle.prototype.values = function () {
var self = this;
return (async function* () {
var children = self._children();
var names = Array.from(children.keys()).sort();
for (var i = 0; i < names.length; i++) {
yield self._handleFor(names[i], children.get(names[i]));
}
})();
};
ZipDirectoryHandle.prototype.entries = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield [step.value.name, step.value];
}
})();
};
ZipDirectoryHandle.prototype.keys = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield step.value.name;
}
})();
};
ZipDirectoryHandle.prototype.getDirectoryHandle = async function (name) {
var children = this._children();
var info = children.get(name);
if (!info || !info.isDir) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return this._handleFor(name, info);
};
ZipDirectoryHandle.prototype.getFileHandle = async function (name) {
var children = this._children();
var info = children.get(name);
if (!info || info.isDir) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return this._handleFor(name, info);
};
ZipDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
ZipDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
ZipDirectoryHandle.prototype.isZipEntry = true;
// -----------------------------------------------------------------
// Constructors
// -----------------------------------------------------------------
function requireJSZip() {
if (!window.JSZip) {
throw new Error('JSZip is not available — this build does not bundle it');
}
return window.JSZip;
}
// Build a ZipDirectoryHandle rooted at the top level of `blob`
// (an ArrayBuffer, Blob, Uint8Array, or anything JSZip.loadAsync
// accepts). `name` labels the root level (default: empty).
async function fromBlob(blob, name) {
var JSZip = requireJSZip();
var src = blob;
if (blob && typeof blob.arrayBuffer === 'function') {
src = await blob.arrayBuffer();
}
var zip = await JSZip.loadAsync(src);
return new ZipDirectoryHandle(zip, '', name || '');
}
// Build a ZipDirectoryHandle from a FileSystemFileHandle (or this
// adapter's own ZipFileHandle — so a zip nested inside a zip works
// by recursion). The handle's basename labels the root level.
async function fromFileHandle(fileHandle) {
var f = await fileHandle.getFile();
return fromBlob(f, fileHandle.name || (f && f.name) || '');
}
window.zddc.zip = {
ZipDirectoryHandle: ZipDirectoryHandle,
ZipFileHandle: ZipFileHandle,
fromBlob: fromBlob,
fromFileHandle: fromFileHandle,
// True for handles produced by this adapter (vs. real FS Access
// handles or the HTTP polyfill).
isZipHandle: function (h) { return !!(h && h.isZipEntry === true); }
};
})();

View file

@ -18,52 +18,23 @@ cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/table.css" \
"../form/css/form.css" \
> "$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 \
"../shared/vendor/js-yaml.min.js" \
"../shared/zddc.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \
"js/filters.js" \
"js/sort.js" \
"js/editor.js" \
"js/undo.js" \
"js/save.js" \
"js/clipboard.js" \
"js/render.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"
escape_js_close_tags "$js_raw" "$js_temp"

View file

@ -27,17 +27,12 @@
margin: 0 0 var(--spacing-sm);
}
.table-toolbar__left,
.table-toolbar__right {
.table-toolbar__left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
#table-add-row {
text-decoration: none;
}
.table-rowcount {
color: var(--color-text-muted);
font-size: 0.9rem;
@ -103,6 +98,14 @@
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);
}
@ -111,82 +114,6 @@
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
cursor: cell;
/* Hide the browser's default outline; the grid pattern renders
its own selection chrome via the --selected class. */
outline: none;
}
/* Currently-selected cell Excel-style focus ring. The 2px outset
border doesn't push surrounding cells around because outline is
used instead of border. */
.zddc-table__cell--selected {
outline: 2px solid var(--color-accent, #2868c8);
outline-offset: -2px;
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
}
/* Cells in the multi-cell range get a fainter highlight; the focus
cell (the one with --selected) stays brighter so the anchor /
focus distinction is visible. */
.zddc-table__cell--in-range:not(.zddc-table__cell--selected) {
background: var(--color-bg-range, rgba(40, 104, 200, 0.05));
}
/* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the
surrounding td still shows. */
.zddc-table__cell-input {
width: 100%;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
outline: none;
}
/* Row-save state markers (Phase 3). The first cell of the row gets a
left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */
.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
.zddc-table__row--invalid td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); }
.zddc-table__row--errored td:first-child { box-shadow: inset 3px 0 0 var(--color-error, #c14242); background: var(--color-bg-error, rgba(193, 66, 66, 0.06)); }
/* Per-cell invalid marker small red corner triangle, Excel-style.
The hover tooltip carries the validation message via title attr. */
.zddc-table__cell--invalid {
position: relative;
}
.zddc-table__cell--invalid::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent var(--color-error, #c14242) transparent transparent;
}
/* Status bar (table-status) when used as the stale-row prompt host. */
.table-status.table-status--prompt {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-warning, rgba(232, 163, 61, 0.08));
border: 1px solid var(--color-warning, #e8a33d);
border-radius: var(--radius-sm, 4px);
margin-bottom: var(--spacing-sm);
color: var(--color-text, #111);
}
.table-empty {

View file

@ -8,21 +8,7 @@
state: {
rows: [],
sort: [],
filter: {},
// Editor-mode state (Phase 1):
// selected: {row: rowId, col: field} | null — currently
// focused cell. row is the row's id (or rowsRel for the
// row file path); col is the column's `field`.
// editing: bool — whether a cell-editor input is mounted.
// drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3).
// range: {anchor: {row, col}, focus: {row, col}} | null
// — multi-cell range selection (Phase 5).
selected: null,
editing: false,
range: null,
drafts: {}
filter: {}
},
modules: {}
};

View file

@ -1,277 +0,0 @@
// clipboard.js — Phase 4 of editable-cell mode.
//
// Bidirectional clipboard interop with Excel / Google Sheets / any
// other spreadsheet that uses RFC-4180-ish TSV on the text/plain
// clipboard mime.
//
// Copy: when a single cell is selected, Ctrl/Cmd+C writes that
// cell's value as plain text. Range selection (Phase 5) extends
// this to a TSV rectangle.
//
// Paste: Ctrl/Cmd+V on the focused cell parses text/plain as TSV
// (tabs between columns, newlines between rows; embedded newlines
// or tabs are quoted with double-quotes; doubled "" escapes).
//
// - 1×1 clipboard into selected cell → writes that one cell.
// - N×M clipboard into selected cell → SPILLS from the anchor
// cell down/right to (anchor.row + N - 1, anchor.col + M - 1).
// Out-of-bounds cells are silently dropped (Excel convention).
//
// Each pasted cell goes through the same draft-buffer write path
// as a normal edit — the row-blur save trigger picks them up,
// and the per-cell schema-driven coercion (Phase 2) applies.
// Per-cell validation runs on the next save attempt; invalid
// cells get the red-corner mark.
(function (app) {
'use strict';
function editor() { return app.modules.editor; }
// --- TSV parsing --------------------------------------------------
// parseTSV(text) → string[][]. Honors RFC-4180-ish quoting:
// - A field surrounded by " can contain tabs, newlines, and
// literal " characters escaped as "".
// - An unquoted field ends at the next tab, newline, or end.
// - Bare \r is treated as part of \r\n (Windows line endings).
function parseTSV(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const s = String(text == null ? '' : text);
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuotes) {
if (ch === '"') {
if (s[i + 1] === '"') {
// Escaped quote inside a quoted field.
field += '"';
i++;
} else {
// End of quoted field.
inQuotes = false;
}
} else {
field += ch;
}
continue;
}
if (ch === '"' && field === '') {
// Open quote — only at start of field.
inQuotes = true;
continue;
}
if (ch === '\t') {
row.push(field);
field = '';
continue;
}
if (ch === '\n' || ch === '\r') {
// \r\n — consume the \n too.
if (ch === '\r' && s[i + 1] === '\n') i++;
row.push(field);
field = '';
rows.push(row);
row = [];
continue;
}
field += ch;
}
// Trailing field (no terminator).
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
// Excel often appends a trailing empty row from the final \n;
// drop one trailing all-empty row to match that convention.
if (rows.length > 0) {
const last = rows[rows.length - 1];
if (last.length === 1 && last[0] === '') rows.pop();
}
return rows;
}
// formatTSV(grid) → string. Reverse of parseTSV. Quotes any
// field containing tab, newline, or double-quote.
function formatTSV(grid) {
const lines = [];
for (let r = 0; r < grid.length; r++) {
const row = grid[r];
const cells = [];
for (let c = 0; c < row.length; c++) {
cells.push(formatCell(row[c]));
}
lines.push(cells.join('\t'));
}
return lines.join('\n');
}
function formatCell(v) {
const s = (v == null) ? '' : String(v);
if (/[\t\n\r"]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
// --- Apply paste --------------------------------------------------
function applyPaste(anchorRowIdx, anchorColIdx, grid) {
// grid is string[][]. Returns {applied: int, skipped: int}.
const ed = editor();
const totalRows = visibleRowCount();
const cols = (app.context && app.context.columns) || [];
const totalCols = cols.length;
let applied = 0, skipped = 0;
for (let r = 0; r < grid.length; r++) {
const dstR = anchorRowIdx + r;
if (dstR >= totalRows) { skipped += grid[r].length; continue; }
const row = rowDataAtIndex(dstR);
if (!row) { skipped += grid[r].length; continue; }
for (let c = 0; c < grid[r].length; c++) {
const dstC = anchorColIdx + c;
if (dstC >= totalCols) { skipped++; continue; }
const col = cols[dstC];
if (!col) { skipped++; continue; }
const newValue = coerceCell(grid[r][c], col, row);
ed.setDraft(ed.rowKey(row), col.field, newValue);
applied++;
}
}
return { applied: applied, skipped: skipped };
}
function visibleRowCount() {
return document.querySelectorAll('#table-root tbody > tr').length;
}
function rowDataAtIndex(r) {
const tr = document.querySelectorAll('#table-root tbody > tr')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (editor().rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function coerceCell(raw, col, _row) {
// Phase 2's editor coerces values typed into a number/checkbox/
// select widget. Pasted cells arrive as raw strings; coerce
// here so the draft holds the right JS type. Falls back to the
// raw string when coercion is ambiguous.
const fmt = col.format;
if (fmt === 'number' || fmt === 'integer' || isNumericSchema(col)) {
const n = Number(raw);
if (raw.trim() !== '' && !Number.isNaN(n)) return n;
}
if (isBooleanSchema(col)) {
const t = String(raw).trim().toLowerCase();
if (t === 'true' || t === 'yes' || t === '1') return true;
if (t === 'false' || t === 'no' || t === '0' || t === '') return false;
}
return raw;
}
function isNumericSchema(col) {
const s = propSchema(col);
return !!(s && (s.type === 'number' || s.type === 'integer'));
}
function isBooleanSchema(col) {
const s = propSchema(col);
return !!(s && s.type === 'boolean');
}
function propSchema(col) {
const ctx = app.context || {};
if (!ctx.rowSchema || !ctx.rowSchema.properties) return null;
return ctx.rowSchema.properties[col.field] || null;
}
// --- Event handlers ----------------------------------------------
function onPaste(ev) {
if (!app.state || !app.state.selected) return;
if (app.state.editing) return; // input owns its own paste
const text = ev.clipboardData && ev.clipboardData.getData('text/plain');
if (!text) return;
ev.preventDefault();
const grid = parseTSV(text);
if (!grid.length) return;
const { row: r, col: c } = app.state.selected;
const result = applyPaste(r, c, grid);
// Trigger a re-paint so draft values display.
if (typeof app.repaint === 'function') app.repaint();
if (result.skipped > 0) {
notifyToast(
'Pasted ' + result.applied + ' cell' + plural(result.applied) +
'; ' + result.skipped + ' dropped (out of bounds)'
);
}
}
function onCopy(ev) {
if (!app.state || !app.state.selected) return;
if (app.state.editing) return; // input owns its own copy
const { row: r, col: c } = app.state.selected;
const row = rowDataAtIndex(r);
const cols = (app.context && app.context.columns) || [];
const col = cols[c];
if (!row || !col) return;
const value = editor().effectiveCellValue(row, col);
ev.preventDefault();
if (ev.clipboardData) {
ev.clipboardData.setData('text/plain', formatCell(value));
}
}
function plural(n) { return n === 1 ? '' : 's'; }
function notifyToast(msg) {
// Cheap toast: write to #table-status, auto-clear after 4s.
// Coexists with save.js's stale-row prompt — just don't fire
// if a prompt is currently up.
const el = document.getElementById('table-status');
if (!el) return;
if (el.classList.contains('table-status--prompt')) return;
el.textContent = msg;
el.hidden = false;
clearTimeout(notifyToast._t);
notifyToast._t = setTimeout(() => {
if (el.textContent === msg) {
el.hidden = true;
el.textContent = '';
}
}, 4000);
}
function attach() {
// Listen at the document level so paste events bubble from
// any cell with focus. No element-specific binding because
// Phase 1's roving tabindex moves focus around.
document.addEventListener('paste', onPaste);
document.addEventListener('copy', onCopy);
}
// Auto-wire on bootstrap. table-mode only — the dispatcher hides
// form-mode in this bundle, but be defensive if both modes ever
// coexist on a page (test fixtures): attach unconditionally; the
// handler bails when there's no selected cell.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attach, { once: true });
} else {
attach();
}
app.modules.clipboard = {
parseTSV: parseTSV,
formatTSV: formatTSV,
applyPaste: applyPaste,
};
})(window.tablesApp);

View file

@ -11,11 +11,9 @@
// to a non-empty object, return it as-is.
//
// 2. File-backed walk (the real-world path served by zddc-server):
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
// list every other *.yaml in <dir> as a row file (filtering
// 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.
// 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
@ -77,49 +75,32 @@
}
const dir = probe.handle;
// Spec lives at <currentdir>/table.yaml — the page URL is
// <currentdir>/table.html, so the spec is right next door.
const spec = await readYaml(dir, 'table.yaml');
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 table.yaml missing columns[]');
throw new Error('Spec ' + specRel + ' missing columns[]');
}
// Optional row schema from <dir>/form.yaml — same JSON Schema
// the form-mode renderer uses. Phase 2 derives per-cell editor
// widgets from it (text/number/date/select/checkbox).
// Best-effort: a directory with only table.yaml still renders
// as a sortable/filterable table; cells fall back to plain
// text inputs without per-property hints.
let rowSchema = null;
try {
const formSpec = await readYaml(dir, 'form.yaml');
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
// (table.yaml) and the row-edit form (form.yaml). They live
// in the same directory by design — copying the directory
// copies the whole table.
const rows = await readRows(dir, '', tableName);
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,
rowSchema: rowSchema,
rows: rows
};
}
function tableNameFromUrl(pathname) {
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
// basename.
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/);
return m ? m[1] : null;
}
@ -165,30 +146,17 @@
return cur;
}
async function readRows(rowsDir, _rowsRel, _tableName) {
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;
// 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 {
const handle = await rowsDir.getFileHandle(entry.name);
const file = await handle.getFile();
const file = await (await rowsDir.getFileHandle(entry.name)).getFile();
const data = window.jsyaml.load(await file.text());
rows.push({
url: rowEditUrl(entry.name),
// Underlying YAML URL — strip the trailing .html
// from the form-mode re-edit URL. Phase 3 PUTs to
// this URL with If-Match: <etag> for optimistic
// concurrency.
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''),
url: rowEditUrl(rowsRel, tableName, entry.name),
data: data || {},
// ETag captured by HttpFileHandle.getFile from the
// server's response header. null in offline / file://
// mode (no HTTP roundtrip happened).
etag: handle._etag || null,
editable: true
});
} catch (err) {
@ -198,12 +166,14 @@
return rows;
}
// Re-edit URL for one row. Page is at /<dir>/table.html; row file
// lives at /<dir>/<basename>.yaml; form re-edit URL is
// /<dir>/<basename>.yaml.html — same directory.
function rowEditUrl(rowFileName) {
const pageDir = location.pathname.replace(/\/table\.html$/, '/');
return pageDir + rowFileName + '.html';
// 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 };

View file

@ -1,873 +0,0 @@
// editor.js — Phase 1 of editable-cell mode.
//
// Owns the cell-selection + per-cell edit lifecycle. Implements the
// W3C ARIA grid-pattern keyboard semantics:
//
// - Arrow keys move the selected cell.
// - Tab / Shift-Tab move right / left, wrapping to next / prev row.
// - Enter, F2, double-click, or any printable character enter edit
// mode (Enter and F2 keep the existing value; printable chars
// replace it; double-click opens with the existing value).
// - In edit mode: Enter commits and moves down, Tab commits and
// moves right, Escape cancels (restoring the prior value), blur
// commits.
//
// Roving tabindex: only the selected cell carries tabindex=0; all
// others are tabindex=-1. This makes the grid a single tab-stop in
// the page's tab order, which is the documented spreadsheet UX.
//
// Edits in this phase live in app.state.drafts and never hit the
// network — Phase 3 wires the row-blur PUT.
(function (app) {
'use strict';
// --- Helpers ------------------------------------------------------
function tableEl() { return document.getElementById('table-root'); }
function cellAt(r, c) { return cellsByRowCol(r, c); }
// The displayed table is filtered+sorted; selection is keyed by
// VISIBLE row index, not row id, so arrow keys behave intuitively
// even after sort / filter changes (the cell at row 3 column 2
// stays at row 3 column 2 even if the underlying row id moved).
// This is how Excel and Google Sheets behave too.
function cellsByRowCol(r, c) {
const t = tableEl();
if (!t) return null;
const tbody = t.querySelector('tbody');
if (!tbody) return null;
const tr = tbody.children[r];
if (!tr) return null;
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
}
function isPrintableKey(ev) {
// A "printable" key produces a single character of text — e.g.
// 'a', '7', '$'. Function keys, arrows, modifiers etc. either
// have multi-char `key` values ('ArrowDown') or are non-text.
// ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
// edit mode.
if (ev.key.length !== 1) return false;
if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
return true;
}
function rowCount() {
const t = tableEl();
if (!t) return 0;
return t.querySelectorAll('tbody > tr').length;
}
function colCount() {
const cols = (app.context && app.context.columns) || [];
return Array.isArray(cols) ? cols.length : 0;
}
function colAt(c) {
const cols = (app.context && app.context.columns) || [];
return cols[c] || null;
}
function rowDataAt(r) {
// The visible row at index r. Walk the rendered tbody to find
// its data-row-id, then look up the row in app.state.rows.
// app.state.rows holds the SORTED+FILTERED current view (kept
// in sync by main.js paint()).
const t = tableEl();
if (!t) return null;
const tr = t.querySelectorAll('tbody > tr')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;
const all = app.state.rows || [];
for (let i = 0; i < all.length; i++) {
if (rowKey(all[i]) === rowId) {
return all[i];
}
}
return null;
}
function rowKey(row) {
// Stable per-row identity. Each context row has a `url` (the
// <id>.yaml.html re-edit URL); the file basename inside that
// URL is unique per directory and survives sort/filter.
if (!row || !row.url) return '';
return row.url;
}
// --- Draft buffer -------------------------------------------------
function getDraft(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return undefined;
return r[field];
}
function setDraft(rowId, field, value) {
if (!app.state.drafts[rowId]) {
app.state.drafts[rowId] = {};
}
app.state.drafts[rowId][field] = value;
}
function clearDraftField(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return;
delete r[field];
if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId];
}
}
function effectiveCellValue(row, col) {
// Display draft value if present; otherwise the row's stored
// value. Used by render to keep the visible cell content in
// sync with uncommitted edits.
const drafted = getDraft(rowKey(row), col.field);
if (drafted !== undefined) {
return drafted;
}
return app.modules.util.resolveField(row.data, col.field);
}
// --- Selection (roving tabindex) ----------------------------------
function setSelected(r, c, opts) {
opts = opts || {};
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) {
app.state.selected = null;
notifySelectionChanged();
return;
}
if (r < 0) r = 0;
if (r > total - 1) r = total - 1;
if (c < 0) c = 0;
if (c > cols - 1) c = cols - 1;
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
const target = cellAt(r, c);
if (target) {
target.setAttribute('tabindex', '0');
target.classList.add('zddc-table__cell--selected');
if (!opts.noFocus) {
target.focus({ preventScroll: false });
}
}
app.state.selected = { row: r, col: c };
// Plain selection moves clear the multi-cell range. Range
// operations (Shift+click, Shift+arrow) pass keepRange so the
// anchor stays put while the focus cell moves.
if (!opts.keepRange) {
clearRange();
}
notifySelectionChanged();
}
function notifySelectionChanged() {
// Phase 3 wires the row-blur save trigger here. save module is
// optional in test fixtures that don't include it.
const save = app.modules.save;
if (save && typeof save.onSelectionChanged === 'function') {
save.onSelectionChanged(app.state.selected);
}
}
function clearSelection() {
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
app.state.selected = null;
}
// --- Edit mode ----------------------------------------------------
function enterEdit(initial) {
if (!app.state.selected) return;
if (app.state.editing) return;
const { row: r, col: c } = app.state.selected;
const cell = cellAt(r, c);
if (!cell) return;
const row = rowDataAt(r);
const col = colAt(c);
if (!row || !col) return;
const propSchema = propertySchemaFor(col);
// Complex-type cells (nested object, generic array, oneOf)
// can't be inline-edited cleanly — punt to the row's form
// editor in a side panel / new page. Phase 2 ships the
// navigation; Phase 5 may add a side-panel mount.
if (isComplexSchema(propSchema)) {
navigateToRowForm(row);
return;
}
const currentValue = effectiveCellValue(row, col);
const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
const inputEl = widget.element;
inputEl.classList.add('zddc-table__cell-input');
inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));
// Replace the cell's text content with the editor widget.
// Stash the original text in dataset so cancel can restore it
// verbatim without re-running the formatCell logic.
cell.setAttribute('data-display', cell.textContent || '');
cell.textContent = '';
cell.appendChild(inputEl);
widget.focus();
app.state.editing = true;
function commit() {
if (!app.state.editing) return;
const newValue = widget.getValue();
const oldRaw = app.modules.util.resolveField(row.data, col.field);
// Compare by JSON-string equality so number 42 == "42"
// entered into a number input doesn't false-positive as
// a change. resolveField already returns the raw typed
// value from row.data.
if (sameValue(oldRaw, newValue)) {
clearDraftField(rowKey(row), col.field);
} else {
// Capture the prior draft value (or stored value if
// no draft) for undo. Lets Ctrl+Z restore intermediate
// state: e.g. typing A → B → C and undoing returns to
// B, not all the way back to the row's stored value.
const priorDraft = getDraft(rowKey(row), col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(row), col.field, newValue);
const undoMod = app.modules.undo;
if (undoMod) {
undoMod.push({
cells: [{
rowId: rowKey(row),
field: col.field,
oldValue: undoOld,
newValue: newValue,
}],
});
}
}
tearDown(newValue);
}
function cancel() {
tearDown(null); // null = restore from data-display, no draft change
}
function tearDown(displayValue) {
inputEl.removeEventListener('keydown', onKey);
inputEl.removeEventListener('blur', onBlur);
const display = (displayValue !== undefined && displayValue !== null)
? renderableText(displayValue, col)
: (cell.getAttribute('data-display') || '');
cell.removeAttribute('data-display');
cell.textContent = display;
app.state.editing = false;
cell.focus({ preventScroll: false });
}
function onKey(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation(); // don't let the table's onCellKey re-handle it
commit();
setSelected(r + 1, c);
} else if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
cancel();
} else if (ev.key === 'Tab') {
ev.preventDefault();
ev.stopPropagation();
commit();
if (ev.shiftKey) {
moveSelection('left-wrap');
} else {
moveSelection('right-wrap');
}
}
// Other keys: stay in edit mode, let the input handle them.
}
function onBlur(_ev) {
// Blur (focus moved elsewhere). Commit any pending value.
// Schedule via setTimeout(0) so a programmatic refocus by
// tearDown→cell.focus doesn't re-fire blur during teardown.
if (app.state.editing) {
commit();
}
}
inputEl.addEventListener('keydown', onKey);
inputEl.addEventListener('blur', onBlur);
}
function renderableText(value, col) {
return app.modules.util.formatCell(value, col.format);
}
// --- Schema → editor widget factory --------------------------------
function propertySchemaFor(col) {
// Walk the row schema for this column's field. Returns null
// when no schema is present (best-effort: cells fall back to
// plain text editors). Supports a single dot-separated path
// — `properties.a.properties.b` for `field: "a.b"` — to mirror
// the existing util.resolveField conventions.
const ctx = app.context || {};
if (!ctx.rowSchema) return null;
const parts = String(col.field || '').split('.').filter(Boolean);
let s = ctx.rowSchema;
for (let i = 0; i < parts.length; i++) {
if (!s || !s.properties || !s.properties[parts[i]]) return null;
s = s.properties[parts[i]];
}
return s;
}
function isComplexSchema(s) {
if (!s) return false;
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
if (s.type === 'object') return true;
if (s.type === 'array') {
// Multi-select-friendly arrays (string-enum + uniqueItems)
// get inline editing; everything else is complex.
const items = s.items || {};
const isMultiSelect = items.type === 'string'
&& Array.isArray(items.enum) && items.enum.length > 0
&& s.uniqueItems === true;
return !isMultiSelect;
}
return false;
}
function makeWidget(propSchema, col, initialValue) {
// Prefers explicit JSON Schema hints; falls back to column-spec
// hints (col.format / col.enum) for tables without a form.yaml;
// defaults to a plain text input.
const s = propSchema || {};
const colHint = col || {};
// Boolean → checkbox.
if (s.type === 'boolean') {
return widgetCheckbox(initialValue);
}
// Enum (string with explicit choices) → select dropdown.
const enumChoices = (Array.isArray(s.enum) && s.enum)
|| (Array.isArray(colHint.enum) && colHint.enum)
|| null;
if (enumChoices) {
return widgetSelect(enumChoices, initialValue);
}
// Multi-select (array of string-enum with uniqueItems).
if (s.type === 'array'
&& s.items && s.items.type === 'string'
&& Array.isArray(s.items.enum) && s.uniqueItems === true) {
return widgetMultiSelect(s.items.enum, initialValue);
}
// Number / integer → number input with min/max/step.
if (s.type === 'number' || s.type === 'integer'
|| colHint.format === 'number' || colHint.format === 'integer') {
return widgetNumber(s, initialValue);
}
// Date / date-time / email — typed inputs the browser can
// help validate.
const fmt = s.format || colHint.format;
if (fmt === 'date') return widgetTyped('date', initialValue);
if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
if (fmt === 'email') return widgetTyped('email', initialValue);
// Long text → textarea (still inline; Phase 5 may add expand).
if (s.type === 'string' && Number(s.maxLength) > 200) {
return widgetTextarea(initialValue);
}
// Default: plain text input.
return widgetText(initialValue);
}
function widgetText(initial) {
const el = document.createElement('input');
el.type = 'text';
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTextarea(initial) {
const el = document.createElement('textarea');
el.rows = 1;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTyped(htmlType, initial) {
const el = document.createElement('input');
el.type = htmlType;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => el.focus()
};
}
function widgetNumber(s, initial) {
const el = document.createElement('input');
el.type = 'number';
if (s.minimum != null) el.min = String(s.minimum);
if (s.maximum != null) el.max = String(s.maximum);
if (s.type === 'integer') el.step = '1';
else if (s.multipleOf != null) el.step = String(s.multipleOf);
el.value = (initial == null || initial === '') ? '' : String(initial);
return {
element: el,
getValue: () => {
const v = el.value;
if (v === '') return null;
const n = Number(v);
return Number.isNaN(n) ? v : n;
},
focus: () => el.focus()
};
}
function widgetCheckbox(initial) {
const el = document.createElement('input');
el.type = 'checkbox';
el.checked = initial === true || initial === 'true';
return {
element: el,
getValue: () => el.checked,
focus: () => el.focus()
};
}
function widgetSelect(choices, initial) {
const el = document.createElement('select');
// Empty option lets the cell go back to "unset" without typing.
const empty = document.createElement('option');
empty.value = '';
empty.textContent = '—';
el.appendChild(empty);
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
el.appendChild(opt);
}
el.value = initial == null ? '' : String(initial);
return {
element: el,
getValue: () => (el.value === '' ? null : el.value),
focus: () => el.focus()
};
}
function widgetMultiSelect(choices, initial) {
const el = document.createElement('select');
el.multiple = true;
el.size = Math.min(6, choices.length);
const initialSet = {};
const initArr = Array.isArray(initial) ? initial : [];
for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
if (initialSet[opt.value]) opt.selected = true;
el.appendChild(opt);
}
return {
element: el,
getValue: () => {
const out = [];
for (let i = 0; i < el.options.length; i++) {
if (el.options[i].selected) out.push(el.options[i].value);
}
return out;
},
focus: () => el.focus()
};
}
function stringify(v) {
if (v == null) return '';
if (typeof v === 'object') {
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
return String(v);
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
// Loose-string compare so number 42 == "42" from a text input.
return String(a) === String(b);
}
function navigateToRowForm(row) {
// Complex-type cells punt to the row's full form editor.
// The url field on each context row already points at
// <dir>/<id>.yaml.html — the form-mode re-edit URL.
if (!row || !row.url) return;
const nav = (window.tablesApp && window.tablesApp.navigateTo)
|| function (u) { window.location.assign(u); };
nav(row.url);
}
// --- Keyboard nav -------------------------------------------------
function moveSelection(dir) {
if (!app.state.selected) return;
let { row: r, col: c } = app.state.selected;
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) return;
switch (dir) {
case 'up': r = Math.max(0, r - 1); break;
case 'down': r = Math.min(total - 1, r + 1); break;
case 'left': c = Math.max(0, c - 1); break;
case 'right': c = Math.min(cols - 1, c + 1); break;
case 'home': c = 0; break;
case 'end': c = cols - 1; break;
case 'home-row': r = 0; c = 0; break;
case 'end-row': r = total - 1; c = cols - 1; break;
case 'left-wrap':
if (c > 0) { c--; }
else if (r > 0) { r--; c = cols - 1; }
break;
case 'right-wrap':
if (c < cols - 1) { c++; }
else if (r < total - 1) { r++; c = 0; }
break;
}
setSelected(r, c);
}
function onCellKey(ev) {
if (app.state.editing) return; // input owns its own keys
if (!app.state.selected) return;
const isRangeKey = ev.shiftKey;
switch (ev.key) {
case 'ArrowUp':
ev.preventDefault();
isRangeKey ? extendRange('up') : moveSelection('up');
return;
case 'ArrowDown':
ev.preventDefault();
isRangeKey ? extendRange('down') : moveSelection('down');
return;
case 'ArrowLeft':
ev.preventDefault();
isRangeKey ? extendRange('left') : moveSelection('left');
return;
case 'ArrowRight':
ev.preventDefault();
isRangeKey ? extendRange('right') : moveSelection('right');
return;
case 'Home':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
else moveSelection('home');
return;
case 'End':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
else moveSelection('end');
return;
case 'Tab':
ev.preventDefault();
moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
return;
case 'Enter':
case 'F2':
ev.preventDefault();
enterEdit();
return;
case 'Escape':
ev.preventDefault();
clearSelection();
clearRange();
return;
case 'Delete':
case 'Backspace':
ev.preventDefault();
bulkClearSelection();
return;
case 'd':
case 'D':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
bulkFill('down');
return;
}
break;
case 'r':
case 'R':
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
bulkFill('right');
return;
}
break;
}
if (isPrintableKey(ev)) {
// Replace value with the typed character (Excel convention).
ev.preventDefault();
enterEdit(ev.key);
}
}
// --- Range selection (multi-cell ops) -----------------------------
function extendRange(dir) {
if (!app.state.selected) return;
const range = ensureRange();
let { row: r, col: c } = range.focus;
const total = rowCount();
const cols = colCount();
switch (dir) {
case 'up': r = Math.max(0, r - 1); break;
case 'down': r = Math.min(total - 1, r + 1); break;
case 'left': c = Math.max(0, c - 1); break;
case 'right': c = Math.min(cols - 1, c + 1); break;
}
range.focus = { row: r, col: c };
applyRangeSelectionStyles(range);
}
function ensureRange() {
if (!app.state.range) {
const sel = app.state.selected;
app.state.range = {
anchor: { row: sel.row, col: sel.col },
focus: { row: sel.row, col: sel.col },
};
}
return app.state.range;
}
function clearRange() {
app.state.range = null;
const t = tableEl();
if (!t) return;
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].classList.remove('zddc-table__cell--in-range');
}
}
function applyRangeSelectionStyles(range) {
const t = tableEl();
if (!t) return;
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].classList.remove('zddc-table__cell--in-range');
}
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const cell = cellAt(r, c);
if (cell) cell.classList.add('zddc-table__cell--in-range');
}
}
}
function rangeCells() {
// Returns an array of {rowIdx, colIdx, row, col} for every
// cell in the current range — or just the selected cell if
// no range is active. Skips cells whose row data can't be
// resolved (defensive).
const out = [];
const range = app.state.range;
if (range) {
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
for (let r = r0; r <= r1; r++) {
const row = rowDataAt(r);
if (!row) continue;
for (let c = c0; c <= c1; c++) {
const col = colAt(c);
if (col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
}
}
return out;
}
if (!app.state.selected) return out;
const { row: r, col: c } = app.state.selected;
const row = rowDataAt(r);
const col = colAt(c);
if (row && col) out.push({ rowIdx: r, colIdx: c, row: row, col: col });
return out;
}
function bulkClearSelection() {
// Delete / Backspace in nav mode: clear every selected cell.
// Pushes one undo Command spanning all affected cells.
const cells = rangeCells();
if (cells.length === 0) return;
const undoCells = [];
for (let i = 0; i < cells.length; i++) {
const c = cells[i];
const oldRaw = app.modules.util.resolveField(c.row.data, c.col.field);
const priorDraft = getDraft(rowKey(c.row), c.col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(c.row), c.col.field, null);
undoCells.push({
rowId: rowKey(c.row),
field: c.col.field,
oldValue: undoOld,
newValue: null,
});
}
const undoMod = app.modules.undo;
if (undoMod) undoMod.push({ cells: undoCells });
if (typeof app.repaint === 'function') app.repaint();
}
function bulkFill(dir) {
// Ctrl+D fills the top row's values down through the range.
// Ctrl+R fills the left column's values right through the range.
// No-op when no range is active (Excel does the same).
const range = app.state.range;
if (!range) return;
const r0 = Math.min(range.anchor.row, range.focus.row);
const r1 = Math.max(range.anchor.row, range.focus.row);
const c0 = Math.min(range.anchor.col, range.focus.col);
const c1 = Math.max(range.anchor.col, range.focus.col);
const undoCells = [];
for (let r = r0; r <= r1; r++) {
const row = rowDataAt(r);
if (!row) continue;
for (let c = c0; c <= c1; c++) {
const col = colAt(c);
if (!col) continue;
const srcR = (dir === 'down') ? r0 : r;
const srcC = (dir === 'right') ? c0 : c;
if (r === srcR && c === srcC) continue;
const srcRow = rowDataAt(srcR);
const srcCol = colAt(srcC);
if (!srcRow || !srcCol) continue;
const value = effectiveCellValue(srcRow, srcCol);
const oldRaw = app.modules.util.resolveField(row.data, col.field);
const priorDraft = getDraft(rowKey(row), col.field);
const undoOld = (priorDraft !== undefined) ? priorDraft : oldRaw;
setDraft(rowKey(row), col.field, value);
undoCells.push({
rowId: rowKey(row),
field: col.field,
oldValue: undoOld,
newValue: value,
});
}
}
if (undoCells.length > 0) {
const undoMod = app.modules.undo;
if (undoMod) undoMod.push({ cells: undoCells });
if (typeof app.repaint === 'function') app.repaint();
}
}
// --- Wiring -------------------------------------------------------
function attachToTable() {
const t = tableEl();
if (!t) return;
t.setAttribute('role', 'grid');
t.addEventListener('keydown', onCellKey);
}
function attachToRow(tr, rowId) {
tr.setAttribute('role', 'row');
tr.setAttribute('data-row-id', rowId);
}
function attachToCell(td, rowIdx, colIdx) {
td.setAttribute('role', 'gridcell');
td.setAttribute('data-col-idx', String(colIdx));
td.setAttribute('data-row-idx', String(rowIdx));
td.setAttribute('tabindex', '-1');
td.addEventListener('click', function (ev) {
ev.stopPropagation();
if (ev.shiftKey && app.state.selected) {
// Shift+click extends the range from the existing
// anchor to the clicked cell.
const range = ensureRange();
range.focus = { row: rowIdx, col: colIdx };
applyRangeSelectionStyles(range);
// Move tabindex/focus marker to the clicked cell but
// keep the anchor in place.
setSelected(rowIdx, colIdx, { keepRange: true });
} else {
clearRange();
setSelected(rowIdx, colIdx);
}
});
td.addEventListener('dblclick', function (ev) {
ev.stopPropagation();
clearRange();
setSelected(rowIdx, colIdx, { noFocus: true });
enterEdit();
});
}
app.modules.editor = {
attachToTable: attachToTable,
attachToRow: attachToRow,
attachToCell: attachToCell,
setSelected: setSelected,
clearSelection: clearSelection,
moveSelection: moveSelection,
enterEdit: enterEdit,
rowKey: rowKey,
getDraft: getDraft,
setDraft: setDraft,
clearDraftField: clearDraftField,
effectiveCellValue: effectiveCellValue
};
})(window.tablesApp);

View file

@ -5,17 +5,13 @@
// - free-text: { kind: 'contains', value: '<string>' }
// - enum: { kind: 'enum', value: ['<choice>', ...] }
// 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) {
return Array.isArray(col.enum) && col.enum.length > 0;
}
function defaultFilterFor(_col) {
return { kind: 'contains', value: '' };
function defaultFilterFor(col) {
return isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' };
}
function rowMatches(filter, cellValue) {

View file

@ -2,12 +2,6 @@
'use strict';
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();
app.context = ctx;
@ -29,22 +23,6 @@
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
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 allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
@ -62,12 +40,14 @@
if (seeded == null) {
continue;
}
// Filter UI is uniformly text-contains. If the spec
// seeds an array (legacy enum-style), coerce to a
// comma-joined contains string — partial match on any
// listed value still narrows the table sensibly.
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
state.filter[col.field] = { kind: 'contains', value: seedStr };
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) };
}
}
}
@ -94,29 +74,7 @@
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
}
}
// Public re-paint entry point so other modules (save.useMine /
// save.reload) can request a refresh after they mutate row state.
app.repaint = paint;
function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);

View file

@ -1,76 +0,0 @@
// 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,12 +22,33 @@
titleRow.appendChild(th);
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);
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',
@ -39,6 +60,7 @@
}
});
td.appendChild(input);
}
filterRow.appendChild(td);
}
@ -48,33 +70,32 @@
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
const editor = app.modules.editor;
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'
});
const rowId = editor ? editor.rowKey(row) : (row.url || '');
if (editor) {
editor.attachToRow(tr, rowId);
'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];
// Editor's draft buffer overrides the row's stored value
// until Phase 3 commits it. Falls back to row.data when
// no draft is present.
const value = editor
? editor.effectiveCellValue(row, col)
: util.resolveField(row.data, col.field);
const text = util.formatCell(value, col.format);
const td = util.h('td', { className: 'zddc-table__cell' }, text);
if (editor) {
editor.attachToCell(td, i, c);
}
tr.appendChild(td);
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);
}

View file

@ -1,411 +0,0 @@
// save.js — Phase 3 of editable-cell mode.
//
// Row-level batch save on row-blur. While the user is editing cells
// inside a row, draft values accumulate in app.state.drafts. When the
// editor's selection moves to a different row (or focus leaves the
// grid entirely), this module fires one PUT for the row that lost
// focus, with full merged data + If-Match for the row's tracked ETag.
//
// Three response paths:
//
// - 200 / 201 / 202: success or queued-offline (cache outbox).
// Drafts clear, row.data merges, new ETag captured. Row's
// "dirty" indicator drops.
//
// - 412 Precondition Failed: someone else changed this row since
// we read it. Drafts STAY — never silently discard the user's
// typing. Row gets a "stale" badge with [Use mine] / [Reload]
// in the page status bar. "Use mine" re-GETs the row to pick up
// the new ETag and server data, replays drafts on top, re-PUTs
// (this is the client-side field-level LWW trick from the
// architecture report — fields the user didn't touch get the
// server's new values automatically). "Reload" drops drafts and
// refreshes from server.
//
// - 422 Unprocessable Entity: server-side schema validation failed.
// Body is {errors: [{path, message}, ...]}. Each path → field,
// marked with a red corner on the cell. Drafts stay so the user
// can correct in place.
//
// - Other (4xx / 5xx / network): row marked errored with the
// status code; drafts stay.
//
// Outbox transparency: when running through a downstream client, the
// PUT is intercepted by the cache layer; on local network failure
// it's queued and the response is 202 Accepted with X-ZDDC-Cache:
// queued. We treat 202 as success-ish — drafts clear, indicator
// shows a small "queued" badge so the user knows the write hasn't
// reached upstream yet.
(function (app) {
'use strict';
function modules() {
return app.modules.editor;
}
function findRowById(rowId) {
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (modules().rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function mergeRow(data, drafts) {
// Shallow merge: drafts are field-level overrides on the row's
// top-level data object. Phase 2's complex-type cells punt to
// form-mode and never produce drafts here, so drafts only
// contain primitive / string-array values that are safe to
// overwrite the corresponding top-level field.
return Object.assign({}, data || {}, drafts || {});
}
function rowFromState(rowId) {
return {
row: findRowById(rowId),
drafts: (app.state.drafts && app.state.drafts[rowId]) || null,
};
}
// --- Visual state markers ----------------------------------------
function setRowState(rowId, stateName) {
// Apply a CSS state class to the row matching rowId. States:
// "" / null — no marker
// "dirty" — has uncommitted drafts
// "saving" — PUT in flight
// "stale" — server returned 412
// "errored" — server returned 4xx/5xx other than 412/422
// "queued" — write went into the outbox
// "invalid" — server returned 422
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
const stateClasses = ['dirty', 'saving', 'stale', 'errored', 'queued', 'invalid'];
for (let i = 0; i < stateClasses.length; i++) {
tr.classList.remove('zddc-table__row--' + stateClasses[i]);
}
if (stateName) tr.classList.add('zddc-table__row--' + stateName);
}
function markCellInvalid(rowId, field, message) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
// Walk the column list to find the field's column index;
// data-col-idx is the numeric position rendered into each td.
const cols = (app.context && app.context.columns) || [];
let idx = -1;
for (let i = 0; i < cols.length; i++) {
if (cols[i].field === field) { idx = i; break; }
}
if (idx < 0) return;
const target = tr.querySelector('[role="gridcell"][data-col-idx="' + idx + '"]');
if (!target) return;
target.classList.add('zddc-table__cell--invalid');
if (message) target.setAttribute('title', message);
}
function clearCellInvalid(rowId) {
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const tr = tbody.querySelector('tr[data-row-id="' + cssEscape(rowId) + '"]');
if (!tr) return;
const invalids = tr.querySelectorAll('.zddc-table__cell--invalid');
for (let i = 0; i < invalids.length; i++) {
invalids[i].classList.remove('zddc-table__cell--invalid');
invalids[i].removeAttribute('title');
}
}
function cssEscape(s) {
// CSS.escape if available; otherwise a defensive escape for
// the characters that appear in URL paths used as data-row-id
// values. Browsers everywhere modern enough to support the
// FS Access API have CSS.escape, so this is mostly defensive.
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, function (ch) {
return '\\' + ch;
});
}
// --- Status bar (stale-row prompt) --------------------------------
function showStatusPrompt(rowId, message, actions) {
// Renders into #table-status (hidden by default per template).
// actions = [{label, onClick}, ...]
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = '';
el.classList.add('table-status--prompt');
const span = document.createElement('span');
span.textContent = message;
el.appendChild(span);
for (let i = 0; i < (actions || []).length; i++) {
const a = actions[i];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-secondary btn-sm';
btn.textContent = a.label;
btn.addEventListener('click', a.onClick);
el.appendChild(btn);
}
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'btn btn-secondary btn-sm';
dismiss.textContent = '×';
dismiss.title = 'Dismiss';
dismiss.addEventListener('click', clearStatus);
el.appendChild(dismiss);
el.hidden = false;
el.setAttribute('data-row-id', rowId);
}
function clearStatus() {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = '';
el.hidden = true;
el.removeAttribute('data-row-id');
el.classList.remove('table-status--prompt');
}
// --- The save itself ---------------------------------------------
async function saveRow(rowId, opts) {
opts = opts || {};
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts || Object.keys(drafts).length === 0) {
return { status: 'noop' };
}
if (!row.yamlUrl) {
// file:// mode or rows from inline-context test fixtures
// don't have a URL to PUT to — bail silently.
return { status: 'no-url' };
}
if (row.editable === false) {
// Row is read-only per the server. Don't even try.
return { status: 'readonly' };
}
setRowState(rowId, 'saving');
const merged = mergeRow(row.data, drafts);
const yamlBody = window.jsyaml.dump(merged);
const headers = { 'Content-Type': 'application/yaml; charset=utf-8' };
if (row.etag) headers['If-Match'] = '"' + row.etag + '"';
const fetchOpts = {
method: 'PUT',
body: yamlBody,
headers: headers,
credentials: 'same-origin',
};
// The unload path passes keepalive:true so the PUT outlives the
// page navigation. Subject to the spec's 64 KB body cap — large
// rows may fail in that path; normal saves are unaffected.
if (opts.keepalive) fetchOpts.keepalive = true;
let resp;
try {
resp = await fetch(row.yamlUrl, fetchOpts);
} catch (err) {
// Network failure — outbox-fronted client should still
// resolve with 202; reaching here means a hard client-side
// network error. Mark errored, drafts stay.
console.error('[tables] save network error', err);
setRowState(rowId, 'errored');
return { status: 'network-error', error: err };
}
if (resp.status === 200 || resp.status === 201) {
// Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, '');
row.data = merged;
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
// If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
return { status: 'ok' };
}
if (resp.status === 202) {
// Outbox queued. Drafts clear (they're persisted in the
// outbox; the server will replay them on reconnect), but
// the row stays marked queued so the user knows.
row.data = merged;
delete app.state.drafts[rowId];
setRowState(rowId, 'queued');
return { status: 'queued' };
}
if (resp.status === 412) {
// Precondition Failed — someone else changed the row.
// Drafts STAY. Surface the prompt.
setRowState(rowId, 'stale');
showStatusPrompt(
rowId,
'This row was changed by someone else. ',
[
{ label: 'Use mine', onClick: () => useMine(rowId) },
{ label: 'Reload', onClick: () => reload(rowId) },
]
);
return { status: 'conflict' };
}
if (resp.status === 422) {
// Validation errors. Body shape matches the form system's
// 422 response: {errors: [{path: "/field", message}, ...]}.
let body = {};
try { body = await resp.json(); } catch (_) { /* ignore */ }
clearCellInvalid(rowId);
const errs = body.errors || [];
for (let i = 0; i < errs.length; i++) {
const e = errs[i];
const field = String(e.path || '').replace(/^\//, '').split('/')[0];
if (field) markCellInvalid(rowId, field, e.message || 'invalid');
}
setRowState(rowId, 'invalid');
return { status: 'invalid', errors: errs };
}
// Other status — generic error.
console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status };
}
async function useMine(rowId) {
const { row, drafts } = rowFromState(rowId);
if (!row || !drafts) return;
// Re-GET the row to learn the latest server state + ETag.
try {
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
if (!resp.ok) {
console.warn('[tables] reload on conflict failed', resp.status);
return;
}
const text = await resp.text();
const fresh = window.jsyaml.load(text) || {};
row.data = fresh;
const newEtag = resp.headers.get('ETag');
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
} catch (err) {
console.error('[tables] reload on conflict error', err);
return;
}
// Drafts preserved — replay against the new base.
return saveRow(rowId);
}
async function reload(rowId) {
const row = findRowById(rowId);
if (!row) return;
try {
const resp = await fetch(row.yamlUrl, { credentials: 'same-origin' });
if (!resp.ok) return;
const text = await resp.text();
row.data = window.jsyaml.load(text) || {};
const newEtag = resp.headers.get('ETag');
row.etag = newEtag ? newEtag.replace(/"/g, '') : null;
} catch (_) { return; }
delete app.state.drafts[rowId];
clearCellInvalid(rowId);
setRowState(rowId, '');
clearStatus();
// Trigger a re-paint via the public app callback if one exists.
if (typeof app.repaint === 'function') app.repaint();
}
// --- Trigger: row-blur ------------------------------------------
let _previousSelectedRowId = null;
function trackSelectionChange(prevRowId, nextRowId) {
// Fires when the editor's selection changes rows. If prevRow
// had drafts, save it now. nextRow can be null (focus left
// the grid) — also a save trigger.
if (prevRowId && prevRowId !== nextRowId) {
const drafts = app.state.drafts && app.state.drafts[prevRowId];
if (drafts && Object.keys(drafts).length > 0) {
// Fire and forget. The user has moved on; we don't
// want to block their flow waiting for the server.
saveRow(prevRowId).catch(err => {
console.error('[tables] saveRow rejection', err);
});
}
}
}
function onSelectionChanged(selected) {
const prevRowId = _previousSelectedRowId;
const nextRowId = selected ? rowIdAtIndex(selected.row) : null;
if (prevRowId !== nextRowId) {
trackSelectionChange(prevRowId, nextRowId);
_previousSelectedRowId = nextRowId;
}
// Mark dirty rows visually whenever selection settles.
markAllDirtyRows();
}
function rowIdAtIndex(visibleRowIdx) {
const tr = document.querySelectorAll('#table-root tbody > tr')[visibleRowIdx];
return tr ? tr.getAttribute('data-row-id') : null;
}
function markAllDirtyRows() {
// After a re-paint or selection change, re-apply dirty state
// to any row that has drafts (CSS classes don't survive
// tbody.innerHTML='' in renderBody).
const drafts = app.state.drafts || {};
const tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
const trs = tbody.querySelectorAll('tr');
for (let i = 0; i < trs.length; i++) {
const tr = trs[i];
const rowId = tr.getAttribute('data-row-id');
if (rowId && drafts[rowId] && Object.keys(drafts[rowId]).length > 0) {
if (!tr.classList.contains('zddc-table__row--saving') &&
!tr.classList.contains('zddc-table__row--stale') &&
!tr.classList.contains('zddc-table__row--invalid') &&
!tr.classList.contains('zddc-table__row--errored') &&
!tr.classList.contains('zddc-table__row--queued')) {
tr.classList.add('zddc-table__row--dirty');
}
}
}
}
function flushAllDrafts() {
// Page-unload safety net. Best-effort: any row with drafts
// gets one final save attempt. fetch() is async, the page may
// already be navigating; we just kick the requests off.
const drafts = app.state.drafts || {};
const ids = Object.keys(drafts);
for (let i = 0; i < ids.length; i++) {
saveRow(ids[i], { keepalive: true }).catch(() => {});
}
}
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
window.addEventListener('beforeunload', function (_ev) {
flushAllDrafts();
});
app.modules.save = {
saveRow: saveRow,
useMine: useMine,
reload: reload,
onSelectionChanged: onSelectionChanged,
markAllDirtyRows: markAllDirtyRows,
flushAllDrafts: flushAllDrafts,
};
})(window.tablesApp);

View file

@ -1,115 +0,0 @@
// undo.js — Phase 5 of editable-cell mode.
//
// Linear command stack, depth 50, session-local. Every successful
// per-cell edit and every bulk operation (paste, fill, delete) push
// a Command onto the stack. Ctrl/Cmd+Z pops the most recent and
// replays the inverse — sets each affected cell's draft buffer
// back to its `oldValue` (or clears the draft when oldValue was
// the row's stored value), then triggers a re-paint and the
// row-blur save flow picks the change up like any other edit.
//
// Why local-only: shared undo across multiple users is conceptually
// broken under last-writer-wins (undoing my edit might revert
// someone else's intervening edit). Every production grid keeps
// undo per-tab; we follow.
//
// Why no redo: minimum viable. Adding redo is a parallel forward
// stack cleared on any new edit. Cheap to add later if users miss
// it.
//
// Command shape:
// { cells: [ {rowId, field, oldValue, newValue}, ... ] }
//
// One-cell edits push a single-cell Command. Bulk operations push
// one Command with N cells so a single Ctrl+Z reverts the whole
// group.
(function (app) {
'use strict';
const STACK_MAX = 50;
const _stack = [];
function push(cmd) {
if (!cmd || !cmd.cells || cmd.cells.length === 0) return;
_stack.push(cmd);
if (_stack.length > STACK_MAX) {
_stack.shift();
}
}
function depth() { return _stack.length; }
function clear() { _stack.length = 0; }
function undo() {
const cmd = _stack.pop();
if (!cmd || !cmd.cells || cmd.cells.length === 0) return null;
const editor = app.modules.editor;
if (!editor) return null;
for (let i = 0; i < cmd.cells.length; i++) {
const c = cmd.cells[i];
// Compare oldValue to the row's stored data — if they
// match, clear the draft (the user's edit is being
// reversed back to baseline). Otherwise set draft = old.
const row = findRow(c.rowId);
if (!row) continue;
const stored = app.modules.util.resolveField(row.data, c.field);
if (sameValue(stored, c.oldValue)) {
editor.clearDraftField(c.rowId, c.field);
} else {
editor.setDraft(c.rowId, c.field, c.oldValue);
}
}
if (typeof app.repaint === 'function') app.repaint();
return cmd;
}
function findRow(rowId) {
const editor = app.modules.editor;
const all = (app.state && app.state.rows) || [];
for (let i = 0; i < all.length; i++) {
if (editor.rowKey(all[i]) === rowId) return all[i];
}
return null;
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
return String(a) === String(b);
}
// Hotkey: Ctrl+Z (Cmd+Z on macOS). Bound at the document level
// so the user can undo from anywhere on the page, not just from
// within a focused cell.
function onKey(ev) {
const isMod = ev.ctrlKey || ev.metaKey;
if (!isMod) return;
if (ev.key === 'z' || ev.key === 'Z') {
// Skip when the active element is a text-input-like; we
// don't want to override the browser's intra-input undo.
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
return;
}
ev.preventDefault();
undo();
}
}
document.addEventListener('keydown', onKey);
app.modules.undo = {
push: push,
undo: undo,
depth: depth,
clear: clear,
};
})(window.tablesApp);

View file

@ -31,8 +31,7 @@
</div>
</header>
<!-- Table mode: shown for /<dir>/table.html requests. -->
<main id="table-mode" class="table-main" hidden>
<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">
@ -40,9 +39,6 @@
<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 class="table-toolbar__right">
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>
<div class="table-scroll">
<table id="table-root" class="zddc-table" aria-describedby="table-description">
@ -53,18 +49,6 @@
<div id="table-empty" class="table-empty" hidden>No rows match the current filters.</div>
</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 -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
@ -73,85 +57,27 @@
</div>
<div class="help-panel__body">
<h3>What is this table?</h3>
<p>The directory you opened — say <code>archive/Acme/mdl/</code>
<em>is</em> the table. <code>table.yaml</code> describes the
columns; <code>form.yaml</code> describes the row-edit form
schema; every other <code>.yaml</code> file in the directory
is one row. Copying the directory anywhere takes the whole
table (spec + form + every row) with it.</p>
<h3>Editing cells</h3>
<p>Click a cell to select it. Then:</p>
<dl>
<dt><kbd></kbd> / <kbd></kbd> / <kbd></kbd> / <kbd></kbd></dt>
<dd>Move selection. Hold <kbd>Shift</kbd> to extend a range.</dd>
<dt><kbd>Tab</kbd> / <kbd>Shift+Tab</kbd></dt>
<dd>Move right / left, wrap to next / previous row.</dd>
<dt><kbd>Enter</kbd> / <kbd>F2</kbd> / double-click / typing</dt>
<dd>Enter edit mode. Typing replaces the cell value; the
others keep it.</dd>
<dt><kbd>Enter</kbd> in edit mode</dt>
<dd>Commit and move down.</dd>
<dt><kbd>Tab</kbd> in edit mode</dt>
<dd>Commit and move right.</dd>
<dt><kbd>Esc</kbd></dt>
<dd>Cancel the edit; restore the prior value.</dd>
<dt><kbd>Delete</kbd> / <kbd>Backspace</kbd></dt>
<dd>Clear every cell in the current selection.</dd>
<dt><kbd>Ctrl+D</kbd> / <kbd>Ctrl+R</kbd></dt>
<dd>Fill the top row down / left column right through the
selected range.</dd>
<dt><kbd>Ctrl+C</kbd> / <kbd>Ctrl+V</kbd></dt>
<dd>Copy / paste — interoperates with Excel and Google
Sheets via tab-separated values.</dd>
<dt><kbd>Ctrl+Z</kbd></dt>
<dd>Undo the last edit (one history per session).</dd>
</dl>
<p>Edits save automatically when you move to a different row.
A small left-edge swatch on the row indicates state:
<strong>blue</strong> = unsaved, <strong>amber</strong> = the
server flagged a validation error, <strong>orange</strong> =
someone else changed this row since you loaded it (you'll
get a prompt with <em>Use mine</em> / <em>Reload</em>).</p>
<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. <kbd>Shift</kbd>-click another header to
add a secondary sort key.</p>
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). Same filter UI
for every column.</p>
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>Customizing the columns</h3>
<p>The default Master Deliverables List has columns for every
component of a tracking number
(<code>originator</code>, <code>phase</code>,
<code>project</code>, <code>area</code>,
<code>discipline</code>, <code>type</code>,
<code>sequence</code>, <code>suffix</code>) plus deliverable
metadata. To customize, drop your own
<code>table.yaml</code> (and matching
<code>form.yaml</code>) into this directory:</p>
<pre><code>archive/&lt;party&gt;/mdl/
table.yaml ← columns + sort/filter defaults
form.yaml ← per-row schema (JSON Schema)
&lt;id&gt;.yaml ... ← rows</code></pre>
<p>Operator-supplied files override the embedded defaults.
Hide a column by omitting it from <code>columns:</code>;
add a column by appending one (and adding the matching
property in <code>form.yaml</code>'s
<code>schema.properties</code>). The same pattern works
for any directory — <code>&lt;dir&gt;/table.html</code>
is automatically a table whenever
<code>&lt;dir&gt;/table.yaml</code> exists.</p>
<h3>Permissions</h3>
<p>Whether a row is editable depends on the cascading
<code>.zddc</code> permissions for the directory. Rows
in <code>Issued</code> or <code>Received</code> archives
are read-only by design (WORM).</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>
@ -175,12 +101,6 @@
-->
<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>
{{JS_PLACEHOLDER}}
</script>

View file

@ -87,56 +87,6 @@ test.describe('Archive Browser', () => {
expect(fileCountText).toBeTruthy();
});
test('a .zip transmittal folder is scanned like an uncompressed one', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// One plain transmittal folder + one delivered as a single .zip
// whose name parses as a transmittal-folder name. The zip's
// members should land in the file list exactly like the plain
// folder's files. (JSZip is bundled into archive.html.)
await page.evaluate(async () => {
const zip = new window.JSZip();
zip.file('789012-ME-CAL-0001_A (IFA) - Calculation.pdf', '%PDF calc');
zip.file('sub/789012-ME-DRW-0001_A (IFA) - Detail.dwg', 'DWG detail');
const buf = await zip.generateAsync({ type: 'uint8array' });
window.__setMockDirectoryTree('test-project', {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
},
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal.zip': buf,
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForFunction(() => window.app && Array.isArray(window.app.files) && window.app.files.length >= 3, { timeout: 10000 });
// 1 file from the plain folder + 2 from inside the zip.
const fileCount = await page.evaluate(() => window.app.files.length);
expect(fileCount).toBeGreaterThanOrEqual(3);
// The zip is surfaced as a transmittal folder, named without ".zip".
const zipFolder = await page.evaluate(() =>
window.app.transmittalFolders.find(f => /Second Transmittal$/.test(f.name)) || null);
expect(zipFolder).toBeTruthy();
expect(zipFolder.name).not.toMatch(/\.zip$/i);
// The zip's members are parsed like normal archive files (tracking
// numbers extracted) and tied to the zip transmittal's folder.
const memberTracking = await page.evaluate(() =>
window.app.files.filter(f => f.folderPath && /\.zip$/i.test(f.folderPath)).map(f => f.trackingNumber).sort());
expect(memberTracking).toEqual(['789012-ME-CAL-0001', '789012-ME-DRW-0001']);
// Select all grouping folders + render the table; zip members show.
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(300);
const rowCount = await page.locator('#filesTableBody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(3);
});
test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => {
// Multi-project layout: server root holds project folders, each containing an
// Archive/ subfolder with third-party folders. ?projects=A,B (set as
@ -488,11 +438,7 @@ test.describe('Archive Browser', () => {
test('Preview toggle is checked by default', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
// The checkbox lives inside the (hidden) preview-controls area
// before any directory is loaded — waitForSelector defaults to
// 'visible' which would time out. Wait for it to be ATTACHED
// and verify the underlying state instead.
await page.waitForSelector('#filePreviewToggle', { state: 'attached', timeout: 15000 });
await page.waitForSelector('#filePreviewToggle', { timeout: 15000 });
await expect(page.locator('#filePreviewToggle')).toBeChecked();
});

Some files were not shown because too many files have changed in this diff Show more