Compare commits
126 commits
ba20e3e5ba
...
adcf5dedd6
| Author | SHA1 | Date | |
|---|---|---|---|
| adcf5dedd6 | |||
| ba7e7a3fdd | |||
| 141fef88fb | |||
| 81e065e5b0 | |||
| db1f44cf74 | |||
| 2dc2d032a0 | |||
| 735fed89c2 | |||
| 5e4d4fefb3 | |||
| bb5e059477 | |||
| c8d0afd1b8 | |||
| 9aa587aac0 | |||
| 54dff4dcd3 | |||
| 2de2fdf92c | |||
| 918f330a6f | |||
| 9c7858c60a | |||
| d90975662f | |||
| 4b04f61e4b | |||
| 6310afa922 | |||
| 5e393cbeaf | |||
| 9d18047a46 | |||
| ea0d29ed17 | |||
| 2f08418fb0 | |||
| d84c1908f6 | |||
| 4af0d8ca7c | |||
| 5debd552ae | |||
| ab44d75d03 | |||
| 02bdf851c1 | |||
| e85d5fc660 | |||
| ee67b9e596 | |||
| d052e9fed3 | |||
| b1479c5104 | |||
| c87fb7f4fa | |||
| d8459b87c2 | |||
| dbb7f6c9b5 | |||
| cb2cf1ebe3 | |||
| d5638e9697 | |||
| 319a3c0ce7 | |||
| 436e8ca066 | |||
| 6d72f5c770 | |||
| 1f03631d2d | |||
| 6260aa4860 | |||
| 8be6c4d98b | |||
| 7904a99c21 | |||
| f2af379ff5 | |||
| 0b69367901 | |||
| 99c6eac0f2 | |||
| 89d96b784f | |||
| 0b382716e3 | |||
| d77981407c | |||
| 7d4d2dc9a2 | |||
| 875870501e | |||
| d6206b03e7 | |||
| 7ac2e1cc73 | |||
| e2c4700d32 | |||
| 9a98901683 | |||
| dc72df83e3 | |||
| 4acf348b21 | |||
| 677ac01b32 | |||
| baf5958174 | |||
| 33ce3886f2 | |||
| b3e8c4127f | |||
| 351b6555b7 | |||
| 4cd39998aa | |||
| 315d039880 | |||
| bc5fcf6c73 | |||
| 3fa2762c28 | |||
| cf5d7c2ea6 | |||
| 7fd96c7c78 | |||
| 837cf47924 | |||
| 6145bb0c87 | |||
| e51d9fe908 | |||
| 2f93fc1854 | |||
| 94323ea356 | |||
| 0959d57dc2 | |||
| 2aa29d1ec4 | |||
| 464c80c1e1 | |||
| 53eb58b90b | |||
| 13e929b029 | |||
| d1a5a14132 | |||
| 002e034119 | |||
| 45005d164e | |||
| d08dcce211 | |||
| b3cea9b7a8 | |||
| 03babd34d2 | |||
| 41e6576111 | |||
| 1d12cfe804 | |||
| 76e8dab009 | |||
| 3fc371752a | |||
| 702ccf3be0 | |||
| e9a7749153 | |||
| 585e84f2f4 | |||
| b10468d4e3 | |||
| 7ced0395b6 | |||
| cc515b0f56 | |||
| 8ba029612e | |||
| 538167b5c8 | |||
| 0c48a583ad | |||
| 0024172be6 | |||
| 3baf160c88 | |||
| 346cbba688 | |||
| bf5ea7aa4f | |||
| c58511232f | |||
| 81b687a2eb | |||
| 0ad5b7dc0d | |||
| b7df50f458 | |||
| 3a4a1c7f39 | |||
| d3cd662740 | |||
| 8e703dc61a | |||
| cd751eb604 | |||
| e5bb7f216c | |||
| 08ce8a1266 | |||
| e6d9966593 | |||
| 2ce5336289 | |||
| 85521b98de | |||
| dd889b4801 | |||
| 66232598db | |||
| ac7553f940 | |||
| 70d49ba111 | |||
| e19667b5a2 | |||
| 55852a9efb | |||
| 8a049ca2a4 | |||
| 707f1d8ec2 | |||
| ca00904f1e | |||
| 97ffaac13b | |||
| 562b105550 | |||
| 0ad47561ed |
194 changed files with 41235 additions and 5295 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -43,3 +43,11 @@ 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
153
AGENTS.md
|
|
@ -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 seven)
|
||||
# (cascades alpha + beta → new stable; tags all nine)
|
||||
./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 declared in `.zddc tables:` as a sortable table whose rows click through to the form editor (see "Form-data system" and "Tables system" below).
|
||||
Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below).
|
||||
|
||||
```
|
||||
tool/
|
||||
|
|
@ -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 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).
|
||||
- `{{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).
|
||||
- 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 seven 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 nine artifacts at that commit. `./deploy --releases` then publishes the bundle.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
- **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.
|
||||
- **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 seven). The rules below are still on you.
|
||||
The build enforces lockstep mechanically (one command bumps all nine). 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,18 +284,11 @@ 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). 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).
|
||||
- **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".**
|
||||
|
||||
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.
|
||||
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.)
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -318,7 +311,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
|
||||
- Runtime CDN loads (jszip, docx-preview, xlsx) are allowed only for the optional DOCX/XLSX preview; core features work offline
|
||||
- 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."
|
||||
- Published payload stored in `<script id="transmittal-data" type="application/json">`
|
||||
|
||||
## mdedit-specific
|
||||
|
|
@ -333,13 +326,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`):
|
||||
- `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
|
||||
**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
|
||||
|
||||
**Storage**: spec at `<dir>/<name>.form.yaml`, submissions at `<dir>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml`. Submissions folder is created lazily; ACL applies via the existing `.zddc` cascade.
|
||||
**Storage**: spec at `<dir>/form.yaml`, submissions at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (siblings of the spec). Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
|
||||
|
||||
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
|
||||
|
||||
|
|
@ -347,7 +340,38 @@ 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**: drop a `<name>.form.yaml` into any path users can write to (per `.zddc` ACL). No code change required. Visit `<that-path>/<name>.form.html`.
|
||||
**Adding a new form**: create a directory `<dir>/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `<dir>/form.html`.
|
||||
|
||||
## Tables system (`tables/` + zddc-server table handler)
|
||||
|
||||
Read/aggregate counterpart to the form system. Renders a directory of YAML row files as a sortable, filterable table; each row clicks through to its `<id>.yaml.html` form editor. The tables tool (`tables/`) is the renderer; the server-side recognizer is `zddc/internal/handler/tablehandler.go RecognizeTableRequest`.
|
||||
|
||||
**Discovery is presence-based**, the same convention as forms: a `<dir>/table.yaml` on disk auto-mounts at `<dir>/table.html`. The directory is the table.
|
||||
|
||||
**Storage** (self-contained directory):
|
||||
|
||||
```
|
||||
<dir>/
|
||||
table.yaml ← spec
|
||||
form.yaml ← row-edit form (paired with table.yaml)
|
||||
<id>.yaml ... ← rows
|
||||
```
|
||||
|
||||
`table.yaml` and `form.yaml` are excluded from the rows list. Each row is also a form submission — the same files the form system reads — so the table view and the per-row form editor are two views of one folder of YAMLs. Copying `<dir>/` elsewhere copies the entire table (spec + form + every row) — that's the whole point of the in-dir layout.
|
||||
|
||||
**One table per directory** by construction (the spec is the singleton `table.yaml`). No `.zddc` reference needed; presence-based discovery is the entire rule. To make a directory a table, drop a `table.yaml` in it — that's it.
|
||||
|
||||
**Subfolders inside a table dir are allowed and silently ignored as rows.** The rows iterator filters non-`.yaml` entries, so directories don't show up in the table view. Legitimate subfolder use cases:
|
||||
- **Nested sub-tables** — `<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
|
||||
- **Per-row attachments** — `<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
|
||||
- **Drafts / staging** — `<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
|
||||
- **Future per-row history** — `<dir>/.history/<id>/<timestamp>.yaml` if/when version sidecars are added.
|
||||
|
||||
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
|
||||
|
||||
**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`.
|
||||
|
||||
## Implementation-vs-dependency policy
|
||||
|
||||
|
|
@ -446,15 +470,92 @@ 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 six 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 eight HTML-tool tags.
|
||||
|
||||
```sh
|
||||
./build release # lockstep stable, coordinated next version
|
||||
|
|
@ -466,7 +567,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 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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
269
ARCHITECTURE.md
269
ARCHITECTURE.md
|
|
@ -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}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
|
||||
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing, form, tables, browse}. `<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}/` 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,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.
|
||||
|
||||
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 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).
|
||||
`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).
|
||||
|
||||
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 six 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 eight 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 seven tools (6 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
|
||||
Three release channels, applied in lockstep across all nine artifacts (8 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
|
||||
|
||||
- **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).
|
||||
- **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).
|
||||
- **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` overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.
|
||||
- **Alpha** — `./build alpha` overwrites only the alpha mirrors, all nine artifacts. 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,20 +210,32 @@ 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 |
|
||||
| transmittal | jszip, docx-preview, xlsx | CDN at runtime | Optional preview features; tool works without them |
|
||||
| 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 |
|
||||
|
||||
**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:
|
||||
**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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
**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.
|
||||
`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.
|
||||
|
||||
### Development vs Production
|
||||
|
||||
|
|
@ -278,17 +290,29 @@ main.js ← Initialization (depends on all modules)
|
|||
|
||||
### State Management
|
||||
|
||||
Tools manage state in one of two patterns:
|
||||
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.
|
||||
|
||||
**1. Direct state on `window.app`** (archive, classifier, mdedit)
|
||||
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
|
||||
|
||||
```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 re-render calls. Classifier additionally layers a small pub-sub on top via `store.js` (`store.on('files', render)`).
|
||||
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
|
||||
|
||||
**2. Proxy-based reactive state** (transmittal)
|
||||
**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)
|
||||
|
||||
```javascript
|
||||
const state = createReactiveState({ mode: 'edit', published: false });
|
||||
|
|
@ -296,7 +320,26 @@ state.subscribe((prop, newVal) => { /* auto-update UI */ });
|
|||
state.mode = 'view'; // Proxy notifies all subscribers automatically
|
||||
```
|
||||
|
||||
Use reactive state when the same property drives multiple independent UI elements. Use direct state when the data flow is simple and unidirectional.
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -459,15 +502,151 @@ none of them is load-bearing alone.
|
|||
|
||||
| Layer | Job | Implementation |
|
||||
|---|---|---|
|
||||
| Authentication | Establish caller identity (email) | Delegated to upstream proxy via `X-Auth-Request-Email`; zddc-server does not authenticate |
|
||||
| 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 |
|
||||
| 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
|
||||
|
|
@ -511,14 +690,34 @@ 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.
|
||||
|
||||
#### Special folders
|
||||
#### Canonical folders, URL routing & the `.zddc` cascade
|
||||
|
||||
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
|
||||
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.
|
||||
|
||||
- `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.
|
||||
The schema keys that drive built-in behavior:
|
||||
|
||||
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.
|
||||
| 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).
|
||||
|
||||
### File API (authenticated CRUD)
|
||||
|
||||
|
|
@ -535,7 +734,17 @@ 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, 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.
|
||||
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.
|
||||
|
||||
### Why the tool-rooted view matters for third-party containment
|
||||
|
||||
|
|
|
|||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -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/` — 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.
|
||||
- `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.
|
||||
- `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 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.
|
||||
- **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.
|
||||
- `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 seven tools)
|
||||
# (cascades alpha + beta → new stable; tags all nine artifacts)
|
||||
./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 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.)
|
||||
- **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.)
|
||||
- **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 (six 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 (nine 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.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -17,7 +17,9 @@ 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** | 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`. |
|
||||
| **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`. |
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -32,13 +34,13 @@ Quick example: `123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf`
|
|||
```bash
|
||||
git clone https://codeberg.org/VARASYS/ZDDC.git && cd ZDDC
|
||||
|
||||
sh build.sh # build all tools (writes to dist/ only)
|
||||
sh archive/build.sh # build one tool
|
||||
./build # dev build of every tool (writes to dist/ only)
|
||||
sh archive/build.sh # iterate on one HTML tool
|
||||
|
||||
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
|
||||
./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
|
||||
|
||||
npm install && npx playwright install chromium && npm test # tests
|
||||
./dev-server start # cache-busting HTTP on :8000
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ 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" \
|
||||
|
|
@ -35,9 +39,15 @@ 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" \
|
||||
|
|
|
|||
|
|
@ -643,12 +643,7 @@ input[type="checkbox"] {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Welcome screen list ─────────────────────────────────────────────────── */
|
||||
.welcome-list {
|
||||
text-align: left;
|
||||
margin: 0.5rem auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
/* .welcome-list lives in shared/base.css. */
|
||||
|
||||
/* ── Windows path tip (inside welcome screen) ────────────────────────────── */
|
||||
.windows-tip {
|
||||
|
|
@ -853,6 +848,15 @@ 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);
|
||||
|
|
|
|||
|
|
@ -5,18 +5,6 @@
|
|||
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;
|
||||
|
|
@ -203,24 +191,7 @@
|
|||
}
|
||||
|
||||
/* 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 / .empty-state__inner / .welcome-list live in shared/base.css. */
|
||||
|
||||
/* Project warning banner */
|
||||
.project-warning-banner {
|
||||
|
|
@ -245,16 +216,6 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,14 +74,39 @@
|
|||
}
|
||||
|
||||
// Auto-connect to the HTTP server
|
||||
// Derives the base URL from the current page's location
|
||||
// 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).
|
||||
async function autoConnectHttpSource() {
|
||||
var href = window.location.href;
|
||||
// Strip query string and fragment
|
||||
href = href.split('?')[0].split('#')[0];
|
||||
// Strip the filename to get the directory
|
||||
var lastSlash = href.lastIndexOf('/');
|
||||
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
var baseUrl;
|
||||
if (href.endsWith('/')) {
|
||||
// Directory URL, e.g. /Project-1/archive/ or /
|
||||
baseUrl = href;
|
||||
} else {
|
||||
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 + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-project mode is opt-in via ?projects= in the URL.
|
||||
// ?projects= absent → not multi-project; scan whatever the URL
|
||||
|
|
@ -103,6 +128,9 @@
|
|||
// 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' } });
|
||||
|
|
@ -111,6 +139,13 @@
|
|||
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) {
|
||||
|
|
@ -272,7 +307,7 @@
|
|||
function showHttpErrorState(message) {
|
||||
var el = document.getElementById('noDirectoryMessage');
|
||||
if (!el) return;
|
||||
var content = el.querySelector('.empty-state-content');
|
||||
var content = el.querySelector('.empty-state__inner');
|
||||
if (content) {
|
||||
content.innerHTML =
|
||||
'<h2>Could not connect to server</h2>' +
|
||||
|
|
@ -302,8 +337,8 @@
|
|||
function showUnsupportedBrowserMessage() {
|
||||
const app = document.getElementById('appContainer');
|
||||
app.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -223,19 +223,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
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 => {
|
||||
|
|
@ -58,6 +67,7 @@
|
|||
|
||||
window.app.modules.parser = {
|
||||
isTransmittalFolder,
|
||||
isTransmittalFolderZip,
|
||||
groupFilesByTrackingNumber,
|
||||
sortGroupedFiles,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,13 +46,24 @@
|
|||
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 n = escapeHtml(name);
|
||||
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>'
|
||||
: '';
|
||||
return '<div class="preset-project-item">'
|
||||
+ '<label class="preset-project-label">'
|
||||
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>'
|
||||
+ ' ' + n
|
||||
+ '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
|
||||
+ ' ' + nLabel + hint
|
||||
+ '</label>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
|
|
|||
|
|
@ -184,6 +184,29 @@
|
|||
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 {
|
||||
|
|
@ -479,6 +502,27 @@
|
|||
}
|
||||
} 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.
|
||||
|
|
|
|||
|
|
@ -632,8 +632,8 @@
|
|||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||||
|
||||
// XLSX is bundled into the dist HTML (shared/vendor/xlsx.full.min.js),
|
||||
// so window.XLSX is available synchronously — no runtime load.
|
||||
const arrayBuffer = await (file.handle
|
||||
? file.handle.getFile().then(f => f.arrayBuffer())
|
||||
: fetch(file.url).then(r => r.arrayBuffer()));
|
||||
|
|
|
|||
|
|
@ -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" style="font-size:1.1rem;">⟳</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</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" style="justify-content: flex-start;">
|
||||
<input type="checkbox"
|
||||
id="selectAllVisibleCheckbox"
|
||||
title="Select/deselect all visible files"
|
||||
style="margin-right: 0.5rem;">
|
||||
<div class="th-content th-content--start">
|
||||
<input type="checkbox"
|
||||
id="selectAllVisibleCheckbox"
|
||||
class="select-all-checkbox"
|
||||
title="Select/deselect all visible files">
|
||||
<span>Revisions</span>
|
||||
</div>
|
||||
<input type="text"
|
||||
|
|
@ -237,8 +237,8 @@
|
|||
</div>
|
||||
|
||||
<!-- No Directory Selected Message -->
|
||||
<div id="noDirectoryMessage" class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,16 @@ js_temp=$(mktemp)
|
|||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# CSS files: shared base first, then browse-specific.
|
||||
# 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).
|
||||
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"
|
||||
|
|
@ -31,15 +38,25 @@ 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"
|
||||
|
|
|
|||
|
|
@ -22,37 +22,9 @@ body {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Empty / first-paint state */
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
/* .empty-state / .empty-state__inner live in shared/base.css. */
|
||||
|
||||
.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; }
|
||||
/* .hidden lives in shared/base.css; no per-tool override needed. */
|
||||
|
||||
/* Status bar — shows transient errors/info */
|
||||
.status-bar {
|
||||
|
|
|
|||
|
|
@ -1,87 +1,112 @@
|
|||
/* Toolbar above the listing */
|
||||
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.browse-root {
|
||||
flex: 1;
|
||||
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;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browse-table-wrap {
|
||||
.browse-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.browse-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.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 */
|
||||
.breadcrumbs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.1rem 0;
|
||||
/* Hide the scrollbar but keep horizontal scroll for very deep paths */
|
||||
scrollbar-width: thin;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link {
|
||||
color: var(--primary);
|
||||
.breadcrumbs a,
|
||||
.breadcrumbs button {
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link:hover {
|
||||
background: var(--bg-hover, rgba(0,0,0,0.05));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link--current {
|
||||
.breadcrumbs a:hover,
|
||||
.breadcrumbs button:hover {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-link--current:hover {
|
||||
background: transparent;
|
||||
text-decoration: none;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-sep {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0.05rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.breadcrumbs .bc-root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
.breadcrumbs .bc-current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.bc-home-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
color: currentColor;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.toolbar__count {
|
||||
|
|
@ -90,195 +115,573 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
cursor: default;
|
||||
font-weight: normal;
|
||||
z-index: 0;
|
||||
/* ── Two-pane browse view ────────────────────────────────────────────────── */
|
||||
|
||||
.browse-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.browse-table thead .filter-row th:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filter-row__icon {
|
||||
display: inline-block;
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.column-filter {
|
||||
width: calc(100% - 1.5rem);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
.pane {
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-row th.col-name .column-filter {
|
||||
width: calc(100% - 1.7rem); /* leave space for the icon */
|
||||
.tree-pane {
|
||||
width: 360px;
|
||||
min-width: 200px;
|
||||
max-width: 60%;
|
||||
border-right: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-filter:focus {
|
||||
outline: 1px solid var(--primary);
|
||||
outline-offset: -1px;
|
||||
.tree-pane__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.browse-table th.sortable {
|
||||
cursor: pointer;
|
||||
.pane-resizer:hover,
|
||||
.pane-resizer.is-dragging {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-name__indent {
|
||||
flex: 0 0 auto;
|
||||
.preview-pane__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 2.1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex: 0 0 1rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.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-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: 0 0 1.1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tree-name__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-name__label.is-folder {
|
||||
.tree-row[data-isdir="true"] .tree-name__label,
|
||||
.tree-row[data-iszip="true"] .tree-name__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-name__label.is-file {
|
||||
cursor: pointer;
|
||||
/* ── 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);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tree-name__label.is-file:hover {
|
||||
text-decoration: underline;
|
||||
.upload-overlay__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Numeric columns right-aligned */
|
||||
.col-size, .col-date {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
.upload-overlay__path {
|
||||
margin-top: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.col-ext {
|
||||
color: var(--text-muted);
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 0.85rem;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Loading row */
|
||||
.tree-row--loading td {
|
||||
.tree-name__hint {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-control__select:focus {
|
||||
outline: 2px 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. */
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@
|
|||
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();
|
||||
|
||||
|
|
@ -23,8 +34,37 @@
|
|||
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') {
|
||||
|
|
|
|||
141
browse/js/download.js
Normal file
141
browse/js/download.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// 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
|
||||
};
|
||||
})();
|
||||
|
|
@ -69,6 +69,7 @@
|
|||
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');
|
||||
|
|
@ -85,6 +86,15 @@
|
|||
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() {
|
||||
|
|
@ -122,95 +132,296 @@
|
|||
var refresh = document.getElementById('refreshHeaderBtn');
|
||||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||||
|
||||
// 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]);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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]);
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
// Tree-row clicks (event delegation on tbody).
|
||||
// Tree-row clicks (event delegation on the tree body).
|
||||
// Click semantics on a folder 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');
|
||||
// - 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');
|
||||
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);
|
||||
} else {
|
||||
tree.toggleFolder(id);
|
||||
return;
|
||||
}
|
||||
// ZIPs don't navigate-into; toggle immediately.
|
||||
if (row.dataset.iszip === 'true') {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// File row: modifier-click → open URL in new tab if
|
||||
// available (server mode preserves the original URL,
|
||||
// useful for direct download / sharing).
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
||||
if (node.url) window.open(node.url, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
// Plain click → preview popup. Intercept default nav.
|
||||
// Plain click → preview in the right pane.
|
||||
e.preventDefault();
|
||||
state.selectedId = id;
|
||||
state.lastPreviewedNodeId = id;
|
||||
tree.render(); // refresh selection highlight
|
||||
var p = previewMod();
|
||||
if (p) p.showFilePreview(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.
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
showBrowseRoot: showBrowseRoot,
|
||||
applyResolvedViewMode: applyResolvedViewMode
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
69
browse/js/grid.js
Normal file
69
browse/js/grid.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -25,25 +25,26 @@
|
|||
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
|
||||
sort: { key: 'name', dir: 1 },
|
||||
|
||||
// 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
|
||||
},
|
||||
// Currently-selected tree node id (for highlight + pop-out).
|
||||
selectedId: null,
|
||||
lastPreviewedNodeId: null,
|
||||
|
||||
// View mode: 'browse' (tree + preview, default) | 'grid' (classifier).
|
||||
viewMode: 'browse',
|
||||
|
||||
// The tree's in-memory representation. Each node:
|
||||
// { 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).
|
||||
// { 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.
|
||||
// Stored flat in a Map keyed by id; render order derived
|
||||
// from a depth-first walk.
|
||||
nodes: new Map(),
|
||||
|
|
@ -52,6 +53,14 @@
|
|||
|
||||
// Single shared popup window for file preview (across
|
||||
// multiple file clicks). Same pattern as archive's preview.
|
||||
previewWindow: null
|
||||
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: ''
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -21,13 +21,21 @@
|
|||
function fromServerEntry(e) {
|
||||
// Server returns directory names with a trailing "/". Strip
|
||||
// it for display; the is_dir flag is the canonical signal.
|
||||
var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
|
||||
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
|
||||
: '';
|
||||
return {
|
||||
name: displayName,
|
||||
name: name,
|
||||
displayName: displayName,
|
||||
isDir: e.is_dir,
|
||||
size: e.size || 0,
|
||||
modTime: e.mod_time ? new Date(e.mod_time) : null,
|
||||
ext: e.is_dir ? '' : splitExt(displayName),
|
||||
ext: e.is_dir ? '' : splitExt(name),
|
||||
url: e.url || null,
|
||||
// FS-API specific (null in server mode):
|
||||
handle: null
|
||||
|
|
@ -62,12 +70,38 @@
|
|||
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
596
browse/js/preview-markdown.js
Normal file
596
browse/js/preview-markdown.js
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
// 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
// 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.
|
||||
// preview.js — file-preview rendering for the browse tool's right pane.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
|
@ -13,9 +16,7 @@
|
|||
var loader = window.app.modules.loader;
|
||||
var preview = window.zddc && window.zddc.preview;
|
||||
if (!preview) {
|
||||
// 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.');
|
||||
console.error('[browse] zddc.preview not loaded — preview disabled.');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
|
|
@ -32,23 +33,25 @@
|
|||
'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'
|
||||
'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'
|
||||
};
|
||||
|
||||
// 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)
|
||||
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';
|
||||
}
|
||||
|
||||
async function getArrayBuffer(node) {
|
||||
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');
|
||||
}
|
||||
// A zip member node carries a ZipFileHandle in node.handle, so
|
||||
// it falls through the same getFile() path as any local file.
|
||||
if (state.source === 'server' && node.url) {
|
||||
var resp = await fetch(node.url);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
|
|
@ -61,15 +64,11 @@
|
|||
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) {
|
||||
if (state.source === 'server' && node.url && node.zipParentId == null) {
|
||||
// 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) {
|
||||
return { url: node.url, fromServer: true };
|
||||
}
|
||||
var buf = await getArrayBuffer(node);
|
||||
|
|
@ -77,14 +76,134 @@
|
|||
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>';
|
||||
|
|
@ -128,64 +247,47 @@
|
|||
+ '</' + 'script></body></html>';
|
||||
}
|
||||
|
||||
async function renderTextInWindow(node, win) {
|
||||
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;
|
||||
var c = win.document.getElementById('previewContent');
|
||||
if (!c) return;
|
||||
try {
|
||||
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, '
|
||||
+ (text.length - MAX) + ' more chars — Download for full file)';
|
||||
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 MAX = 200000;
|
||||
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
|
||||
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>';
|
||||
}
|
||||
var pre = win.document.createElement('pre');
|
||||
pre.className = 'preview-text';
|
||||
pre.textContent = text;
|
||||
c.innerHTML = '';
|
||||
c.appendChild(pre);
|
||||
} catch (e) {
|
||||
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
async function renderInPopup(node) {
|
||||
var info;
|
||||
try {
|
||||
info = await getBlobUrl(node);
|
||||
} catch (e) {
|
||||
window.app.modules.events.statusError('Preview failed: ' + e.message);
|
||||
window.app.modules.events.statusError('Pop-out failed: ' + e.message);
|
||||
return;
|
||||
}
|
||||
var html = popupShell(node, info.url);
|
||||
|
||||
var win = state.previewWindow;
|
||||
if (win && !win.closed) {
|
||||
win.document.open();
|
||||
|
|
@ -201,7 +303,6 @@
|
|||
'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;
|
||||
}
|
||||
|
|
@ -210,30 +311,21 @@
|
|||
win.focus();
|
||||
state.previewWindow = win;
|
||||
}
|
||||
|
||||
// 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>';
|
||||
}
|
||||
}
|
||||
await renderInPopupWindow(node, win, info);
|
||||
}
|
||||
|
||||
window.app.modules.preview = { showFilePreview: showFilePreview };
|
||||
// ── 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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -14,13 +14,19 @@
|
|||
|
||||
function newNode(raw, parentId, depth) {
|
||||
var id = state.nextId++;
|
||||
// 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.
|
||||
// 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.
|
||||
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,
|
||||
|
|
@ -33,9 +39,11 @@
|
|||
loaded: false,
|
||||
childIds: [],
|
||||
isZip: isZip,
|
||||
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)
|
||||
_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
|
||||
};
|
||||
state.nodes.set(id, node);
|
||||
return node;
|
||||
|
|
@ -102,17 +110,14 @@
|
|||
parent.loaded = true;
|
||||
}
|
||||
|
||||
// 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().
|
||||
// Walk nodes in render order. Skips the children of a collapsed
|
||||
// expandable.
|
||||
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);
|
||||
}
|
||||
|
|
@ -149,166 +154,53 @@
|
|||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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 = node.depth * 1.2;
|
||||
var indent = 0.4 + node.depth * 1.0;
|
||||
var expandable = node.isDir || node.isZip;
|
||||
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
|
||||
var chevronClass = 'tree-name__chevron'
|
||||
+ (expandable ? '' : ' tree-name__chevron--leaf');
|
||||
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>';
|
||||
}
|
||||
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>'
|
||||
: '';
|
||||
return ''
|
||||
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
|
||||
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls
|
||||
+ '" data-id="' + node.id
|
||||
+ '" data-isdir="' + node.isDir
|
||||
+ '" 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>'
|
||||
+ 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>';
|
||||
+ '" data-iszip="' + node.isZip + '"'
|
||||
+ (node.virtual ? ' data-virtual="true"' : '')
|
||||
+ ' style="padding-left:' + indent + 'rem"'
|
||||
+ ' role="treeitem" tabindex="-1">'
|
||||
+ '<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>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var tbody = document.getElementById('browseTbody');
|
||||
if (!tbody) return;
|
||||
recomputeVisibility();
|
||||
var body = document.getElementById('treeBody');
|
||||
if (!body) return;
|
||||
var ids = visibleIds();
|
||||
var html = '';
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
html += rowHtml(state.nodes.get(ids[i]));
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
body.innerHTML = html;
|
||||
updateCount();
|
||||
updateSortHeaders();
|
||||
renderBreadcrumbs();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Count nodes that render at the root + every expanded subtree.
|
||||
function expandedSetSize() {
|
||||
var n = 0;
|
||||
function walk(ids) {
|
||||
|
|
@ -327,14 +219,8 @@
|
|||
function updateCount() {
|
||||
var el = document.getElementById('entryCount');
|
||||
if (!el) return;
|
||||
var visible = visibleIds().length;
|
||||
var total = expandedSetSize();
|
||||
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');
|
||||
el.textContent = total + ' item' + (total === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────────────
|
||||
|
|
@ -390,38 +276,64 @@
|
|||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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 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)
|
||||
// - 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
|
||||
async function loadChildren(node) {
|
||||
if (node.loaded) return;
|
||||
try {
|
||||
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 + '/');
|
||||
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)));
|
||||
} else if (node.isDir) {
|
||||
var raw;
|
||||
if (state.source === 'server') {
|
||||
|
|
@ -439,117 +351,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -647,15 +448,11 @@
|
|||
}
|
||||
render();
|
||||
},
|
||||
// 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;
|
||||
// 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);
|
||||
render();
|
||||
},
|
||||
pathFor: pathFor
|
||||
|
|
|
|||
220
browse/js/upload.js
Normal file
220
browse/js/upload.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// 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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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" style="font-size:1.1rem;">⟳</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</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,69 +34,67 @@
|
|||
</header>
|
||||
|
||||
<main id="appMain">
|
||||
<div id="emptyState" class="empty-state">
|
||||
<div class="empty-state__inner">
|
||||
<div id="emptyState" class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<h2>ZDDC Browse</h2>
|
||||
<p>A simple directory listing for ZDDC archives — and any directory.
|
||||
Pick how you want to browse:</p>
|
||||
<ul>
|
||||
<p>A two-pane file browser for ZDDC archives — and any directory.</p>
|
||||
<ul class="welcome-list">
|
||||
<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 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browseRoot" class="browse-root hidden">
|
||||
<div class="toolbar">
|
||||
<div class="browse-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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -111,66 +109,43 @@
|
|||
</div>
|
||||
<div class="help-panel__body">
|
||||
<h3>What is Browse?</h3>
|
||||
<p>Browse is a directory listing for ZDDC archives — and any directory. It works in two modes:</p>
|
||||
<p>Browse is the ZDDC file experience. Two top-level modes:</p>
|
||||
<dl>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
|
||||
<h3>Tree navigation</h3>
|
||||
<h3>Tree navigation (Browse mode)</h3>
|
||||
<dl>
|
||||
<dt>Click a folder</dt>
|
||||
<dd>Toggle expand/collapse on that folder.</dd>
|
||||
<dd>Expand or collapse it inline.</dd>
|
||||
<dt>Shift-click a folder</dt>
|
||||
<dd>Recursive expand or collapse — applies to the whole subtree.</dd>
|
||||
<dd>Recursive expand or collapse — the whole subtree.</dd>
|
||||
<dt>Click a file</dt>
|
||||
<dd>Open in the preview popup. Modifier-click (Ctrl/Cmd) or middle-click
|
||||
opens in a new tab.</dd>
|
||||
<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>
|
||||
<dt>ZIP files</dt>
|
||||
<dd>Behave as folders — click to inspect contents inline. JSZip is
|
||||
bundled, so this works offline.</dd>
|
||||
<dt>Column headers</dt>
|
||||
<dd>Click to sort; click again to reverse.</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>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>
|
||||
|
|
|
|||
27
build
27
build
|
|
@ -199,17 +199,20 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
|
||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
# 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.
|
||||
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html"
|
||||
echo "Populated zddc/internal/handler/tables.html for //go:embed"
|
||||
|
||||
# Same pattern for the tables renderer — embedded directly into the
|
||||
# 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
|
||||
|
|
@ -975,10 +978,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 embedded form +
|
||||
# tables templates.
|
||||
# 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).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ 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" \
|
||||
|
|
@ -33,10 +37,15 @@ 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" \
|
||||
|
|
|
|||
|
|
@ -28,38 +28,5 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
}
|
||||
/* Toast notifications come from shared/toast.css (.zddc-toast); the
|
||||
classifier-local .toast block was promoted there. */
|
||||
|
|
|
|||
|
|
@ -1,46 +1,8 @@
|
|||
/* Classifier layout — tokens from shared/base.css */
|
||||
|
||||
/* 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 / .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.drag-over {
|
||||
background: var(--primary-light);
|
||||
outline: 2px dashed var(--primary);
|
||||
|
|
@ -81,13 +43,6 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -411,14 +411,16 @@
|
|||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
/* 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 {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
.tree-empty h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,26 +6,19 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
* 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).
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
// Remove existing toast
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -403,8 +403,8 @@
|
|||
if (!container) return;
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||||
|
||||
// XLSX bundled into the dist HTML; window.XLSX is available
|
||||
// synchronously, no runtime load needed.
|
||||
const blob = await getFileBlob(file);
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
container.innerHTML = '';
|
||||
|
||||
if (window.app.folderTree.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No folders found</div>';
|
||||
container.innerHTML = '<div class="tree-empty">No folders found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,9 +129,15 @@
|
|||
</div>
|
||||
|
||||
<!-- Empty State — shown until a directory is selected -->
|
||||
<div id="welcomeScreen" class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div id="welcomeScreen" class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,19 @@ 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" \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/* form/ — ZDDC generic form renderer.
|
||||
Pulls theme tokens from shared/base.css; only adds form-specific layout. */
|
||||
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. */
|
||||
|
||||
.form-main {
|
||||
max-width: 800px;
|
||||
|
|
@ -10,20 +13,20 @@
|
|||
.form-status {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-status.is-error {
|
||||
background: var(--color-bg-alt);
|
||||
border-color: #c43;
|
||||
color: #c43;
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-status.is-success {
|
||||
background: var(--color-bg-alt);
|
||||
border-color: #283;
|
||||
color: #283;
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.form-root {
|
||||
|
|
@ -44,24 +47,24 @@
|
|||
}
|
||||
|
||||
.form-field__label .required-mark {
|
||||
color: #c43;
|
||||
color: var(--danger);
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
.form-field__description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-field__error {
|
||||
font-size: 0.85rem;
|
||||
color: #c43;
|
||||
color: var(--danger);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.form-field__help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +72,10 @@
|
|||
.form-field__textarea,
|
||||
.form-field__select {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #111);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
|
@ -86,14 +89,14 @@
|
|||
.form-field__input:focus,
|
||||
.form-field__textarea:focus,
|
||||
.form-field__select:focus {
|
||||
outline: 2px solid var(--color-primary, #1e3a5f);
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.form-field--invalid .form-field__input,
|
||||
.form-field--invalid .form-field__textarea,
|
||||
.form-field--invalid .form-field__select {
|
||||
border-color: #c43;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.form-field__radio-group,
|
||||
|
|
@ -113,8 +116,8 @@
|
|||
}
|
||||
|
||||
.form-fieldset {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -136,10 +139,10 @@
|
|||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-alt, #f6f6f8);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.form-array__row-body {
|
||||
|
|
@ -165,36 +168,31 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
/* 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:hover {
|
||||
background: var(--color-bg-alt, #f6f6f8);
|
||||
.form-welcome h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary, #1e3a5f);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary, #1e3a5f);
|
||||
.form-welcome h3 {
|
||||
margin: 1rem 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
const actions = u.h('div', { className: 'form-array__row-actions' });
|
||||
const removeBtn = u.h('button', {
|
||||
type: 'button',
|
||||
className: 'btn btn-small',
|
||||
className: 'btn btn-sm btn-secondary',
|
||||
title: 'Remove this row',
|
||||
onClick: function () { removeRow(rowEl); }
|
||||
}, '×');
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
if (addable) {
|
||||
const addBtn = u.h('button', {
|
||||
type: 'button',
|
||||
className: 'btn btn-small form-array__add',
|
||||
className: 'btn btn-sm btn-secondary form-array__add',
|
||||
onClick: function () { addRow(undefined); }
|
||||
}, '+ Add');
|
||||
wrap.appendChild(addBtn);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,54 @@
|
|||
(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><name>.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><name>.form.yaml</code> spec.</li>',
|
||||
'<li>Visit <code><path>/<name>.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) {
|
||||
const t = document.getElementById('form-title');
|
||||
// Standalone form.html has #form-title in its header; unified
|
||||
// tables.html bundle has #table-title (shared across modes).
|
||||
// Whichever exists, write to it.
|
||||
const t = document.getElementById('form-title') ||
|
||||
document.getElementById('table-title');
|
||||
if (t) {
|
||||
t.textContent = app.context.title;
|
||||
}
|
||||
|
|
@ -20,6 +63,12 @@
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Helm charts
|
||||
|
||||
Two example charts for deploying [zddc-server](../zddc/) on Kubernetes.
|
||||
Both compile zddc-server from source via an init container — no
|
||||
Three example charts for deploying [zddc-server](../zddc/) on Kubernetes.
|
||||
All 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,16 +11,24 @@ alpine + the freshly built static binary.
|
|||
|
||||
| Chart | When to use |
|
||||
|---|---|
|
||||
| **`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. |
|
||||
| **`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. |
|
||||
|
||||
The chart values are nearly identical between the two; the differences
|
||||
The prod and dev chart values are nearly identical; 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
|
||||
|
|
@ -48,6 +56,30 @@ 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
|
||||
|
|
@ -107,8 +139,12 @@ 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
|
||||
```
|
||||
|
|
|
|||
32
helm/zddc-server-cache/Chart.yaml
Normal file
32
helm/zddc-server-cache/Chart.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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
|
||||
33
helm/zddc-server-cache/templates/_helpers.tpl
Normal file
33
helm/zddc-server-cache/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{{/*
|
||||
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 -}}
|
||||
162
helm/zddc-server-cache/templates/deployment.yaml
Normal file
162
helm/zddc-server-cache/templates/deployment.yaml
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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
|
||||
29
helm/zddc-server-cache/templates/ingress.yaml
Normal file
29
helm/zddc-server-cache/templates/ingress.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{{- 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 }}
|
||||
15
helm/zddc-server-cache/templates/service.yaml
Normal file
15
helm/zddc-server-cache/templates/service.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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 }}
|
||||
159
helm/zddc-server-cache/values.yaml.example
Normal file
159
helm/zddc-server-cache/values.yaml.example
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# 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
|
||||
|
|
@ -147,6 +147,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ 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: ""
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,23 @@ 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.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ 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"
|
||||
|
||||
|
|
@ -26,6 +30,9 @@ 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"
|
||||
|
|
|
|||
|
|
@ -342,3 +342,179 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,8 +429,22 @@
|
|||
function openArchiveWith(names) {
|
||||
if (!names || names.length === 0) return;
|
||||
var base = location.pathname.replace(/\/[^\/]*$/, '/');
|
||||
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
|
||||
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(',')];
|
||||
if (v) params.push('v=' + encodeURIComponent(v));
|
||||
navigate(base + 'archive.html?' + params.join('&'));
|
||||
}
|
||||
|
|
@ -598,9 +612,214 @@
|
|||
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/<party>/</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();
|
||||
|
||||
|
|
@ -655,6 +874,9 @@
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main class="landing-main">
|
||||
<main id="landingMain" class="landing-main">
|
||||
<!-- Picker mode (deployment root /). Project picker + groups. -->
|
||||
<div id="pickerView">
|
||||
<!-- Welcome / hero -->
|
||||
<section class="landing-hero">
|
||||
<h1>Welcome to the ZDDC Archive</h1>
|
||||
|
|
@ -90,6 +92,56 @@
|
|||
<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/<party>/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 -->
|
||||
|
|
|
|||
|
|
@ -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/vendor/toastui-editor-all.min.js"
|
||||
toastui_css="$root_dir/vendor/toastui-editor.min.css"
|
||||
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
|
||||
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
|
|
@ -29,7 +29,11 @@ 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" \
|
||||
|
|
@ -38,9 +42,16 @@ 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" \
|
||||
|
|
|
|||
|
|
@ -221,14 +221,6 @@
|
|||
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). */
|
||||
|
|
@ -403,3 +395,11 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,9 +217,9 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
|
||||
tocDepthSelector.addEventListener('change', function () {
|
||||
const depth = parseInt(this.value);
|
||||
if (window.updateToc && editorInstance) {
|
||||
if (editorInstance) {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
window.updateToc(currentContent, tocContainer, editorInstance, depth);
|
||||
updateToc(currentContent, tocContainer, editorInstance, depth);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -266,16 +266,16 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
|
|||
}
|
||||
|
||||
// Generate initial TOC
|
||||
if (isMarkdown && window.updateToc && tocContainer) {
|
||||
if (isMarkdown && tocContainer) {
|
||||
try {
|
||||
window.updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
|
||||
updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error generating TOC:', error);
|
||||
}
|
||||
|
||||
const debouncedUpdateToc = debounce(() => {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
window.updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
|
||||
updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
|
||||
}, 300);
|
||||
|
||||
editorInstance.on('change', () => {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ function setupTocDepthSelector() {
|
|||
const content = instance.editor.getMarkdown();
|
||||
|
||||
try {
|
||||
window.updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
|
||||
updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC depth:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -471,9 +471,9 @@ async function reloadFileFromDisk(filePath) {
|
|||
}
|
||||
}, 100);
|
||||
|
||||
if (editorInstance.tocContainer && window.updateToc) {
|
||||
if (editorInstance.tocContainer) {
|
||||
try {
|
||||
window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
|
||||
updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC during reload:', error);
|
||||
}
|
||||
|
|
@ -683,9 +683,38 @@ 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];
|
||||
const lastSlash = href.lastIndexOf('/');
|
||||
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
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 + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
||||
|
|
|
|||
|
|
@ -630,9 +630,8 @@ async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModi
|
|||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
try {
|
||||
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');
|
||||
|
||||
// jszip + docx-preview bundled into the dist HTML; window.JSZip
|
||||
// and window.docx are available synchronously.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
docxContainer.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, docxContainer);
|
||||
|
|
@ -692,8 +691,8 @@ async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModi
|
|||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
try {
|
||||
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
||||
|
||||
// XLSX bundled into the dist HTML; window.XLSX is available
|
||||
// synchronously, no runtime load needed.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
|
|
|
|||
|
|
@ -250,7 +250,5 @@ function setActiveTocItem(tocContainer, headerText) {
|
|||
}
|
||||
}
|
||||
|
||||
// Export globally
|
||||
window.updateToc = updateToc;
|
||||
window.clearActiveTocItem = clearActiveTocItem;
|
||||
window.setActiveTocItem = setActiveTocItem;
|
||||
// Reachable at top-level scope to other concatenated mdedit JS files via the
|
||||
// build's flat-IIFE-less module pattern; no window.* exports needed.
|
||||
|
|
|
|||
|
|
@ -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" style="font-size:1.1rem;">⟳</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh">⟳</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" style="width: 450px; min-width: 200px;">
|
||||
<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-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,6 +59,13 @@
|
|||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { defineConfig } from '@playwright/test';
|
|||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
// 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,
|
||||
retries: 0,
|
||||
reporter: [['line'], ['html', { open: 'never' }]],
|
||||
|
||||
|
|
@ -47,6 +51,26 @@ 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',
|
||||
|
|
@ -71,6 +95,23 @@ 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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
238
shared/base.css
238
shared/base.css
|
|
@ -35,9 +35,17 @@
|
|||
/* Shape */
|
||||
--radius: 4px;
|
||||
|
||||
/* Typography */
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||
/* 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;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
|
||||
|
|
@ -45,9 +53,9 @@
|
|||
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--primary: #4a90c4;
|
||||
--primary-hover: #5ba3d9;
|
||||
--primary-active: #6ab5e8;
|
||||
--primary: #5fa8e0;
|
||||
--primary-hover: #74b6e6;
|
||||
--primary-active: #88c4ec;
|
||||
--primary-light: #1a3550;
|
||||
|
||||
--bg: #1e1e1e;
|
||||
|
|
@ -66,9 +74,9 @@
|
|||
|
||||
/* Manual dark override — wins over media query */
|
||||
[data-theme="dark"] {
|
||||
--primary: #4a90c4;
|
||||
--primary-hover: #5ba3d9;
|
||||
--primary-active: #6ab5e8;
|
||||
--primary: #5fa8e0;
|
||||
--primary-hover: #74b6e6;
|
||||
--primary-active: #88c4ec;
|
||||
--primary-light: #1a3550;
|
||||
|
||||
--bg: #1e1e1e;
|
||||
|
|
@ -103,8 +111,19 @@ 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 {
|
||||
|
|
@ -275,12 +294,31 @@ a:hover {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tool name inside the header */
|
||||
/* 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. */
|
||||
.app-header__title {
|
||||
font-size: 17px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +332,40 @@ 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;
|
||||
|
|
@ -327,7 +399,88 @@ a:hover {
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
|
||||
#theme-btn,
|
||||
|
|
@ -516,3 +669,62 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
shared/fonts.css
Normal file
30
shared/fonts.css
Normal file
File diff suppressed because one or more lines are too long
63
shared/fonts/README.md
Normal file
63
shared/fonts/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# 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.
|
||||
BIN
shared/fonts/ibm-plex-sans-400.woff2
Normal file
BIN
shared/fonts/ibm-plex-sans-400.woff2
Normal file
Binary file not shown.
BIN
shared/fonts/ibm-plex-sans-600.woff2
Normal file
BIN
shared/fonts/ibm-plex-sans-600.woff2
Normal file
Binary file not shown.
BIN
shared/fonts/source-serif-4-600.woff2
Normal file
BIN
shared/fonts/source-serif-4-600.woff2
Normal file
Binary file not shown.
21
shared/logo.css
Normal file
21
shared/logo.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/* 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;
|
||||
}
|
||||
82
shared/logo.js
Normal file
82
shared/logo.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// 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();
|
||||
}
|
||||
})();
|
||||
56
shared/nav.css
Normal file
56
shared/nav.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* 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);
|
||||
}
|
||||
204
shared/nav.js
Normal file
204
shared/nav.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -119,7 +119,10 @@
|
|||
opts = opts || {};
|
||||
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
|
||||
|
||||
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
|
||||
// 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 () {
|
||||
var ifds;
|
||||
try {
|
||||
ifds = window.UTIF.decode(arrayBuffer);
|
||||
|
|
@ -384,9 +387,9 @@
|
|||
opts = opts || {};
|
||||
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
|
||||
|
||||
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
|
||||
return window.JSZip.loadAsync(arrayBuffer);
|
||||
}).then(function (zip) {
|
||||
// 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) {
|
||||
var entries = [];
|
||||
zip.forEach(function (relativePath, zipEntry) {
|
||||
if (zipEntry.dir) return;
|
||||
|
|
|
|||
40
shared/toast.css
Normal file
40
shared/toast.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/* 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; }
|
||||
}
|
||||
76
shared/toast.js
Normal file
76
shared/toast.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// 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');
|
||||
};
|
||||
}
|
||||
})();
|
||||
1160
shared/vendor/utif.min.js
vendored
Normal file
1160
shared/vendor/utif.min.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
24
shared/vendor/xlsx.full.min.js
vendored
Normal file
24
shared/vendor/xlsx.full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -171,7 +171,24 @@
|
|||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i];
|
||||
var rawName = stripSlash(e.name);
|
||||
var childUrl = joinUrl(url, rawName, e.is_dir);
|
||||
// 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);
|
||||
}
|
||||
if (e.is_dir) {
|
||||
yield new HttpDirectoryHandle(childUrl, rawName);
|
||||
} else {
|
||||
|
|
|
|||
269
shared/zip-source.js
Normal file
269
shared/zip-source.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// 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); }
|
||||
};
|
||||
})();
|
||||
|
|
@ -18,23 +18,52 @@ 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"
|
||||
|
|
|
|||
|
|
@ -27,12 +27,17 @@
|
|||
margin: 0 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.table-toolbar__left {
|
||||
.table-toolbar__left,
|
||||
.table-toolbar__right {
|
||||
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;
|
||||
|
|
@ -98,14 +103,6 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -114,6 +111,82 @@
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,21 @@
|
|||
state: {
|
||||
rows: [],
|
||||
sort: [],
|
||||
filter: {}
|
||||
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: {}
|
||||
},
|
||||
modules: {}
|
||||
};
|
||||
|
|
|
|||
277
tables/js/clipboard.js
Normal file
277
tables/js/clipboard.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
// 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);
|
||||
|
|
@ -11,9 +11,11 @@
|
|||
// to a non-empty object, return it as-is.
|
||||
//
|
||||
// 2. File-backed walk (the real-world path served by zddc-server):
|
||||
// fetch <dir>/.zddc, find tables[<name>], fetch the *.table.yaml
|
||||
// spec, list <dir>/<name>/*.yaml row files, parse each, and
|
||||
// assemble the same shape.
|
||||
// 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.
|
||||
//
|
||||
// file:// mode without a directory handle is unsupported in v1 — the
|
||||
// walk only runs against http(s). file:// users must either inject an
|
||||
|
|
@ -75,32 +77,49 @@
|
|||
}
|
||||
const dir = probe.handle;
|
||||
|
||||
const zddcDoc = await readYaml(dir, '.zddc');
|
||||
const tablesMap = (zddcDoc && zddcDoc.tables) || {};
|
||||
const specRel = tablesMap[tableName];
|
||||
if (!specRel) {
|
||||
throw new Error('No tables.' + tableName + ' declared in .zddc');
|
||||
}
|
||||
const spec = await readYaml(dir, stripDotSlash(specRel));
|
||||
// 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');
|
||||
if (!spec || !Array.isArray(spec.columns)) {
|
||||
throw new Error('Spec ' + specRel + ' missing columns[]');
|
||||
throw new Error('Spec table.yaml missing columns[]');
|
||||
}
|
||||
|
||||
const rowsRel = stripDotSlash(spec.rows || ('./' + tableName));
|
||||
const rowsDir = await resolveDirectory(dir, rowsRel);
|
||||
const rows = await readRows(rowsDir, rowsRel, tableName);
|
||||
// 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);
|
||||
|
||||
return {
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
columns: spec.columns,
|
||||
defaults: spec.defaults,
|
||||
rowSchema: rowSchema,
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
function tableNameFromUrl(pathname) {
|
||||
const m = String(pathname || '').match(/\/([^\/]+)\.table\.html$/);
|
||||
// /<dir>/.../<rowsdir>/table.html → name is the rows-dir's
|
||||
// basename.
|
||||
const m = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
|
|
@ -146,17 +165,30 @@
|
|||
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 file = await (await rowsDir.getFileHandle(entry.name)).getFile();
|
||||
const handle = await rowsDir.getFileHandle(entry.name);
|
||||
const file = await handle.getFile();
|
||||
const data = window.jsyaml.load(await file.text());
|
||||
rows.push({
|
||||
url: rowEditUrl(rowsRel, tableName, entry.name),
|
||||
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$/, ''),
|
||||
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) {
|
||||
|
|
@ -166,14 +198,12 @@
|
|||
return rows;
|
||||
}
|
||||
|
||||
// Build the form-handler URL for editing one row. The page is at
|
||||
// <dir>/<tableName>.table.html; the row file lives at
|
||||
// <dir>/<rowsRel>/<basename>.yaml; the form re-edit URL is
|
||||
// <dir>/<rowsRel>/<basename>.yaml.html.
|
||||
function rowEditUrl(rowsRel, tableName, rowFileName) {
|
||||
const pageDir = location.pathname.replace(/\/[^\/]+\.table\.html$/, '/');
|
||||
const rowsPath = pageDir + (rowsRel || tableName) + '/';
|
||||
return rowsPath + rowFileName + '.html';
|
||||
// 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';
|
||||
}
|
||||
|
||||
app.modules.context = { load: load };
|
||||
|
|
|
|||
873
tables/js/editor.js
Normal file
873
tables/js/editor.js
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
// 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);
|
||||
|
|
@ -5,13 +5,17 @@
|
|||
// - 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 isEnumColumn(col) ? { kind: 'enum', value: [] } : { kind: 'contains', value: '' };
|
||||
function defaultFilterFor(_col) {
|
||||
return { kind: 'contains', value: '' };
|
||||
}
|
||||
|
||||
function rowMatches(filter, cellValue) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
'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;
|
||||
|
||||
|
|
@ -23,6 +29,22 @@
|
|||
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 : [];
|
||||
|
|
@ -40,14 +62,12 @@
|
|||
if (seeded == null) {
|
||||
continue;
|
||||
}
|
||||
if (app.modules.filters.isEnumColumn(col)) {
|
||||
state.filter[col.field] = {
|
||||
kind: 'enum',
|
||||
value: Array.isArray(seeded) ? seeded.slice() : [String(seeded)]
|
||||
};
|
||||
} else {
|
||||
state.filter[col.field] = { kind: 'contains', value: String(seeded) };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +94,30 @@
|
|||
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);
|
||||
paint();
|
||||
|
|
|
|||
76
tables/js/mode.js
Normal file
76
tables/js/mode.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// mode.js — picks table-mode vs form-mode at boot time and unhides the
|
||||
// matching container. Both apps (tablesApp, formApp) ship in the same
|
||||
// bundle but each only paints when its container is visible.
|
||||
//
|
||||
// Decision rule:
|
||||
// /<dir>/table.html → table mode
|
||||
// /<dir>/form.html → form mode (empty / create)
|
||||
// /<dir>/<id>.yaml.html → form mode (re-edit)
|
||||
// anything else / file:// → table mode (legacy default; tables tool
|
||||
// was the original consumer of this bundle)
|
||||
//
|
||||
// In offline / file:// mode the inline-context placeholders decide:
|
||||
// whichever blob is non-empty wins. Tests that inject only
|
||||
// #form-context render in form mode; tests that inject only
|
||||
// #table-context render in table mode.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function modeFromUrl() {
|
||||
const path = String((typeof location !== 'undefined' && location.pathname) || '');
|
||||
if (/\/form\.html$/.test(path) || /\.yaml\.html$/.test(path)) {
|
||||
return 'form';
|
||||
}
|
||||
if (/\/table\.html$/.test(path)) {
|
||||
return 'table';
|
||||
}
|
||||
return null; // unknown — will be decided once DOM is parsed.
|
||||
}
|
||||
|
||||
function readInline(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
try {
|
||||
return JSON.parse(el.textContent || '{}');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function modeFromInline() {
|
||||
// file:// or unrecognised URL — whichever inline-context blob is
|
||||
// non-empty wins. Tests that inject only #form-context render in
|
||||
// form mode; tests that inject only #table-context render in
|
||||
// table mode. Default to table for legacy compatibility.
|
||||
const formCtx = readInline('form-context');
|
||||
if (formCtx && Object.keys(formCtx).length > 0) {
|
||||
return 'form';
|
||||
}
|
||||
return 'table';
|
||||
}
|
||||
|
||||
// Best-effort synchronous decision so per-app boot guards can read
|
||||
// window.zddcMode without waiting for DOM. URL-based decision is
|
||||
// always known up-front; inline-context fallback only matters for
|
||||
// file:// and is finalized at DOMContentLoaded.
|
||||
window.zddcMode = modeFromUrl() || 'table';
|
||||
|
||||
function activate() {
|
||||
if (modeFromUrl() == null) {
|
||||
window.zddcMode = modeFromInline();
|
||||
}
|
||||
const tableEl = document.getElementById('table-mode');
|
||||
const formEl = document.getElementById('form-mode');
|
||||
if (window.zddcMode === 'form' && formEl) {
|
||||
formEl.hidden = false;
|
||||
} else if (tableEl) {
|
||||
tableEl.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', activate, { once: true });
|
||||
} else {
|
||||
activate();
|
||||
}
|
||||
})();
|
||||
|
|
@ -22,45 +22,23 @@
|
|||
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);
|
||||
const input = util.h('input', {
|
||||
type: 'text',
|
||||
className: 'zddc-table__filter-text',
|
||||
placeholder: 'filter…',
|
||||
'aria-label': 'Filter ' + (col.title || col.field),
|
||||
value: typeof f.value === 'string' ? f.value : '',
|
||||
onInput: function (ev) {
|
||||
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
|
||||
}
|
||||
td.appendChild(select);
|
||||
} else {
|
||||
const input = util.h('input', {
|
||||
type: 'text',
|
||||
className: 'zddc-table__filter-text',
|
||||
placeholder: 'filter…',
|
||||
'aria-label': 'Filter ' + (col.title || col.field),
|
||||
value: typeof f.value === 'string' ? f.value : '',
|
||||
onInput: function (ev) {
|
||||
onFilterChange(col.field, { kind: 'contains', value: ev.target.value });
|
||||
}
|
||||
});
|
||||
td.appendChild(input);
|
||||
}
|
||||
});
|
||||
td.appendChild(input);
|
||||
filterRow.appendChild(td);
|
||||
}
|
||||
|
||||
|
|
@ -70,32 +48,33 @@
|
|||
|
||||
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',
|
||||
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);
|
||||
}
|
||||
}
|
||||
'data-editable': row.editable ? '1' : '0'
|
||||
});
|
||||
const rowId = editor ? editor.rowKey(row) : (row.url || '');
|
||||
if (editor) {
|
||||
editor.attachToRow(tr, rowId);
|
||||
}
|
||||
for (let c = 0; c < columns.length; c++) {
|
||||
const col = columns[c];
|
||||
const raw = util.resolveField(row.data, col.field);
|
||||
const text = util.formatCell(raw, col.format);
|
||||
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
|
||||
// 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);
|
||||
}
|
||||
tbodyEl.appendChild(tr);
|
||||
}
|
||||
|
|
|
|||
411
tables/js/save.js
Normal file
411
tables/js/save.js
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// 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);
|
||||
115
tables/js/undo.js
Normal file
115
tables/js/undo.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// 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);
|
||||
|
|
@ -31,7 +31,8 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main class="table-main">
|
||||
<!-- Table mode: shown for /<dir>/table.html requests. -->
|
||||
<main id="table-mode" class="table-main" hidden>
|
||||
<div id="table-description" class="table-description" hidden></div>
|
||||
<div id="table-status" class="table-status" hidden></div>
|
||||
<div class="table-toolbar" id="table-toolbar">
|
||||
|
|
@ -39,6 +40,9 @@
|
|||
<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">
|
||||
|
|
@ -49,6 +53,18 @@
|
|||
<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">
|
||||
|
|
@ -57,27 +73,85 @@
|
|||
</div>
|
||||
<div class="help-panel__body">
|
||||
<h3>What is this table?</h3>
|
||||
<p>Each row in this table is one YAML file in the source directory.
|
||||
Tables are declared in <code>.zddc</code> via a
|
||||
<code>tables:</code> map. The columns and row schema come from
|
||||
a <code>*.table.yaml</code> spec file.</p>
|
||||
<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>
|
||||
|
||||
<h3>Sorting</h3>
|
||||
<p>Click a column header to sort by that column. Click again to
|
||||
toggle direction. Shift-click another header to add a secondary
|
||||
sort key.</p>
|
||||
toggle direction. <kbd>Shift</kbd>-click another header to
|
||||
add a secondary sort key.</p>
|
||||
|
||||
<h3>Filtering</h3>
|
||||
<p>Type in the box under a column header to filter rows whose
|
||||
value contains your text (case-insensitive). For columns with a
|
||||
fixed enum, the box becomes a multi-select — leave it empty to
|
||||
show every value.</p>
|
||||
value contains your text (case-insensitive). Same filter UI
|
||||
for every column.</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>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/<party>/mdl/
|
||||
table.yaml ← columns + sort/filter defaults
|
||||
form.yaml ← per-row schema (JSON Schema)
|
||||
<id>.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><dir>/table.html</code>
|
||||
is automatically a table whenever
|
||||
<code><dir>/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>Header buttons</h3>
|
||||
<dl>
|
||||
|
|
@ -101,6 +175,12 @@
|
|||
-->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,56 @@ 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
|
||||
|
|
@ -438,7 +488,11 @@ test.describe('Archive Browser', () => {
|
|||
|
||||
test('Preview toggle is checked by default', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#filePreviewToggle', { timeout: 15000 });
|
||||
// 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 expect(page.locator('#filePreviewToggle')).toBeChecked();
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue